mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
618e83714c | ||
|
|
64631a31e6 | ||
|
|
9fef6d6c91 | ||
|
|
f98bc330d4 | ||
|
|
86694dd926 | ||
|
|
f8da17719e | ||
|
|
a72e6087d8 | ||
|
|
3a64e322ad | ||
|
|
9e2853a5f8 | ||
|
|
feecb60442 | ||
|
|
e53e238e81 | ||
|
|
991aea85ee | ||
|
|
bfbf0bb892 | ||
|
|
b6cb4cc2d9 | ||
|
|
827481633c | ||
|
|
ad780b9cb4 | ||
|
|
4582ed16b0 | ||
|
|
f56505258d | ||
|
|
ddd1536bcb | ||
|
|
9fa5234061 | ||
|
|
e21efa61ef | ||
|
|
3f72c5d918 | ||
|
|
c286b88d54 | ||
|
|
22c08c2325 | ||
|
|
f6493e8843 | ||
|
|
1d2bfff8ea | ||
|
|
b967852891 | ||
|
|
d95d9583b0 | ||
|
|
8c0cce3ac5 | ||
|
|
f036bd54e3 | ||
|
|
77da90f3f8 | ||
|
|
b0b6f84492 | ||
|
|
0da8e32a51 | ||
|
|
7410faa96d | ||
|
|
e880dfa3e7 | ||
|
|
7791b3f170 | ||
|
|
e58039c83e | ||
|
|
9b0a7d2f61 | ||
|
|
86fe4929e9 | ||
|
|
9993718d9c | ||
|
|
0bf2f61ab1 | ||
|
|
51eff0d75f | ||
|
|
a30cce129e | ||
|
|
54885d8a1c | ||
|
|
617b4f81d8 | ||
|
|
a0f829383c | ||
|
|
6083c24484 | ||
|
|
1964545f35 | ||
|
|
fc7e971d7e | ||
|
|
86309c8e40 | ||
|
|
282ad3a960 | ||
|
|
d6f857eee2 | ||
|
|
751b420b39 | ||
|
|
5b2be80399 | ||
|
|
6ae8dd4cfd | ||
|
|
25a66df41b | ||
|
|
5212730773 | ||
|
|
9b7496947b | ||
|
|
895e94ccac | ||
|
|
da9d8a84f1 | ||
|
|
e7b9143423 | ||
|
|
604e4ea7e3 | ||
|
|
c926372d81 | ||
|
|
da12f47369 | ||
|
|
b7c81965a1 | ||
|
|
f65dc775eb | ||
|
|
c59041a09d | ||
|
|
715f37effc | ||
|
|
570e1941b2 | ||
|
|
4c700c8d2d | ||
|
|
5ae554ed51 | ||
|
|
62b697c90e | ||
|
|
4b3b602457 | ||
|
|
d7bd5c6636 | ||
|
|
66e6eb2509 | ||
|
|
1365fc9635 | ||
|
|
78669863b7 | ||
|
|
316bbe11c3 | ||
|
|
ff00499e8e | ||
|
|
1a5ae4e3ce | ||
|
|
bf9066a602 | ||
|
|
acf51e02a8 | ||
|
|
1c9d53df11 | ||
|
|
b298c4c273 | ||
|
|
f407a41a98 | ||
|
|
173c843107 | ||
|
|
b3787e07ea | ||
|
|
935c8cad88 | ||
|
|
d95270da5a | ||
|
|
7a2211d0f4 | ||
|
|
e14885fa80 | ||
|
|
ccbaf90a24 | ||
|
|
f66c2525cb | ||
|
|
842d932ae6 | ||
|
|
37d1c822f2 | ||
|
|
865fd28704 | ||
|
|
43a92a764f | ||
|
|
17f783073e | ||
|
|
db86ff4ce3 | ||
|
|
ee042769e2 | ||
|
|
41ffb8537a | ||
|
|
80658c94e3 | ||
|
|
c35301e155 | ||
|
|
14f0421e7b | ||
|
|
3f772b5b27 | ||
|
|
a12547081a | ||
|
|
63f4befe80 | ||
|
|
0f25386e2e | ||
|
|
7f752dde99 | ||
|
|
93ef98a429 | ||
|
|
b71bd801fb | ||
|
|
9badeb243e | ||
|
|
9a683ec511 | ||
|
|
eaa89defd1 | ||
|
|
d245059a01 | ||
|
|
c30bf3e557 | ||
|
|
dd95f24980 | ||
|
|
1cf36241e4 | ||
|
|
2ab31f3d3e | ||
|
|
2915cb2113 | ||
|
|
7b1057df0c | ||
|
|
0b99ac4071 | ||
|
|
19260945bd | ||
|
|
93ea832394 | ||
|
|
38d42c4d9b | ||
|
|
2d02ba22fb | ||
|
|
91d8734198 | ||
|
|
12ab5f1a93 | ||
|
|
a5bc9f916a | ||
|
|
935f3fac8e | ||
|
|
ad90d417cc | ||
|
|
4eae7502d2 | ||
|
|
d6326d8967 | ||
|
|
8ec3766da3 | ||
|
|
90fc171e26 | ||
|
|
972a52ff9c | ||
|
|
6b78838416 | ||
|
|
13051b2f06 | ||
|
|
aed4bf62ae | ||
|
|
a039e395f5 | ||
|
|
67b30c1549 | ||
|
|
40bdf4316b | ||
|
|
e3c05a9d47 | ||
|
|
6ab77612f5 | ||
|
|
a3073007a1 | ||
|
|
a95fb4a96b |
@@ -222,12 +222,12 @@ jobs:
|
||||
- name: Set up QEMU (for arm64 emulation)
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push (multi-arch)
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
Generated
+718
-216
File diff suppressed because it is too large
Load Diff
+13
-6
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.8"
|
||||
version = "0.5.5"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -32,7 +32,7 @@ tokio-stream = "0.1"
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
toml = "0.9"
|
||||
rmp-serde = "1"
|
||||
|
||||
# Error handling
|
||||
@@ -75,6 +75,7 @@ bytes = "1"
|
||||
|
||||
# Futures
|
||||
futures = "0.3"
|
||||
prost = "0.13"
|
||||
|
||||
# WebSocket client (for Discord/Slack gateway)
|
||||
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
|
||||
@@ -84,7 +85,7 @@ url = "2"
|
||||
wasmtime = "41"
|
||||
|
||||
# HTTP server (for API daemon)
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
axum = { version = "0.8", features = ["ws", "multipart"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "compression-br"] }
|
||||
|
||||
@@ -113,7 +114,7 @@ rand = "0.8"
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
|
||||
# Rate limiting
|
||||
governor = "0.8"
|
||||
governor = "0.10"
|
||||
|
||||
# Interactive CLI
|
||||
ratatui = "0.29"
|
||||
@@ -129,18 +130,24 @@ html-escape = "0.2"
|
||||
# Lightweight regex
|
||||
regex-lite = "0.1"
|
||||
|
||||
# MCP SDK (official Rust implementation)
|
||||
rmcp = { version = "1.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "reqwest"] }
|
||||
|
||||
# Socket options (SO_REUSEADDR)
|
||||
socket2 = "0.5"
|
||||
|
||||
# Zip archive extraction
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
zip = { version = "4", default-features = false, features = ["deflate"] }
|
||||
|
||||
# Email (SMTP + IMAP)
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
|
||||
imap = "2"
|
||||
native-tls = "0.2"
|
||||
native-tls = { version = "0.2", features = ["vendored"] }
|
||||
mailparse = "0.16"
|
||||
|
||||
# MQTT client
|
||||
rumqttc = "0.24"
|
||||
|
||||
# OpenSSL (vendored = statically compiled, no runtime libssl dependency on Linux)
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
LangChain Code Review Agent — core review logic.
|
||||
|
||||
Supports OpenAI, Ollama, and any LangChain-compatible LLM.
|
||||
"""
|
||||
|
||||
import os
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain_core.output_parsers import StrOutputParser
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
You are a principal-level code reviewer with 15+ years of production experience \
|
||||
across multiple languages (Python, Rust, TypeScript, Java, Go, C/C++).
|
||||
You receive code snippets, diffs, or pull request descriptions and produce a \
|
||||
structured, actionable review report.
|
||||
|
||||
You MUST respond in **中文**, but keep code snippets, variable names, and \
|
||||
technical terms in their original language.
|
||||
|
||||
# ── 审核维度(按优先级排序) ──────────────────────────────
|
||||
|
||||
## 1. 正确性 (Correctness)
|
||||
- 逻辑错误、off-by-one、边界条件
|
||||
- 空指针 / None / undefined 未处理
|
||||
- 错误处理不完整(吞异常、漏 catch、panic 路径)
|
||||
- 并发问题:竞态条件、死锁、数据竞争
|
||||
- 类型安全:隐式转换、溢出、精度丢失
|
||||
- 资源泄漏:未关闭的文件/连接/锁
|
||||
|
||||
## 2. 安全性 (Security)
|
||||
- SQL / NoSQL / OS 命令注入
|
||||
- XSS、CSRF、SSRF
|
||||
- 硬编码密钥、token、密码
|
||||
- 不安全的反序列化
|
||||
- 路径穿越(Path Traversal)
|
||||
- 缺少输入校验 / 输出编码
|
||||
- 权限检查缺失或绕过
|
||||
- 敏感数据明文日志
|
||||
|
||||
## 3. 性能 (Performance)
|
||||
- 算法复杂度不合理(O(n²) 可优化为 O(n))
|
||||
- 不必要的内存分配 / 拷贝
|
||||
- N+1 查询、缺少批量操作
|
||||
- 阻塞 I/O 在异步上下文中
|
||||
- 缺少缓存 / 索引
|
||||
- 热路径上的正则编译 / 反射
|
||||
|
||||
## 4. 可维护性 (Maintainability)
|
||||
- 命名不清晰、缩写歧义
|
||||
- 函数过长(>50行建议拆分)
|
||||
- 重复代码(DRY 违反)
|
||||
- 职责不单一(SRP 违反)
|
||||
- 缺少必要注释(复杂业务逻辑、非显而易见的决策)
|
||||
- 魔法数字 / 字符串
|
||||
- 耦合过紧、依赖方向不合理
|
||||
|
||||
## 5. 测试 (Testing)
|
||||
- 关键路径缺少单元测试
|
||||
- 测试覆盖了 happy path 但遗漏了 edge case
|
||||
- 测试中有硬编码依赖(时间、文件路径、网络)
|
||||
- Mock 过度导致测试失去意义
|
||||
|
||||
## 6. 风格 (Style)
|
||||
- 不符合语言惯例(Pythonic、Rust idiom 等)
|
||||
- 格式不一致(应由 formatter 处理的除外)
|
||||
- 不必要的复杂写法
|
||||
|
||||
# ── 严重级别 ──────────────────────────────────────────
|
||||
|
||||
| 级别 | 含义 | 是否阻塞合并 |
|
||||
|------|------|-------------|
|
||||
| 🔴 **[必须修复]** | 存在 bug、安全漏洞或数据丢失风险 | 是 |
|
||||
| 🟡 **[建议修复]** | 不影响功能但会影响可维护性或性能 | 否,但强烈建议 |
|
||||
| 🔵 **[小建议]** | 风格、命名等微小改进 | 否 |
|
||||
| 🟢 **[亮点]** | 写得好的地方,值得肯定 | — |
|
||||
|
||||
# ── 输出格式 ──────────────────────────────────────────
|
||||
|
||||
严格按以下 Markdown 格式输出:
|
||||
|
||||
```
|
||||
## 📋 总结
|
||||
**结论**: [✅ 通过 / ⚠️ 需要修改 / 💬 仅评论]
|
||||
**概述**: [1-2 句话总体评价]
|
||||
**发现统计**: 🔴 X 个必须修复 | 🟡 X 个建议修复 | 🔵 X 个小建议 | 🟢 X 个亮点
|
||||
|
||||
---
|
||||
|
||||
## 🔍 详细发现
|
||||
|
||||
### 🔴 [必须修复] 问题标题
|
||||
- **位置**: `文件名` 第 X-Y 行
|
||||
- **问题**: 具体描述
|
||||
- **原因**: 为什么这是个问题,可能造成什么后果
|
||||
- **修复建议**:
|
||||
(给出修复后的代码)
|
||||
|
||||
### 🟡 [建议修复] 问题标题
|
||||
...
|
||||
|
||||
### 🔵 [小建议] 问题标题
|
||||
...
|
||||
|
||||
### 🟢 [亮点] 优点标题
|
||||
- **位置**: `文件名` 第 X-Y 行
|
||||
- **说明**: 为什么这段代码写得好
|
||||
|
||||
---
|
||||
|
||||
## 📊 评分
|
||||
| 维度 | 分数 | 说明 |
|
||||
|------|------|------|
|
||||
| 正确性 | X/10 | 一句话说明 |
|
||||
| 安全性 | X/10 | 一句话说明 |
|
||||
| 性能 | X/10 | 一句话说明 |
|
||||
| 可维护性 | X/10 | 一句话说明 |
|
||||
| 测试 | X/10 | 一句话说明 |
|
||||
| **综合** | **X/10** | 一句话总结 |
|
||||
```
|
||||
|
||||
# ── 审核原则 ──────────────────────────────────────────
|
||||
|
||||
1. **先肯定,再指出问题** — 不要只挑毛病,好的代码也要指出来
|
||||
2. **解释 WHY,不仅是 WHAT** — 每个问题都要说清楚「为什么不好」和「可能导致什么后果」
|
||||
3. **给出具体修复代码** — 不要只说"这里有问题",要给出改好后的写法
|
||||
4. **区分严重级别** — 不要把小问题标成必须修复,也不要把严重 bug 标成小建议
|
||||
5. **尊重作者** — 用建设性的语气,避免 "这是错的" 这种措辞,用 "这里可以改进为..."
|
||||
6. **不纠结格式** — 如果项目有 formatter/linter,格式问题跳过
|
||||
7. **关注变更本身** — 如果是 diff,只审核变更的部分,不要评论未修改的代码
|
||||
8. **没有代码时** — 直接要求提交代码,不要编造审核结果"""
|
||||
|
||||
|
||||
def _build_llm():
|
||||
"""Build the LLM based on environment configuration."""
|
||||
use_ollama = os.getenv("USE_OLLAMA", "").lower() in ("1", "true", "yes")
|
||||
|
||||
if use_ollama:
|
||||
from langchain_ollama import ChatOllama
|
||||
model = os.getenv("OLLAMA_MODEL", "qwen2.5")
|
||||
base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
return ChatOllama(model=model, base_url=base_url, temperature=0.2)
|
||||
|
||||
provider = os.getenv("LLM_PROVIDER", "openai").lower()
|
||||
|
||||
if provider == "deepseek":
|
||||
from langchain_openai import ChatOpenAI
|
||||
return ChatOpenAI(
|
||||
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
|
||||
api_key=os.getenv("DEEPSEEK_API_KEY"),
|
||||
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
|
||||
temperature=0.2,
|
||||
max_tokens=4096,
|
||||
)
|
||||
|
||||
from langchain_openai import ChatOpenAI
|
||||
return ChatOpenAI(
|
||||
model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
|
||||
temperature=0.2,
|
||||
max_tokens=4096,
|
||||
)
|
||||
|
||||
|
||||
class CodeReviewAgent:
|
||||
"""LangChain-based code review agent."""
|
||||
|
||||
def __init__(self):
|
||||
self.llm = _build_llm()
|
||||
self.prompt = ChatPromptTemplate.from_messages([
|
||||
("system", SYSTEM_PROMPT),
|
||||
("human", "{input}"),
|
||||
])
|
||||
self.chain = self.prompt | self.llm | StrOutputParser()
|
||||
|
||||
def review(self, code_or_diff: str) -> str:
|
||||
"""
|
||||
Review the given code or diff.
|
||||
|
||||
Args:
|
||||
code_or_diff: Source code, git diff, or PR description to review.
|
||||
|
||||
Returns:
|
||||
Structured review report as markdown text.
|
||||
"""
|
||||
if not code_or_diff.strip():
|
||||
return "No code provided. Please submit code or a diff to review."
|
||||
|
||||
return self.chain.invoke({"input": code_or_diff})
|
||||
@@ -0,0 +1,10 @@
|
||||
# Add this section to your ~/.openfang/config.toml
|
||||
# to register the LangChain code review agent.
|
||||
|
||||
[a2a]
|
||||
enabled = true
|
||||
listen_path = "/a2a"
|
||||
|
||||
[[a2a.external_agents]]
|
||||
name = "langchain-code-reviewer"
|
||||
url = "http://127.0.0.1:9100"
|
||||
@@ -0,0 +1,6 @@
|
||||
langchain>=0.3
|
||||
langchain-openai>=0.3
|
||||
langchain-core>=0.3
|
||||
langchain-ollama>=0.3
|
||||
fastapi>=0.115
|
||||
uvicorn>=0.34
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
LangChain Code Review Agent — A2A-compatible server.
|
||||
|
||||
Exposes a code review agent via Google's A2A protocol so that
|
||||
OpenFang workflows can call it as an external agent.
|
||||
|
||||
Start:
|
||||
OPENAI_API_KEY=sk-xxx python server.py
|
||||
# or with Ollama (no key needed):
|
||||
USE_OLLAMA=1 python server.py
|
||||
|
||||
Endpoints:
|
||||
GET /.well-known/agent.json — A2A Agent Card
|
||||
POST /a2a — JSON-RPC task endpoint
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
import uvicorn
|
||||
|
||||
from agent import CodeReviewAgent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HOST = os.getenv("HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("PORT", "9100"))
|
||||
BASE_URL = os.getenv("BASE_URL", f"http://127.0.0.1:{PORT}")
|
||||
|
||||
app = FastAPI(title="LangChain Code Review Agent")
|
||||
agent = CodeReviewAgent()
|
||||
|
||||
# In-memory task store
|
||||
tasks: dict[str, dict] = {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A2A Agent Card
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AGENT_CARD = {
|
||||
"name": "langchain-code-reviewer",
|
||||
"description": (
|
||||
"LangChain-powered code review agent. "
|
||||
"Analyzes code for bugs, security issues, performance problems, "
|
||||
"and style violations. Returns structured review with severity levels."
|
||||
),
|
||||
"url": f"{BASE_URL}/a2a",
|
||||
"version": "0.1.0",
|
||||
"capabilities": {
|
||||
"streaming": False,
|
||||
"pushNotifications": False,
|
||||
"stateTransitionHistory": True,
|
||||
},
|
||||
"skills": [
|
||||
{
|
||||
"id": "code-review",
|
||||
"name": "Code Review",
|
||||
"description": "Review code for correctness, security, performance, and style",
|
||||
"tags": ["code", "review", "security", "quality"],
|
||||
"examples": [
|
||||
"Review this Python function for bugs",
|
||||
"Check this Rust code for security issues",
|
||||
"Analyze this PR diff for performance problems",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "pr-review",
|
||||
"name": "Pull Request Review",
|
||||
"description": "Review a git diff / pull request",
|
||||
"tags": ["pr", "diff", "git"],
|
||||
"examples": [
|
||||
"Review this PR diff",
|
||||
"Analyze these changes",
|
||||
],
|
||||
},
|
||||
],
|
||||
"defaultInputModes": ["text"],
|
||||
"defaultOutputModes": ["text"],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/.well-known/agent.json")
|
||||
async def agent_card():
|
||||
return JSONResponse(content=AGENT_CARD)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A2A JSON-RPC Endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.post("/a2a")
|
||||
async def a2a_endpoint(request: Request):
|
||||
body = await request.json()
|
||||
|
||||
jsonrpc = body.get("jsonrpc", "2.0")
|
||||
req_id = body.get("id", 1)
|
||||
method = body.get("method", "")
|
||||
params = body.get("params", {})
|
||||
|
||||
if method == "tasks/send":
|
||||
return await handle_tasks_send(jsonrpc, req_id, params)
|
||||
elif method == "tasks/get":
|
||||
return handle_tasks_get(jsonrpc, req_id, params)
|
||||
elif method == "tasks/cancel":
|
||||
return handle_tasks_cancel(jsonrpc, req_id, params)
|
||||
else:
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"error": {"code": -32601, "message": f"Method not found: {method}"},
|
||||
})
|
||||
|
||||
|
||||
async def handle_tasks_send(jsonrpc: str, req_id: int, params: dict):
|
||||
message = params.get("message", {})
|
||||
session_id = params.get("sessionId")
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
text_parts = [
|
||||
p["text"] for p in message.get("parts", []) if p.get("type") == "text"
|
||||
]
|
||||
user_input = "\n".join(text_parts)
|
||||
|
||||
task = {
|
||||
"id": task_id,
|
||||
"sessionId": session_id,
|
||||
"status": {"state": "working", "message": None},
|
||||
"messages": [message],
|
||||
"artifacts": [],
|
||||
}
|
||||
tasks[task_id] = task
|
||||
|
||||
try:
|
||||
review_result = await asyncio.to_thread(agent.review, user_input)
|
||||
|
||||
agent_message = {
|
||||
"role": "agent",
|
||||
"parts": [{"type": "text", "text": review_result}],
|
||||
}
|
||||
task["messages"].append(agent_message)
|
||||
task["status"] = {"state": "completed", "message": None}
|
||||
task["artifacts"] = [
|
||||
{
|
||||
"name": "code-review-report",
|
||||
"description": "Structured code review report",
|
||||
"parts": [{"type": "text", "text": review_result}],
|
||||
"index": 0,
|
||||
"lastChunk": True,
|
||||
}
|
||||
]
|
||||
except Exception as e:
|
||||
task["status"] = {"state": "failed", "message": str(e)}
|
||||
task["messages"].append({
|
||||
"role": "agent",
|
||||
"parts": [{"type": "text", "text": f"Review failed: {e}"}],
|
||||
})
|
||||
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"result": task,
|
||||
})
|
||||
|
||||
|
||||
def handle_tasks_get(jsonrpc: str, req_id: int, params: dict):
|
||||
task_id = params.get("id", "")
|
||||
task = tasks.get(task_id)
|
||||
|
||||
if task is None:
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"error": {"code": -32000, "message": f"Task not found: {task_id}"},
|
||||
})
|
||||
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"result": task,
|
||||
})
|
||||
|
||||
|
||||
def handle_tasks_cancel(jsonrpc: str, req_id: int, params: dict):
|
||||
task_id = params.get("id", "")
|
||||
task = tasks.get(task_id)
|
||||
|
||||
if task is None:
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"error": {"code": -32000, "message": f"Task not found: {task_id}"},
|
||||
})
|
||||
|
||||
task["status"] = {"state": "cancelled", "message": None}
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"result": task,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "agent": "langchain-code-reviewer", "tasks": len(tasks)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Starting LangChain Code Review Agent on {HOST}:{PORT}")
|
||||
print(f"Agent Card: {BASE_URL}/.well-known/agent.json")
|
||||
print(f"A2A endpoint: {BASE_URL}/a2a")
|
||||
uvicorn.run(app, host=HOST, port=PORT)
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"name": "langchain-code-review-pipeline",
|
||||
"description": "Code review pipeline: uses LangChain external agent for deep review, then OpenFang Writer agent to format the final report.",
|
||||
"created_at": "2026-03-16T00:00:00Z",
|
||||
"steps": [
|
||||
{
|
||||
"name": "review-code",
|
||||
"agent": { "name": "a2a-proxy" },
|
||||
"prompt_template": "Use the a2a_send tool to send the following code to the external agent for code review. Set agent_name to langchain-code-reviewer and set message to the code below. Return the complete review result:\n\n{{input}}",
|
||||
"mode": "sequential",
|
||||
"timeout_secs": 300,
|
||||
"output_var": "review_result"
|
||||
},
|
||||
{
|
||||
"name": "format-report",
|
||||
"agent": { "name": "Writer" },
|
||||
"prompt_template": "Format the following code review into a clean, professional report. Preserve all severity levels and scores. Add a brief executive summary at the top:\n\n{{review_result}}",
|
||||
"mode": "sequential",
|
||||
"timeout_secs": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -30,6 +30,7 @@ use openfang_channels::messenger::MessengerAdapter;
|
||||
use openfang_channels::reddit::RedditAdapter;
|
||||
use openfang_channels::revolt::RevoltAdapter;
|
||||
use openfang_channels::viber::ViberAdapter;
|
||||
use openfang_types::config::FeishuMode;
|
||||
// Wave 4
|
||||
use openfang_channels::flock::FlockAdapter;
|
||||
use openfang_channels::guilded::GuildedAdapter;
|
||||
@@ -49,6 +50,7 @@ use openfang_channels::gitter::GitterAdapter;
|
||||
use openfang_channels::gotify::GotifyAdapter;
|
||||
use openfang_channels::linkedin::LinkedInAdapter;
|
||||
use openfang_channels::mumble::MumbleAdapter;
|
||||
use openfang_channels::mqtt::MqttAdapter;
|
||||
use openfang_channels::ntfy::NtfyAdapter;
|
||||
use openfang_channels::webhook::WebhookAdapter;
|
||||
use openfang_channels::wecom::WeComAdapter;
|
||||
@@ -809,6 +811,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
"webhook" => channels.webhook.as_ref().map(|c| c.overrides.clone()),
|
||||
"linkedin" => channels.linkedin.as_ref().map(|c| c.overrides.clone()),
|
||||
"wecom" => channels.wecom.as_ref().map(|c| c.overrides.clone()),
|
||||
"mqtt" => channels.mqtt.as_ref().map(|c| c.overrides.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1219,6 +1222,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
mx_config.user_id.clone(),
|
||||
token,
|
||||
mx_config.allowed_rooms.clone(),
|
||||
mx_config.auto_accept_invites,
|
||||
));
|
||||
adapters.push((adapter, mx_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1430,16 +1434,22 @@ pub async fn start_channel_bridge_with_config(
|
||||
.encrypt_key_env
|
||||
.as_ref()
|
||||
.and_then(|env| read_token(env, "Feishu encrypt_key"));
|
||||
let adapter = Arc::new(FeishuAdapter::with_config(
|
||||
fs_config.app_id.clone(),
|
||||
secret,
|
||||
fs_config.webhook_port,
|
||||
region,
|
||||
Some(fs_config.webhook_path.clone()),
|
||||
fs_config.verification_token.clone(),
|
||||
encrypt_key,
|
||||
fs_config.bot_names.clone(),
|
||||
));
|
||||
let adapter = match fs_config.mode {
|
||||
FeishuMode::Webhook => Arc::new(FeishuAdapter::with_config(
|
||||
fs_config.app_id.clone(),
|
||||
secret,
|
||||
fs_config.webhook_port,
|
||||
region,
|
||||
Some(fs_config.webhook_path.clone()),
|
||||
fs_config.verification_token.clone(),
|
||||
encrypt_key,
|
||||
fs_config.bot_names.clone(),
|
||||
)),
|
||||
FeishuMode::Websocket => Arc::new(FeishuAdapter::new_websocket(
|
||||
fs_config.app_id.clone(),
|
||||
secret,
|
||||
)),
|
||||
};
|
||||
adapters.push((adapter, fs_config.default_agent.clone()));
|
||||
}
|
||||
}
|
||||
@@ -1670,6 +1680,25 @@ pub async fn start_channel_bridge_with_config(
|
||||
}
|
||||
}
|
||||
|
||||
// MQTT
|
||||
if let Some(ref mq_config) = config.mqtt {
|
||||
let username = read_token(&mq_config.username_env, "MQTT (username)");
|
||||
let password = read_token(&mq_config.password_env, "MQTT (password)");
|
||||
let adapter = Arc::new(MqttAdapter::new(
|
||||
mq_config.broker_url.clone(),
|
||||
mq_config.client_id.clone(),
|
||||
mq_config.subscribe_topic.clone(),
|
||||
mq_config.publish_topic.clone(),
|
||||
username,
|
||||
password,
|
||||
mq_config.use_tls,
|
||||
mq_config.keep_alive_secs,
|
||||
mq_config.clean_session,
|
||||
mq_config.qos,
|
||||
));
|
||||
adapters.push((adapter, mq_config.default_agent.clone()));
|
||||
}
|
||||
|
||||
if adapters.is_empty() {
|
||||
return (None, Vec::new());
|
||||
}
|
||||
@@ -1874,4 +1903,35 @@ mod tests {
|
||||
assert!(config.channels.webhook.is_none());
|
||||
assert!(config.channels.linkedin.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_feishu_bridge_mode_defaults_to_websocket() {
|
||||
let config: openfang_types::config::KernelConfig = toml::from_str(
|
||||
r#"
|
||||
[channels.feishu]
|
||||
app_id = "cli_test"
|
||||
app_secret_env = "FEISHU_APP_SECRET"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let feishu = config.channels.feishu.expect("feishu config should exist");
|
||||
assert_eq!(feishu.mode, openfang_types::config::FeishuMode::Websocket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_feishu_bridge_mode_supports_websocket() {
|
||||
let config: openfang_types::config::KernelConfig = toml::from_str(
|
||||
r#"
|
||||
[channels.feishu]
|
||||
app_id = "cli_test"
|
||||
app_secret_env = "FEISHU_APP_SECRET"
|
||||
mode = "websocket"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let feishu = config.channels.feishu.expect("feishu config should exist");
|
||||
assert_eq!(feishu.mode, openfang_types::config::FeishuMode::Websocket);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,13 +236,16 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
|
||||
headers.insert("x-content-type-options", "nosniff".parse().unwrap());
|
||||
headers.insert("x-frame-options", "DENY".parse().unwrap());
|
||||
headers.insert("x-xss-protection", "1; mode=block".parse().unwrap());
|
||||
// All JS/CSS is bundled inline — only external resource is Google Fonts.
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; font-src 'self' https://fonts.gstatic.com; media-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
// The dashboard handler (webchat_page) sets its own nonce-based CSP.
|
||||
// For all other responses (API endpoints), apply a strict default.
|
||||
if !headers.contains_key("content-security-policy") {
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
headers.insert(
|
||||
"referrer-policy",
|
||||
"strict-origin-when-cross-origin".parse().unwrap(),
|
||||
|
||||
@@ -379,7 +379,7 @@ async fn stream_response(
|
||||
|
||||
let (mut rx, _handle) = state
|
||||
.kernel
|
||||
.send_message_streaming(agent_id, message, Some(kernel_handle), None, None)
|
||||
.send_message_streaming(agent_id, message, Some(kernel_handle), None, None, None)
|
||||
.map_err(|e| format!("Streaming setup failed: {e}"))?;
|
||||
|
||||
let (tx, stream_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(64);
|
||||
|
||||
@@ -29,6 +29,7 @@ pub fn operation_cost(method: &str, path: &str) -> NonZeroU32 {
|
||||
("POST", p) if p.contains("/run") => NonZeroU32::new(100).unwrap(),
|
||||
("POST", "/api/skills/install") => NonZeroU32::new(50).unwrap(),
|
||||
("POST", "/api/skills/uninstall") => NonZeroU32::new(10).unwrap(),
|
||||
("POST", "/api/skills/reload") => NonZeroU32::new(5).unwrap(),
|
||||
("POST", "/api/migrate") => NonZeroU32::new(100).unwrap(),
|
||||
("PUT", p) if p.contains("/update") => NonZeroU32::new(10).unwrap(),
|
||||
_ => NonZeroU32::new(5).unwrap(),
|
||||
|
||||
+358
-134
@@ -1,11 +1,12 @@
|
||||
//! Route handlers for the OpenFang API.
|
||||
|
||||
use crate::types::*;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::extract::{Multipart, Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use dashmap::DashMap;
|
||||
use openfang_channels::bridge::channel_command_specs;
|
||||
use openfang_kernel::triggers::{TriggerId, TriggerPattern};
|
||||
use openfang_kernel::workflow::{
|
||||
ErrorMode, StepAgent, StepMode, Workflow, WorkflowId, WorkflowStep,
|
||||
@@ -40,6 +41,9 @@ pub struct AppState {
|
||||
/// Avoids blocking the `/api/providers` endpoint on TCP timeouts to
|
||||
/// unreachable local services. 60-second TTL.
|
||||
pub provider_probe_cache: openfang_runtime::provider_health::ProbeCache,
|
||||
/// Thread-safe mutable budget config. Updated via PUT /api/budget.
|
||||
/// Initialized from `kernel.config.budget` at startup.
|
||||
pub budget_config: Arc<tokio::sync::RwLock<openfang_types::config::BudgetConfig>>,
|
||||
}
|
||||
|
||||
/// POST /api/agents — Spawn a new agent.
|
||||
@@ -358,21 +362,28 @@ pub async fn send_message(
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve file attachments into image content blocks
|
||||
if !req.attachments.is_empty() {
|
||||
// Resolve file attachments into image content blocks.
|
||||
// Pass them as content_blocks so the LLM receives them in the current turn
|
||||
// (not as a separate session message which the LLM may not process).
|
||||
let content_blocks = if !req.attachments.is_empty() {
|
||||
let image_blocks = resolve_attachments(&req.attachments);
|
||||
if !image_blocks.is_empty() {
|
||||
inject_attachments_into_session(&state.kernel, agent_id, image_blocks);
|
||||
if image_blocks.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(image_blocks)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let kernel_handle: Arc<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
|
||||
match state
|
||||
.kernel
|
||||
.send_message_with_handle(
|
||||
.send_message_with_handle_and_blocks(
|
||||
agent_id,
|
||||
&req.message,
|
||||
Some(kernel_handle),
|
||||
content_blocks,
|
||||
req.sender_id,
|
||||
req.sender_name,
|
||||
)
|
||||
@@ -568,9 +579,7 @@ pub async fn get_agent_session(
|
||||
msg.get_mut("tools").and_then(|v| v.as_array_mut())
|
||||
{
|
||||
if let Some(tool_obj) = tools_arr.get_mut(tool_idx) {
|
||||
let preview: String =
|
||||
result.chars().take(2000).collect();
|
||||
tool_obj["result"] = serde_json::Value::String(preview);
|
||||
tool_obj["result"] = serde_json::Value::String(result.clone());
|
||||
tool_obj["is_error"] =
|
||||
serde_json::Value::Bool(*is_error);
|
||||
}
|
||||
@@ -841,7 +850,26 @@ pub async fn create_workflow(
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let id = state.kernel.register_workflow(workflow).await;
|
||||
let id = state.kernel.register_workflow(workflow.clone()).await;
|
||||
|
||||
// Persist workflow to disk so it survives daemon restarts (#751)
|
||||
let wf_dir = state
|
||||
.kernel
|
||||
.config
|
||||
.workflows_dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| state.kernel.config.home_dir.join("workflows"));
|
||||
if let Err(e) = std::fs::create_dir_all(&wf_dir) {
|
||||
tracing::warn!("Failed to create workflows dir: {e}");
|
||||
} else {
|
||||
let wf_path = wf_dir.join(format!("{}.json", id));
|
||||
if let Ok(json) = serde_json::to_string_pretty(&workflow) {
|
||||
if let Err(e) = std::fs::write(&wf_path, json) {
|
||||
tracing::warn!("Failed to persist workflow {id}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
Json(serde_json::json!({"workflow_id": id.to_string()})),
|
||||
@@ -1412,6 +1440,7 @@ pub async fn send_message_stream(
|
||||
Some(kernel_handle),
|
||||
req.sender_id,
|
||||
req.sender_name,
|
||||
None, // SSE streaming doesn't support image attachments yet
|
||||
) {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
@@ -1858,6 +1887,7 @@ const CHANNEL_REGISTRY: &[ChannelMeta] = &[
|
||||
ChannelField { key: "app_id", label: "App ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "cli_abc123", advanced: false },
|
||||
ChannelField { key: "app_secret_env", label: "App Secret", field_type: FieldType::Secret, env_var: Some("FEISHU_APP_SECRET"), required: true, placeholder: "abc123...", advanced: false },
|
||||
ChannelField { key: "region", label: "Region", field_type: FieldType::Text, env_var: None, required: false, placeholder: "cn or intl", advanced: false },
|
||||
ChannelField { key: "mode", label: "Receive Mode", field_type: FieldType::Text, env_var: None, required: false, placeholder: "webhook|websocket", advanced: true },
|
||||
ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8453", advanced: true },
|
||||
ChannelField { key: "webhook_path", label: "Webhook Path", field_type: FieldType::Text, env_var: None, required: false, placeholder: "/feishu/webhook", advanced: true },
|
||||
ChannelField { key: "verification_token", label: "Verification Token", field_type: FieldType::Text, env_var: None, required: false, placeholder: "verify-token", advanced: true },
|
||||
@@ -1866,7 +1896,7 @@ const CHANNEL_REGISTRY: &[ChannelMeta] = &[
|
||||
ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true },
|
||||
],
|
||||
setup_steps: &["Create an app at open.feishu.cn (CN) or open.larksuite.com (International)", "Copy App ID and Secret", "Set region: cn (Feishu) or intl (Lark)"],
|
||||
config_template: "[channels.feishu]\napp_id = \"\"\napp_secret_env = \"FEISHU_APP_SECRET\"\nregion = \"cn\"",
|
||||
config_template: "[channels.feishu]\napp_id = \"\"\napp_secret_env = \"FEISHU_APP_SECRET\"\nregion = \"cn\"\nmode = \"websocket\"",
|
||||
},
|
||||
ChannelMeta {
|
||||
name: "dingtalk", display_name: "DingTalk", icon: "DT",
|
||||
@@ -2287,6 +2317,23 @@ fn find_channel_meta(name: &str) -> Option<&'static ChannelMeta> {
|
||||
CHANNEL_REGISTRY.iter().find(|c| c.name == name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod channel_meta_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn feishu_channel_meta_includes_mode_field() {
|
||||
let meta = find_channel_meta("feishu").expect("feishu channel meta should exist");
|
||||
assert!(meta.fields.iter().any(|f| f.key == "mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feishu_channel_meta_template_includes_websocket_mode_default() {
|
||||
let meta = find_channel_meta("feishu").expect("feishu channel meta should exist");
|
||||
assert!(meta.config_template.contains("mode = \"websocket\""));
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a channel's config to a JSON Value for pre-populating dashboard forms.
|
||||
fn channel_config_values(
|
||||
config: &openfang_types::config::ChannelsConfig,
|
||||
@@ -3081,15 +3128,21 @@ pub async fn list_templates() -> impl IntoResponse {
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let description = std::fs::read_to_string(&manifest_path)
|
||||
.ok()
|
||||
.and_then(|content| toml::from_str::<AgentManifest>(&content).ok())
|
||||
let manifest_content = std::fs::read_to_string(&manifest_path).ok();
|
||||
let description = manifest_content
|
||||
.as_ref()
|
||||
.and_then(|content| toml::from_str::<AgentManifest>(content).ok())
|
||||
.map(|m| m.description)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Add category based on template name
|
||||
let category = get_template_category(&name);
|
||||
|
||||
templates.push(serde_json::json!({
|
||||
"name": name,
|
||||
"description": description,
|
||||
"category": category,
|
||||
"manifest_toml": manifest_content.unwrap_or_default(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -3102,6 +3155,24 @@ pub async fn list_templates() -> impl IntoResponse {
|
||||
}))
|
||||
}
|
||||
|
||||
fn get_template_category(name: &str) -> &str {
|
||||
match name {
|
||||
"hello-world" | "assistant" => "General",
|
||||
"researcher" | "analyst" => "Research",
|
||||
"coder" | "debugger" | "devops-lead" => "Development",
|
||||
"writer" | "doc-writer" => "Writing",
|
||||
"ops" | "planner" => "Operations",
|
||||
"architect" | "security-auditor" => "Development",
|
||||
"code-reviewer" | "data-scientist" | "test-engineer" => "Development",
|
||||
"legal-assistant" | "email-assistant" | "social-media" => "Business",
|
||||
"customer-support" | "sales-assistant" | "recruiter" => "Business",
|
||||
"meeting-assistant" => "Business",
|
||||
"translator" | "tutor" | "health-tracker" => "General",
|
||||
"personal-finance" | "travel-planner" | "home-automation" => "General",
|
||||
_ => "General",
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/templates/:name — Get template details.
|
||||
pub async fn get_template(Path(name): Path<String>) -> impl IntoResponse {
|
||||
let agents_dir = openfang_kernel::config::openfang_home().join("agents");
|
||||
@@ -3503,6 +3574,15 @@ pub async fn uninstall_skill(
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/skills/reload — Hot-reload the skill registry from disk.
|
||||
///
|
||||
/// Called by the CLI after `openfang skill install` to notify the running
|
||||
/// daemon that new skill files were added to the skills directory (#752).
|
||||
pub async fn reload_skills(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
state.kernel.reload_skills();
|
||||
Json(serde_json::json!({"status": "reloaded"}))
|
||||
}
|
||||
|
||||
/// GET /api/marketplace/search — Search the FangHub marketplace.
|
||||
pub async fn marketplace_search(
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
@@ -3821,6 +3901,9 @@ pub async fn clawhub_install(
|
||||
|
||||
match client.install(&req.slug, &skills_dir).await {
|
||||
Ok(result) => {
|
||||
// Hot-reload so agents see the new skill immediately (#752)
|
||||
state.kernel.reload_skills();
|
||||
|
||||
let warnings: Vec<serde_json::Value> = result
|
||||
.warnings
|
||||
.iter()
|
||||
@@ -4802,6 +4885,12 @@ pub async fn list_mcp_servers(State(state): State<Arc<AppState>>) -> impl IntoRe
|
||||
"url": url,
|
||||
})
|
||||
}
|
||||
openfang_types::config::McpTransportEntry::Http { url } => {
|
||||
serde_json::json!({
|
||||
"type": "http",
|
||||
"url": url,
|
||||
})
|
||||
}
|
||||
};
|
||||
serde_json::json!({
|
||||
"name": s.name,
|
||||
@@ -5243,10 +5332,8 @@ pub async fn usage_daily(State(state): State<Arc<AppState>>) -> impl IntoRespons
|
||||
|
||||
/// GET /api/budget — Current budget status (limits, spend, % used).
|
||||
pub async fn budget_status(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let status = state
|
||||
.kernel
|
||||
.metering
|
||||
.budget_status(&state.kernel.config.budget);
|
||||
let budget = state.budget_config.read().await;
|
||||
let status = state.kernel.metering.budget_status(&budget);
|
||||
Json(serde_json::to_value(&status).unwrap_or_default())
|
||||
}
|
||||
|
||||
@@ -5255,34 +5342,26 @@ pub async fn update_budget(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
// SAFETY: Budget config is updated in-place. Since KernelConfig is behind
|
||||
// an Arc and we only have &self, we use ptr mutation (same pattern as OFP).
|
||||
let config_ptr = &state.kernel.config as *const openfang_types::config::KernelConfig
|
||||
as *mut openfang_types::config::KernelConfig;
|
||||
|
||||
// Apply updates
|
||||
unsafe {
|
||||
{
|
||||
let mut budget = state.budget_config.write().await;
|
||||
if let Some(v) = body["max_hourly_usd"].as_f64() {
|
||||
(*config_ptr).budget.max_hourly_usd = v;
|
||||
budget.max_hourly_usd = v;
|
||||
}
|
||||
if let Some(v) = body["max_daily_usd"].as_f64() {
|
||||
(*config_ptr).budget.max_daily_usd = v;
|
||||
budget.max_daily_usd = v;
|
||||
}
|
||||
if let Some(v) = body["max_monthly_usd"].as_f64() {
|
||||
(*config_ptr).budget.max_monthly_usd = v;
|
||||
budget.max_monthly_usd = v;
|
||||
}
|
||||
if let Some(v) = body["alert_threshold"].as_f64() {
|
||||
(*config_ptr).budget.alert_threshold = v.clamp(0.0, 1.0);
|
||||
budget.alert_threshold = v.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(v) = body["default_max_llm_tokens_per_hour"].as_u64() {
|
||||
(*config_ptr).budget.default_max_llm_tokens_per_hour = v;
|
||||
budget.default_max_llm_tokens_per_hour = v;
|
||||
}
|
||||
}
|
||||
|
||||
let status = state
|
||||
.kernel
|
||||
.metering
|
||||
.budget_status(&state.kernel.config.budget);
|
||||
let budget = state.budget_config.read().await;
|
||||
let status = state.kernel.metering.budget_status(&budget);
|
||||
Json(serde_json::to_value(&status).unwrap_or_default())
|
||||
}
|
||||
|
||||
@@ -8813,20 +8892,16 @@ pub async fn patch_agent_config(
|
||||
if !new_model.is_empty() {
|
||||
if let Some(ref new_provider) = req.provider {
|
||||
if !new_provider.is_empty() {
|
||||
// Explicit provider given — use it directly
|
||||
if state
|
||||
.kernel
|
||||
.registry
|
||||
.update_model_and_provider(
|
||||
agent_id,
|
||||
new_model.clone(),
|
||||
new_provider.clone(),
|
||||
)
|
||||
.is_err()
|
||||
// Explicit provider given — still route through set_agent_model
|
||||
// so provider-specific auth/env hints stay in sync.
|
||||
if let Err(e) =
|
||||
state
|
||||
.kernel
|
||||
.set_agent_model(agent_id, new_model, Some(new_provider))
|
||||
{
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Agent not found"})),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("{e}")})),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -9300,16 +9375,14 @@ fn is_allowed_content_type(ct: &str) -> bool {
|
||||
|
||||
/// POST /api/agents/{id}/upload — Upload a file attachment.
|
||||
///
|
||||
/// Accepts raw body bytes. The client must set:
|
||||
/// - `Content-Type` header (e.g., `image/png`, `text/plain`, `application/pdf`)
|
||||
/// - `X-Filename` header (original filename)
|
||||
/// Accepts multipart/form-data. The client must include a file field with:
|
||||
/// - `Content-Type` field (e.g., `image/png`, `text/plain`, `application/pdf`)
|
||||
/// - `filename` attribute (original filename)
|
||||
pub async fn upload_file(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
headers: axum::http::HeaderMap,
|
||||
body: axum::body::Bytes,
|
||||
mut multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
// Validate agent ID format
|
||||
let _agent_id: AgentId = match id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
@@ -9320,12 +9393,62 @@ pub async fn upload_file(
|
||||
}
|
||||
};
|
||||
|
||||
// Extract content type
|
||||
let content_type = headers
|
||||
.get(axum::http::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let mut file_data: Option<(Option<String>, String, axum::body::Bytes)> = None;
|
||||
let mut filename_from_field: Option<String> = None;
|
||||
|
||||
while let Some(field) = multipart.next_field().await.transpose() {
|
||||
let field = match field {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": format!("Failed to read field: {}", e)})),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let field_name = field.name().unwrap_or("").to_string();
|
||||
|
||||
match field_name.as_str() {
|
||||
"file" => {
|
||||
let filename_attr = field.file_name().map(|s| s.to_string());
|
||||
let content_type = field
|
||||
.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let bytes = match field.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
serde_json::json!({"error": format!("Failed to read file: {}", e)}),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
file_data = Some((filename_attr, content_type, bytes));
|
||||
}
|
||||
"filename" => {
|
||||
filename_from_field = field.text().await.ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let (filename_attr, content_type, body) = match file_data {
|
||||
Some(data) => data,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "No file provided"})),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let filename = filename_from_field
|
||||
.or(filename_attr)
|
||||
.unwrap_or_else(|| "upload".to_string());
|
||||
|
||||
if !is_allowed_content_type(&content_type) {
|
||||
return (
|
||||
@@ -9336,13 +9459,6 @@ pub async fn upload_file(
|
||||
);
|
||||
}
|
||||
|
||||
// Extract filename from header
|
||||
let filename = headers
|
||||
.get("X-Filename")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("upload")
|
||||
.to_string();
|
||||
|
||||
// Validate size
|
||||
if body.len() > MAX_UPLOAD_SIZE {
|
||||
return (
|
||||
@@ -9487,25 +9603,28 @@ pub async fn serve_upload(Path(file_id): Path<String>) -> impl IntoResponse {
|
||||
// Execution Approval System — backed by kernel.approval_manager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/approvals — List pending approval requests.
|
||||
/// GET /api/approvals — List pending and recent approval requests.
|
||||
///
|
||||
/// Transforms field names to match the dashboard template expectations:
|
||||
/// `action_summary` → `action`, `agent_id` → `agent_name`, `requested_at` → `created_at`.
|
||||
pub async fn list_approvals(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let pending = state.kernel.approval_manager.list_pending();
|
||||
let total = pending.len();
|
||||
let recent = state.kernel.approval_manager.list_recent(50);
|
||||
|
||||
// Resolve agent names for display
|
||||
let registry_agents = state.kernel.registry.list();
|
||||
let agent_name_for = |agent_id: &str| {
|
||||
registry_agents
|
||||
.iter()
|
||||
.find(|ag| ag.id.to_string() == agent_id || ag.name == agent_id)
|
||||
.map(|ag| ag.name.clone())
|
||||
.unwrap_or_else(|| agent_id.to_string())
|
||||
};
|
||||
|
||||
let approvals: Vec<serde_json::Value> = pending
|
||||
let mut approvals: Vec<serde_json::Value> = pending
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let agent_name = registry_agents
|
||||
.iter()
|
||||
.find(|ag| ag.id.to_string() == a.agent_id || ag.name == a.agent_id)
|
||||
.map(|ag| ag.name.as_str())
|
||||
.unwrap_or(&a.agent_id);
|
||||
let agent_name = agent_name_for(&a.agent_id);
|
||||
serde_json::json!({
|
||||
"id": a.id,
|
||||
"agent_id": a.agent_id,
|
||||
@@ -9523,6 +9642,42 @@ pub async fn list_approvals(State(state): State<Arc<AppState>>) -> impl IntoResp
|
||||
})
|
||||
.collect();
|
||||
|
||||
approvals.extend(recent.into_iter().map(|record| {
|
||||
let request = record.request;
|
||||
let agent_name = agent_name_for(&request.agent_id);
|
||||
let status = match record.decision {
|
||||
openfang_types::approval::ApprovalDecision::Approved => "approved",
|
||||
openfang_types::approval::ApprovalDecision::Denied => "rejected",
|
||||
openfang_types::approval::ApprovalDecision::TimedOut => "expired",
|
||||
};
|
||||
serde_json::json!({
|
||||
"id": request.id,
|
||||
"agent_id": request.agent_id,
|
||||
"agent_name": agent_name,
|
||||
"tool_name": request.tool_name,
|
||||
"description": request.description,
|
||||
"action_summary": request.action_summary,
|
||||
"action": request.action_summary,
|
||||
"risk_level": request.risk_level,
|
||||
"requested_at": request.requested_at,
|
||||
"created_at": request.requested_at,
|
||||
"timeout_secs": request.timeout_secs,
|
||||
"status": status,
|
||||
"decided_at": record.decided_at,
|
||||
"decided_by": record.decided_by,
|
||||
})
|
||||
}));
|
||||
|
||||
approvals.sort_by(|a, b| {
|
||||
let a_pending = a["status"].as_str() == Some("pending");
|
||||
let b_pending = b["status"].as_str() == Some("pending");
|
||||
b_pending
|
||||
.cmp(&a_pending)
|
||||
.then_with(|| b["created_at"].as_str().cmp(&a["created_at"].as_str()))
|
||||
});
|
||||
|
||||
let total = approvals.len();
|
||||
|
||||
Json(serde_json::json!({"approvals": approvals, "total": total}))
|
||||
}
|
||||
|
||||
@@ -9709,78 +9864,84 @@ pub async fn config_schema(State(state): State<Arc<AppState>>) -> impl IntoRespo
|
||||
.collect();
|
||||
drop(catalog);
|
||||
|
||||
// Helper: normalize field definitions to objects with {name, type, label}
|
||||
// so the frontend template can iterate and render inputs correctly.
|
||||
let f = |name: &str, ftype: &str, label: &str| -> serde_json::Value {
|
||||
serde_json::json!({"name": name, "type": ftype, "label": label})
|
||||
};
|
||||
|
||||
Json(serde_json::json!({
|
||||
"sections": {
|
||||
"general": {
|
||||
"root_level": true,
|
||||
"fields": {
|
||||
"api_listen": "string",
|
||||
"api_key": "string",
|
||||
"log_level": "string"
|
||||
}
|
||||
"fields": [
|
||||
f("api_listen", "string", "API Listen Address"),
|
||||
f("api_key", "string", "API Key"),
|
||||
f("log_level", "string", "Log Level")
|
||||
]
|
||||
},
|
||||
"default_model": {
|
||||
"hot_reloadable": true,
|
||||
"fields": {
|
||||
"provider": { "type": "select", "options": provider_options },
|
||||
"model": { "type": "select", "options": model_options },
|
||||
"api_key_env": "string",
|
||||
"base_url": "string"
|
||||
}
|
||||
"fields": [
|
||||
{ "name": "provider", "type": "select", "label": "Provider", "options": provider_options },
|
||||
{ "name": "model", "type": "select", "label": "Model", "options": model_options },
|
||||
f("api_key_env", "string", "API Key Env Var"),
|
||||
f("base_url", "string", "Base URL")
|
||||
]
|
||||
},
|
||||
"memory": {
|
||||
"fields": {
|
||||
"decay_rate": "number",
|
||||
"vector_dims": "number"
|
||||
}
|
||||
"fields": [
|
||||
f("decay_rate", "number", "Decay Rate"),
|
||||
f("vector_dims", "number", "Vector Dimensions")
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"fields": {
|
||||
"provider": "string",
|
||||
"timeout_secs": "number",
|
||||
"max_results": "number"
|
||||
}
|
||||
"fields": [
|
||||
f("provider", "string", "Search Provider"),
|
||||
f("timeout_secs", "number", "Timeout (seconds)"),
|
||||
f("max_results", "number", "Max Results")
|
||||
]
|
||||
},
|
||||
"browser": {
|
||||
"fields": {
|
||||
"headless": "boolean",
|
||||
"timeout_secs": "number",
|
||||
"executable_path": "string"
|
||||
}
|
||||
"fields": [
|
||||
f("headless", "boolean", "Headless Mode"),
|
||||
f("timeout_secs", "number", "Timeout (seconds)"),
|
||||
f("executable_path", "string", "Chrome/Chromium Path")
|
||||
]
|
||||
},
|
||||
"network": {
|
||||
"fields": {
|
||||
"enabled": "boolean",
|
||||
"listen_addr": "string",
|
||||
"shared_secret": "string"
|
||||
}
|
||||
"fields": [
|
||||
f("enabled", "boolean", "Enable OFP Network"),
|
||||
f("listen_addr", "string", "Listen Address"),
|
||||
f("shared_secret", "string", "Shared Secret")
|
||||
]
|
||||
},
|
||||
"extensions": {
|
||||
"fields": {
|
||||
"auto_connect": "boolean",
|
||||
"health_check_interval_secs": "number"
|
||||
}
|
||||
"fields": [
|
||||
f("auto_connect", "boolean", "Auto Connect"),
|
||||
f("health_check_interval_secs", "number", "Health Check Interval (s)")
|
||||
]
|
||||
},
|
||||
"vault": {
|
||||
"fields": {
|
||||
"path": "string"
|
||||
}
|
||||
"fields": [
|
||||
f("path", "string", "Vault Path")
|
||||
]
|
||||
},
|
||||
"a2a": {
|
||||
"fields": {
|
||||
"enabled": "boolean",
|
||||
"name": "string",
|
||||
"description": "string",
|
||||
"url": "string"
|
||||
}
|
||||
"fields": [
|
||||
f("enabled", "boolean", "Enable A2A"),
|
||||
f("name", "string", "Agent Name"),
|
||||
f("description", "string", "Description"),
|
||||
f("url", "string", "URL")
|
||||
]
|
||||
},
|
||||
"channels": {
|
||||
"fields": {
|
||||
"telegram": "object",
|
||||
"discord": "object",
|
||||
"slack": "object",
|
||||
"whatsapp": "object"
|
||||
}
|
||||
"fields": [
|
||||
f("telegram", "object", "Telegram"),
|
||||
f("discord", "object", "Discord"),
|
||||
f("slack", "object", "Slack"),
|
||||
f("whatsapp", "object", "WhatsApp")
|
||||
]
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -10116,6 +10277,66 @@ pub async fn cron_job_status(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run cron job on demand
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// POST /api/cron/jobs/{id}/run — Trigger a cron job immediately.
|
||||
///
|
||||
/// Returns `{"status": "triggered", "job_id": "..."}` and spawns the execution
|
||||
/// in the background. The job's status can be polled via
|
||||
/// `GET /api/cron/jobs/{id}/status`.
|
||||
pub async fn run_cron_job(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let uuid = match uuid::Uuid::parse_str(&id) {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"status": "error", "error": "Invalid job ID"})),
|
||||
);
|
||||
}
|
||||
};
|
||||
let job_id = openfang_types::scheduler::CronJobId(uuid);
|
||||
|
||||
// Atomically check existence + enabled + reserve next_run in one lock hold.
|
||||
let job = match state.kernel.cron_scheduler.try_claim_for_run(job_id) {
|
||||
Ok(j) => j,
|
||||
Err(openfang_kernel::cron::ClaimError::NotFound) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"status": "error", "error": "Job not found"})),
|
||||
);
|
||||
}
|
||||
Err(openfang_kernel::cron::ClaimError::Disabled) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"status": "error", "error": "Job is disabled"})),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Spawn execution in the background so we don't block the HTTP response.
|
||||
let kernel = Arc::clone(&state.kernel);
|
||||
let job_name = job.name.clone();
|
||||
tokio::spawn(async move {
|
||||
match kernel.cron_run_job(&job).await {
|
||||
Ok(_) => tracing::info!(job = %job_name, "On-demand cron job completed"),
|
||||
Err(e) => tracing::warn!(job = %job_name, error = %e, "On-demand cron job failed"),
|
||||
}
|
||||
});
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"status": "triggered",
|
||||
"job_id": id,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhook trigger endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -10471,21 +10692,24 @@ pub async fn pairing_notify(
|
||||
|
||||
/// GET /api/commands — List available chat commands (for dynamic slash menu).
|
||||
pub async fn list_commands(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let mut commands = vec![
|
||||
serde_json::json!({"cmd": "/help", "desc": "Show available commands"}),
|
||||
serde_json::json!({"cmd": "/new", "desc": "Reset session (clear history)"}),
|
||||
serde_json::json!({"cmd": "/compact", "desc": "Trigger LLM session compaction"}),
|
||||
serde_json::json!({"cmd": "/model", "desc": "Show or switch model (/model [name])"}),
|
||||
serde_json::json!({"cmd": "/stop", "desc": "Cancel current agent run"}),
|
||||
serde_json::json!({"cmd": "/usage", "desc": "Show session token usage & cost"}),
|
||||
serde_json::json!({"cmd": "/think", "desc": "Toggle extended thinking (/think [on|off|stream])"}),
|
||||
let mut commands: Vec<serde_json::Value> = channel_command_specs()
|
||||
.iter()
|
||||
.map(|spec| {
|
||||
serde_json::json!({
|
||||
"cmd": format!("/{}", spec.name),
|
||||
"desc": spec.desc,
|
||||
"source": "channel",
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
commands.extend([
|
||||
serde_json::json!({"cmd": "/context", "desc": "Show context window usage & pressure"}),
|
||||
serde_json::json!({"cmd": "/verbose", "desc": "Cycle tool detail level (/verbose [off|on|full])"}),
|
||||
serde_json::json!({"cmd": "/queue", "desc": "Check if agent is processing"}),
|
||||
serde_json::json!({"cmd": "/status", "desc": "Show system status"}),
|
||||
serde_json::json!({"cmd": "/clear", "desc": "Clear chat display"}),
|
||||
serde_json::json!({"cmd": "/exit", "desc": "Disconnect from agent"}),
|
||||
];
|
||||
]);
|
||||
|
||||
// Add skill-registered tool names as potential commands
|
||||
if let Ok(registry) = state.kernel.skill_registry.read() {
|
||||
|
||||
@@ -51,6 +51,7 @@ pub async fn build_router(
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(kernel.config.budget.clone())),
|
||||
});
|
||||
|
||||
// CORS: allow localhost origins by default. If API key is set, the API
|
||||
@@ -335,6 +336,10 @@ pub async fn build_router(
|
||||
"/api/skills/uninstall",
|
||||
axum::routing::post(routes::uninstall_skill),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/reload",
|
||||
axum::routing::post(routes::reload_skills),
|
||||
)
|
||||
.route(
|
||||
"/api/marketplace/search",
|
||||
axum::routing::get(routes::marketplace_search),
|
||||
@@ -586,6 +591,10 @@ pub async fn build_router(
|
||||
"/api/cron/jobs/{id}/status",
|
||||
axum::routing::get(routes::cron_job_status),
|
||||
)
|
||||
.route(
|
||||
"/api/cron/jobs/{id}/run",
|
||||
axum::routing::post(routes::run_cron_job),
|
||||
)
|
||||
// Webhook trigger endpoints (external event injection)
|
||||
.route("/hooks/wake", axum::routing::post(routes::webhook_wake))
|
||||
.route("/hooks/agent", axum::routing::post(routes::webhook_agent))
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
use axum::http::header;
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
/// Nonce placeholder in compile-time HTML, replaced at request time.
|
||||
const NONCE_PLACEHOLDER: &str = "__NONCE__";
|
||||
|
||||
/// Compile-time ETag based on the crate version.
|
||||
/// Not used for the dashboard page (nonce prevents caching) but retained
|
||||
/// for potential future use by static asset handlers.
|
||||
#[allow(dead_code)]
|
||||
const ETAG: &str = concat!("\"openfang-", env!("CARGO_PKG_VERSION"), "\"");
|
||||
|
||||
/// Embedded logo PNG for single-binary deployment.
|
||||
@@ -76,18 +82,35 @@ pub async fn sw_js() -> impl IntoResponse {
|
||||
|
||||
/// GET / — Serve the OpenFang Dashboard single-page application.
|
||||
///
|
||||
/// Returns the full SPA with ETag header based on package version for caching.
|
||||
/// Generates a unique CSP nonce on every request and injects it into both
|
||||
/// the `<script>` tags and the `Content-Security-Policy` header. This
|
||||
/// replaces `'unsafe-inline'` so only our own scripts execute.
|
||||
pub async fn webchat_page() -> impl IntoResponse {
|
||||
let nonce = uuid::Uuid::new_v4().to_string();
|
||||
let html = WEBCHAT_HTML.replace(NONCE_PLACEHOLDER, &nonce);
|
||||
let csp = format!(
|
||||
"default-src 'self'; \
|
||||
script-src 'self' 'nonce-{nonce}' 'unsafe-eval'; \
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; \
|
||||
img-src 'self' data: blob:; \
|
||||
connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; \
|
||||
font-src 'self' https://fonts.gstatic.com; \
|
||||
media-src 'self' blob:; \
|
||||
frame-src 'self' blob:; \
|
||||
object-src 'none'; \
|
||||
base-uri 'self'; \
|
||||
form-action 'self'"
|
||||
);
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
|
||||
(header::ETAG, ETAG),
|
||||
(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
"public, max-age=3600, must-revalidate",
|
||||
header::HeaderName::from_static("content-security-policy"),
|
||||
csp,
|
||||
),
|
||||
(header::CACHE_CONTROL, "no-store".to_string()),
|
||||
],
|
||||
WEBCHAT_HTML,
|
||||
html,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -110,23 +133,25 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
"\n</style>\n",
|
||||
include_str!("../static/index_body.html"),
|
||||
// Vendor libs: marked + highlight first (used by app.js), then Chart.js
|
||||
"<script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/vendor/marked.min.js"),
|
||||
"\n</script>\n",
|
||||
"<script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/vendor/highlight.min.js"),
|
||||
"\n</script>\n",
|
||||
"<script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/vendor/chart.umd.min.js"),
|
||||
"\n</script>\n",
|
||||
// App code
|
||||
"<script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/js/api.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/app.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/overview.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/katex.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/chat.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/agents.js"),
|
||||
@@ -160,7 +185,7 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/js/pages/runtime.js"),
|
||||
"\n</script>\n",
|
||||
// Alpine.js MUST be last — it processes x-data and fires alpine:init
|
||||
"<script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/vendor/alpine.min.js"),
|
||||
"\n</script>\n",
|
||||
"</body></html>"
|
||||
|
||||
@@ -439,6 +439,7 @@ async fn handle_text_message(
|
||||
|
||||
// Resolve file attachments into image content blocks
|
||||
let mut has_images = false;
|
||||
let mut ws_content_blocks: Option<Vec<openfang_types::message::ContentBlock>> = None;
|
||||
if let Some(attachments) = parsed["attachments"].as_array() {
|
||||
let refs: Vec<crate::types::AttachmentRef> = attachments
|
||||
.iter()
|
||||
@@ -448,11 +449,7 @@ async fn handle_text_message(
|
||||
let image_blocks = crate::routes::resolve_attachments(&refs);
|
||||
if !image_blocks.is_empty() {
|
||||
has_images = true;
|
||||
crate::routes::inject_attachments_into_session(
|
||||
&state.kernel,
|
||||
agent_id,
|
||||
image_blocks,
|
||||
);
|
||||
ws_content_blocks = Some(image_blocks);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -508,6 +505,7 @@ async fn handle_text_message(
|
||||
Some(kernel_handle),
|
||||
None,
|
||||
None,
|
||||
ws_content_blocks,
|
||||
) {
|
||||
Ok((mut rx, handle)) => {
|
||||
// Forward stream events to WebSocket with debouncing.
|
||||
@@ -1000,11 +998,14 @@ async fn handle_command(
|
||||
fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_json::Value> {
|
||||
match event {
|
||||
StreamEvent::TextDelta { .. } => None, // Handled by debounce buffer
|
||||
StreamEvent::ToolUseStart { name, .. } => Some(serde_json::json!({
|
||||
StreamEvent::ToolUseStart { id, name, .. } => Some(serde_json::json!({
|
||||
"type": "tool_start",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
})),
|
||||
StreamEvent::ToolUseEnd { name, input, .. } if name == "canvas_present" => {
|
||||
StreamEvent::ToolUseEnd {
|
||||
id, name, input, ..
|
||||
} if name == "canvas_present" => {
|
||||
let html = input.get("html").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let title = input
|
||||
.get("title")
|
||||
@@ -1012,12 +1013,15 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
|
||||
.unwrap_or("Canvas");
|
||||
Some(serde_json::json!({
|
||||
"type": "canvas",
|
||||
"id": id,
|
||||
"canvas_id": uuid::Uuid::new_v4().to_string(),
|
||||
"html": html,
|
||||
"title": title,
|
||||
}))
|
||||
}
|
||||
StreamEvent::ToolUseEnd { name, input, .. } => match verbose {
|
||||
StreamEvent::ToolUseEnd {
|
||||
id, name, input, ..
|
||||
} => match verbose {
|
||||
VerboseLevel::Off => None,
|
||||
VerboseLevel::On => {
|
||||
let input_preview: String = serde_json::to_string(input)
|
||||
@@ -1027,6 +1031,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
|
||||
.collect();
|
||||
Some(serde_json::json!({
|
||||
"type": "tool_end",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
"input": input_preview,
|
||||
}))
|
||||
@@ -1039,18 +1044,21 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
|
||||
.collect();
|
||||
Some(serde_json::json!({
|
||||
"type": "tool_end",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
"input": input_preview,
|
||||
}))
|
||||
}
|
||||
},
|
||||
StreamEvent::ToolExecutionResult {
|
||||
id,
|
||||
name,
|
||||
result_preview,
|
||||
is_error,
|
||||
} => match verbose {
|
||||
VerboseLevel::Off => Some(serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
"is_error": is_error,
|
||||
})),
|
||||
@@ -1058,6 +1066,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
|
||||
let truncated: String = result_preview.chars().take(200).collect();
|
||||
Some(serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
"result": truncated,
|
||||
"is_error": is_error,
|
||||
@@ -1065,6 +1074,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
|
||||
}
|
||||
VerboseLevel::Full => Some(serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
"result": result_preview,
|
||||
"is_error": is_error,
|
||||
|
||||
@@ -647,6 +647,11 @@ mark.search-highlight {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.message-bubble.markdown-body ul,
|
||||
.message-bubble.markdown-body ol {
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@@ -1274,8 +1279,11 @@ mark.search-highlight {
|
||||
/* Utility */
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.grid { display: grid; }
|
||||
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.gap-4 { gap: 16px; }
|
||||
@@ -1283,6 +1291,7 @@ mark.search-highlight {
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.mb-2 { margin-bottom: 8px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
.text-dim { color: var(--text-dim); }
|
||||
.text-sm { font-size: 11px; }
|
||||
.text-xs { font-size: 10px; }
|
||||
|
||||
@@ -243,6 +243,14 @@
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
position: fixed !important;
|
||||
top: 12px;
|
||||
left: 16px;
|
||||
z-index: 98;
|
||||
padding: 6px 10px !important;
|
||||
}
|
||||
|
||||
/* Wide desktop — larger card grids */
|
||||
@media (min-width: 1400px) {
|
||||
.card-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
||||
@@ -277,6 +285,8 @@
|
||||
left: -300px;
|
||||
}
|
||||
.mobile-menu-btn { display: flex !important; }
|
||||
/* Offset header content so it does not overlap the fixed mobile menu button. */
|
||||
.page-header > :first-child { margin-left: 52px; }
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
@@ -285,7 +295,14 @@
|
||||
|
||||
/* Mobile small screen */
|
||||
@media (max-width: 480px) {
|
||||
.page-header { flex-direction: column; gap: 8px; align-items: flex-start; padding: 12px 16px; }
|
||||
.page-header {
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page-header h2 {
|
||||
line-height: 44px;
|
||||
}
|
||||
.page-body { padding: 12px; }
|
||||
.stats-row { flex-wrap: wrap; }
|
||||
.stat-card { min-width: 80px; flex: 1 1 40%; }
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
<a class="nav-item" :class="{ active: page === 'approvals' }" @click="navigate('approvals')" :aria-current="page === 'approvals' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg></span>
|
||||
<span class="nav-label">Approvals</span>
|
||||
<span class="badge badge-warn" x-show="$store.app.pendingApprovalCount > 0" x-text="$store.app.pendingApprovalCount"></span>
|
||||
</a>
|
||||
<a class="nav-item" :class="{ active: page === 'comms' }" @click="navigate('comms')" :aria-current="page === 'comms' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg></span>
|
||||
@@ -200,7 +201,7 @@
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Mobile menu button -->
|
||||
<button class="mobile-menu-btn btn btn-ghost" @click="mobileMenuOpen = !mobileMenuOpen" style="position:fixed;top:8px;left:8px;z-index:98;padding:6px 10px">
|
||||
<button class="mobile-menu-btn btn btn-ghost" @click="mobileMenuOpen = !mobileMenuOpen">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -880,7 +881,7 @@
|
||||
<div class="text-sm font-bold mb-2" style="color:var(--text-dim);letter-spacing:0.5px;font-size:11px;text-transform:uppercase" x-text="agents.length ? 'Or Start a New Agent' : 'Start Chatting'"></div>
|
||||
<div class="card-grid">
|
||||
<template x-for="t in builtinTemplates" :key="t.name">
|
||||
<div class="card" style="cursor:pointer" @click="spawnBuiltin(t)">
|
||||
<div class="card" style="cursor:pointer" @click="t.manifest_toml ? spawnFromTemplate(t) : spawnBuiltin(t)">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<div class="card-header" style="margin:0;font-size:14px;font-weight:600" x-text="t.name"></div>
|
||||
<span class="badge badge-dim" x-text="t.category"></span>
|
||||
@@ -897,7 +898,7 @@
|
||||
<!-- Agent detail modal with tabs (Info / Files / Config) -->
|
||||
<template x-if="showDetailModal && detailAgent">
|
||||
<div class="modal-overlay" @click.self="showDetailModal = false" @keydown.escape.window="showDetailModal = false">
|
||||
<div class="modal" style="max-width:600px">
|
||||
<div class="modal" style="max-width:700px">
|
||||
<div class="modal-header">
|
||||
<h3>
|
||||
<span x-show="detailAgent.identity && detailAgent.identity.emoji" x-text="detailAgent.identity && detailAgent.identity.emoji" style="margin-right:6px"></span>
|
||||
@@ -961,13 +962,13 @@
|
||||
<!-- Fallback Model Chain -->
|
||||
<div class="detail-row" style="align-items:flex-start">
|
||||
<span class="detail-label">Fallbacks</span>
|
||||
<div style="flex:1">
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:6px;min-width:0;margin-left:16px">
|
||||
<template x-if="detailAgent._fallbacks && detailAgent._fallbacks.length > 0">
|
||||
<div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px">
|
||||
<template x-for="(fb, idx) in detailAgent._fallbacks" :key="idx">
|
||||
<div class="flex gap-1 items-center" style="margin-bottom:4px">
|
||||
<span class="badge" style="font-size:11px;font-family:var(--font-mono)" x-text="(idx+1) + '. ' + fb.provider + '/' + fb.model"></span>
|
||||
<button class="btn btn-ghost btn-sm" style="padding:1px 4px;font-size:10px;color:var(--danger)" @click="removeFallback(idx)">×</button>
|
||||
<div class="flex gap-1 items-center" style="min-width:0">
|
||||
<span class="badge" style="font-size:11px;font-family:var(--font-mono);word-break:break-all;white-space:normal" x-text="(idx+1) + '. ' + fb.provider + '/' + fb.model"></span>
|
||||
<button class="btn btn-ghost btn-sm" style="padding:1px 4px;font-size:10px;color:var(--danger);flex-shrink:0" @click="removeFallback(idx)">×</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -976,10 +977,12 @@
|
||||
<span class="text-dim" style="font-size:12px">None — add a fallback chain</span>
|
||||
</template>
|
||||
<template x-if="!editingFallback">
|
||||
<button class="btn btn-ghost btn-sm" style="padding:2px 8px;font-size:11px;margin-top:4px" @click="editingFallback = true; newFallbackValue = ''">+ Add</button>
|
||||
<div>
|
||||
<button class="btn btn-ghost btn-sm" style="padding:2px 8px;font-size:11px" @click="editingFallback = true; newFallbackValue = ''">+ Add</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="editingFallback">
|
||||
<div class="flex gap-1 mt-1" style="align-items:center">
|
||||
<div class="flex gap-1" style="align-items:center">
|
||||
<input class="form-input" style="width:220px;font-size:12px" x-model="newFallbackValue" placeholder="provider/model" @keydown.enter="addFallback()" @keydown.escape="editingFallback = false">
|
||||
<button class="btn btn-primary btn-sm" @click="addFallback()" style="padding:2px 10px;font-size:11px">Add</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="editingFallback = false" style="padding:2px 8px;font-size:11px">Cancel</button>
|
||||
@@ -1154,28 +1157,26 @@
|
||||
<div class="form-group">
|
||||
<label>Provider</label>
|
||||
<select class="form-select" x-model="spawnForm.provider">
|
||||
<optgroup label="Cloud">
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="gemini">Google Gemini</option>
|
||||
<option value="groq">Groq</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="mistral">Mistral</option>
|
||||
<option value="xai">xAI</option>
|
||||
<option value="together">Together</option>
|
||||
<option value="fireworks">Fireworks</option>
|
||||
<option value="cerebras">Cerebras</option>
|
||||
<option value="sambanova">SambaNova</option>
|
||||
<option value="azure">Azure OpenAI</option>
|
||||
<option value="nvidia">NVIDIA NIM</option>
|
||||
</optgroup>
|
||||
<optgroup label="Local">
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="lmstudio">LM Studio</option>
|
||||
<option value="vllm">vLLM</option>
|
||||
<option value="lemonade">Lemonade</option>
|
||||
</optgroup>
|
||||
<template x-if="spawnProvidersLoading">
|
||||
<option disabled>Loading providers…</option>
|
||||
</template>
|
||||
<template x-if="!spawnProvidersLoading && spawnProviders.length === 0">
|
||||
<option disabled>No providers available</option>
|
||||
</template>
|
||||
<template x-if="!spawnProvidersLoading && spawnProviders.filter(p => !p.is_local).length > 0">
|
||||
<optgroup label="Cloud">
|
||||
<template x-for="p in spawnProviders.filter(p => !p.is_local)" :key="p.id">
|
||||
<option :value="p.id" x-text="p.display_name"></option>
|
||||
</template>
|
||||
</optgroup>
|
||||
</template>
|
||||
<template x-if="!spawnProvidersLoading && spawnProviders.filter(p => p.is_local).length > 0">
|
||||
<optgroup label="Local">
|
||||
<template x-for="p in spawnProviders.filter(p => p.is_local)" :key="p.id">
|
||||
<option :value="p.id" x-text="p.display_name"></option>
|
||||
</template>
|
||||
</optgroup>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -1276,7 +1277,7 @@
|
||||
|
||||
<!-- Page: Approvals -->
|
||||
<template x-if="page === 'approvals'">
|
||||
<div x-data="approvalsPage" x-init="loadData()">
|
||||
<div x-data="approvalsPage()" x-init="init()">
|
||||
<div class="page-header">
|
||||
<h2>Execution Approvals</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -1298,6 +1299,7 @@
|
||||
<button class="filter-pill" :class="{ active: filterStatus === 'pending' }" @click="filterStatus = 'pending'">Pending</button>
|
||||
<button class="filter-pill" :class="{ active: filterStatus === 'approved' }" @click="filterStatus = 'approved'">Approved</button>
|
||||
<button class="filter-pill" :class="{ active: filterStatus === 'rejected' }" @click="filterStatus = 'rejected'">Rejected</button>
|
||||
<button class="filter-pill" :class="{ active: filterStatus === 'expired' }" @click="filterStatus = 'expired'">Expired</button>
|
||||
</div>
|
||||
<div x-show="filtered.length === 0" class="empty-state">
|
||||
<h4>No approvals</h4>
|
||||
@@ -1340,7 +1342,7 @@
|
||||
|
||||
<!-- Tab: List -->
|
||||
<template x-if="wfTab === 'list'">
|
||||
<div x-data="workflowsPage">
|
||||
<div x-data="workflowsPage" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden">
|
||||
<div class="page-body" x-init="loadWorkflows()">
|
||||
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading workflows...</span></div>
|
||||
<div x-show="!loading && loadError" class="error-state">
|
||||
@@ -3366,7 +3368,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<span class="text-xs text-dim ml-2" x-text="customModelStatus"></span>
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-2" x-text="filteredModels.length + ' of ' + models.length + ' models'"></div>
|
||||
<div x-show="!filteredModels.length && !settingsLoading" style="text-align:center;padding:32px 16px">
|
||||
<div x-show="!filteredModels.length && !loading" style="text-align:center;padding:32px 16px">
|
||||
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">🤖</div>
|
||||
<h3 style="margin:0 0 4px;font-size:14px" x-text="models.length ? 'No models match your search' : 'No models available'"></h3>
|
||||
<p class="text-xs text-dim" x-text="models.length ? 'Try a different search term or clear filters.' : 'Configure an LLM provider to see available models.'"></p>
|
||||
@@ -3400,7 +3402,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<input placeholder="Search tools..." x-model="toolSearch">
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-2" x-text="filteredTools.length + ' of ' + tools.length + ' tools'"></div>
|
||||
<div x-show="!filteredTools.length && !settingsLoading" style="text-align:center;padding:32px 16px">
|
||||
<div x-show="!filteredTools.length && !loading" style="text-align:center;padding:32px 16px">
|
||||
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">🔧</div>
|
||||
<h3 style="margin:0 0 4px;font-size:14px" x-text="tools.length ? 'No tools match your search' : 'No tools available'"></h3>
|
||||
<p class="text-xs text-dim" x-text="tools.length ? 'Try a different search term.' : 'Tools will appear once agents are configured.'"></p>
|
||||
@@ -3449,7 +3451,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header" style="text-transform:capitalize" x-text="section.replace(/_/g, ' ')"></div>
|
||||
<div class="detail-grid" style="margin-top:12px">
|
||||
<template x-for="field in fields" :key="section + '.' + field.name">
|
||||
<template x-for="field in (fields.fields || [])" :key="section + '.' + field.name">
|
||||
<div class="detail-row" style="align-items:center">
|
||||
<span class="detail-label" x-text="field.label || field.name"></span>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
|
||||
@@ -3469,8 +3471,8 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<select class="form-select" style="width:180px"
|
||||
:value="configValues[section] && configValues[section][field.name]"
|
||||
@change="configValues[section] = configValues[section] || {}; configValues[section][field.name] = $event.target.value; markConfigDirty(section, field.name)">
|
||||
<template x-for="opt in field.options" :key="opt">
|
||||
<option :value="opt" x-text="opt" :selected="configValues[section] && configValues[section][field.name] === opt"></option>
|
||||
<template x-for="opt in field.options" :key="typeof opt === 'object' ? opt.id : opt">
|
||||
<option :value="typeof opt === 'object' ? opt.id : opt" x-text="typeof opt === 'object' ? (opt.name || opt.display_name || opt.id) : opt" :selected="configValues[section] && configValues[section][field.name] === (typeof opt === 'object' ? opt.id : opt)"></option>
|
||||
</template>
|
||||
</select>
|
||||
</template>
|
||||
@@ -4312,7 +4314,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div x-data="logsPage">
|
||||
<div class="page-header">
|
||||
<h2>Logs</h2>
|
||||
<div class="flex gap-2 items-center" x-show="tab === 'live'">
|
||||
<div class="flex gap-2 items-center flex-wrap" x-show="tab === 'live'">
|
||||
<!-- Connection status indicator -->
|
||||
<span class="live-indicator" :class="connectionClass">
|
||||
<span class="live-dot"></span>
|
||||
@@ -4988,7 +4990,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div class="page-body" x-init="loadData()">
|
||||
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading runtime info...</span></div>
|
||||
<div x-show="!loading">
|
||||
<div class="grid grid-cols-4" style="gap:16px;margin-bottom:24px">
|
||||
<div class="grid grid-cols-4 gap-4 mb-6">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">Uptime</div>
|
||||
<div class="stat-value" x-text="uptime"></div>
|
||||
|
||||
@@ -11,7 +11,4 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css">
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/contrib/auto-render.min.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -224,9 +224,12 @@ var OpenFangAPI = (function() {
|
||||
try {
|
||||
var url = WS_BASE + '/api/agents/' + agentId + '/ws';
|
||||
if (_authToken) url += '?token=' + encodeURIComponent(_authToken);
|
||||
_ws = new WebSocket(url);
|
||||
var socket = new WebSocket(url);
|
||||
_ws = socket;
|
||||
|
||||
_ws.onopen = function() {
|
||||
socket.onopen = function() {
|
||||
// Guard: ignore if this socket was superseded by a newer connection
|
||||
if (_ws !== socket) return;
|
||||
_wsConnected = true;
|
||||
_reconnectAttempts = 0;
|
||||
setConnectionState('connected');
|
||||
@@ -237,14 +240,20 @@ var OpenFangAPI = (function() {
|
||||
if (_wsCallbacks.onOpen) _wsCallbacks.onOpen();
|
||||
};
|
||||
|
||||
_ws.onmessage = function(e) {
|
||||
socket.onmessage = function(e) {
|
||||
try {
|
||||
var data = JSON.parse(e.data);
|
||||
if (_wsCallbacks.onMessage) _wsCallbacks.onMessage(data);
|
||||
} catch(err) { /* ignore parse errors */ }
|
||||
} catch(parseErr) {
|
||||
return; // Ignore malformed JSON frames
|
||||
}
|
||||
// Dispatch outside try/catch so handler errors are not swallowed
|
||||
if (_wsCallbacks.onMessage) _wsCallbacks.onMessage(data);
|
||||
};
|
||||
|
||||
_ws.onclose = function(e) {
|
||||
socket.onclose = function(e) {
|
||||
// Guard: only update state if this is still the active socket.
|
||||
// A superseded socket closing must not null-out the new connection.
|
||||
if (_ws !== socket) return;
|
||||
_wsConnected = false;
|
||||
_ws = null;
|
||||
if (_wsAgentId && _reconnectAttempts < MAX_RECONNECT && e.code !== 1000) {
|
||||
@@ -265,7 +274,9 @@ var OpenFangAPI = (function() {
|
||||
if (_wsCallbacks.onClose) _wsCallbacks.onClose();
|
||||
};
|
||||
|
||||
_ws.onerror = function() {
|
||||
socket.onerror = function() {
|
||||
// Guard: ignore errors from superseded sockets
|
||||
if (_ws !== socket) return;
|
||||
_wsConnected = false;
|
||||
if (_wsCallbacks.onError) _wsCallbacks.onError();
|
||||
};
|
||||
@@ -297,15 +308,15 @@ var OpenFangAPI = (function() {
|
||||
function getToken() { return _authToken; }
|
||||
|
||||
function upload(agentId, file) {
|
||||
var hdrs = {
|
||||
'Content-Type': file.type || 'application/octet-stream',
|
||||
'X-Filename': file.name
|
||||
};
|
||||
var hdrs = {};
|
||||
if (_authToken) hdrs['Authorization'] = 'Bearer ' + _authToken;
|
||||
var form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('filename', file.name);
|
||||
return fetch(BASE + '/api/agents/' + agentId + '/upload', {
|
||||
method: 'POST',
|
||||
headers: hdrs,
|
||||
body: file
|
||||
body: form
|
||||
}).then(function(r) {
|
||||
if (!r.ok) throw new Error('Upload failed');
|
||||
return r.json();
|
||||
|
||||
@@ -66,26 +66,6 @@ function renderMarkdown(text) {
|
||||
return escapeHtml(text);
|
||||
}
|
||||
|
||||
// Render LaTeX math in the chat message container using KaTeX auto-render.
|
||||
// Call this after new messages are inserted into the DOM.
|
||||
function renderLatex(el) {
|
||||
if (typeof renderMathInElement !== 'function') return;
|
||||
var target = el || document.getElementById('messages');
|
||||
if (!target) return;
|
||||
try {
|
||||
renderMathInElement(target, {
|
||||
delimiters: [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
{ left: '\\(', right: '\\)', display: false }
|
||||
],
|
||||
throwOnError: false,
|
||||
trust: false
|
||||
});
|
||||
} catch(e) { /* KaTeX render error — ignore gracefully */ }
|
||||
}
|
||||
|
||||
function copyCode(btn) {
|
||||
var code = btn.nextElementSibling;
|
||||
if (code) {
|
||||
@@ -154,6 +134,8 @@ document.addEventListener('alpine:init', function() {
|
||||
lastError: '',
|
||||
version: '0.1.0',
|
||||
agentCount: 0,
|
||||
pendingApprovalCount: 0,
|
||||
lastPendingApprovalSignature: '',
|
||||
pendingAgent: null,
|
||||
focusMode: localStorage.getItem('openfang-focus') === 'true',
|
||||
showOnboarding: false,
|
||||
@@ -174,6 +156,23 @@ document.addEventListener('alpine:init', function() {
|
||||
} catch(e) { /* silent */ }
|
||||
},
|
||||
|
||||
async refreshApprovals() {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/approvals');
|
||||
var approvals = Array.isArray(data) ? data : (data.approvals || []);
|
||||
var pending = approvals.filter(function(a) { return a.status === 'pending'; });
|
||||
var signature = pending
|
||||
.map(function(a) { return a.id; })
|
||||
.sort()
|
||||
.join(',');
|
||||
if (pending.length > 0 && signature !== this.lastPendingApprovalSignature && typeof OpenFangToast !== 'undefined') {
|
||||
OpenFangToast.warn('An agent is waiting for approval. Open Approvals to review.');
|
||||
}
|
||||
this.pendingApprovalCount = pending.length;
|
||||
this.lastPendingApprovalSignature = signature;
|
||||
} catch(e) { /* silent */ }
|
||||
},
|
||||
|
||||
async checkStatus() {
|
||||
try {
|
||||
var s = await OpenFangAPI.get('/api/status');
|
||||
@@ -370,9 +369,13 @@ function app() {
|
||||
|
||||
// Initial data load
|
||||
this.pollStatus();
|
||||
Alpine.store('app').refreshApprovals();
|
||||
Alpine.store('app').checkOnboarding();
|
||||
Alpine.store('app').checkAuth();
|
||||
setInterval(function() { self.pollStatus(); }, 5000);
|
||||
setInterval(function() {
|
||||
self.pollStatus();
|
||||
Alpine.store('app').refreshApprovals();
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
navigate(p) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// On-demand KaTeX loader and renderer for chat messages.
|
||||
|
||||
var KATEX_VERSION = '0.16.21';
|
||||
var KATEX_CSS_URL = 'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/katex.min.css';
|
||||
var KATEX_JS_URL = 'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/katex.min.js';
|
||||
var KATEX_AUTORENDER_URL =
|
||||
'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/contrib/auto-render.min.js';
|
||||
var katexLoadPromise = null;
|
||||
|
||||
function hasLatexDelimiters(text) {
|
||||
if (!text) return false;
|
||||
return /\$\$|\\\[|\\\(|\$(?=\S)[^$\n]+\$/.test(text);
|
||||
}
|
||||
|
||||
function loadScript(url) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.async = true;
|
||||
script.onload = function () {
|
||||
resolve();
|
||||
};
|
||||
script.onerror = function () {
|
||||
reject(new Error('Failed to load script: ' + url));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureKatexLoaded() {
|
||||
if (typeof renderMathInElement === 'function') return Promise.resolve(true);
|
||||
if (katexLoadPromise) return katexLoadPromise;
|
||||
|
||||
katexLoadPromise = new Promise(function (resolve) {
|
||||
var cssId = 'openfang-katex-css';
|
||||
if (!document.getElementById(cssId)) {
|
||||
var link = document.createElement('link');
|
||||
link.id = cssId;
|
||||
link.rel = 'stylesheet';
|
||||
link.href = KATEX_CSS_URL;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
loadScript(KATEX_JS_URL)
|
||||
.then(function () {
|
||||
return loadScript(KATEX_AUTORENDER_URL);
|
||||
})
|
||||
.then(function () {
|
||||
resolve(typeof renderMathInElement === 'function');
|
||||
})
|
||||
.catch(function () {
|
||||
katexLoadPromise = null;
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
return katexLoadPromise;
|
||||
}
|
||||
|
||||
// Render LaTeX math in the chat message container using KaTeX auto-render.
|
||||
// Call this after new messages are inserted into the DOM.
|
||||
function renderLatex(el) {
|
||||
var target = el || document.getElementById('messages');
|
||||
if (!target) return;
|
||||
if (!hasLatexDelimiters(target.textContent || '')) return;
|
||||
|
||||
ensureKatexLoaded().then(function (ok) {
|
||||
if (!ok || typeof renderMathInElement !== 'function') return;
|
||||
try {
|
||||
renderMathInElement(target, {
|
||||
delimiters: [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
],
|
||||
throwOnError: false,
|
||||
trust: false,
|
||||
});
|
||||
} catch (e) {
|
||||
/* KaTeX render error — ignore gracefully */
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -40,6 +40,8 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
// -- Multi-step wizard state --
|
||||
spawnProviders: [], // populated from /api/providers on wizard open
|
||||
spawnProvidersLoading: false,
|
||||
spawnStep: 1,
|
||||
spawnIdentity: { emoji: '', color: '#FF5C00', archetype: '' },
|
||||
selectedPreset: '',
|
||||
@@ -92,98 +94,12 @@ function agentsPage() {
|
||||
selectedCategory: 'All',
|
||||
searchQuery: '',
|
||||
|
||||
builtinTemplates: [
|
||||
{
|
||||
name: 'General Assistant',
|
||||
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
|
||||
category: 'General',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.'
|
||||
},
|
||||
{
|
||||
name: 'Code Helper',
|
||||
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.'
|
||||
},
|
||||
{
|
||||
name: 'Researcher',
|
||||
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
|
||||
category: 'Research',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'research',
|
||||
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.'
|
||||
},
|
||||
{
|
||||
name: 'Writer',
|
||||
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
|
||||
category: 'Writing',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.'
|
||||
},
|
||||
{
|
||||
name: 'Data Analyst',
|
||||
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.'
|
||||
},
|
||||
{
|
||||
name: 'DevOps Engineer',
|
||||
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'automation',
|
||||
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.'
|
||||
},
|
||||
{
|
||||
name: 'Customer Support',
|
||||
description: 'A professional, empathetic agent for handling customer inquiries and resolving issues.',
|
||||
category: 'Business',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'messaging',
|
||||
system_prompt: 'You are a professional customer support representative. Be empathetic, patient, and solution-oriented. Acknowledge concerns before offering solutions. Escalate complex issues appropriately.'
|
||||
},
|
||||
{
|
||||
name: 'Tutor',
|
||||
description: 'A patient educational agent that explains concepts step-by-step and adapts to the learner\'s level.',
|
||||
category: 'General',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a patient and encouraging tutor. Explain concepts step by step, starting from fundamentals. Use analogies and examples. Check understanding before moving on. Adapt to the learner\'s pace.'
|
||||
},
|
||||
{
|
||||
name: 'API Designer',
|
||||
description: 'An agent specialized in RESTful API design, OpenAPI specs, and integration architecture.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are an API design expert. Help users design clean, consistent RESTful APIs following best practices. Cover endpoint naming, request/response schemas, error handling, and versioning.'
|
||||
},
|
||||
{
|
||||
name: 'Meeting Notes',
|
||||
description: 'Summarizes meeting transcripts into structured notes with action items and key decisions.',
|
||||
category: 'Business',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'minimal',
|
||||
system_prompt: 'You are a meeting summarizer. When given a meeting transcript or notes, produce a structured summary with: key decisions, action items (with owners), discussion highlights, and follow-up questions.'
|
||||
}
|
||||
],
|
||||
builtinTemplates: [],
|
||||
|
||||
// Load templates from API
|
||||
async init() {
|
||||
await this.loadTemplates();
|
||||
},
|
||||
|
||||
// ── Profile Descriptions ──
|
||||
profileDescriptions: {
|
||||
@@ -280,6 +196,7 @@ function agentsPage() {
|
||||
this.loadError = '';
|
||||
try {
|
||||
await Alpine.store('app').refreshAgents();
|
||||
await this.loadTemplates();
|
||||
} catch(e) {
|
||||
this.loadError = e.message || 'Could not load agents. Is the daemon running?';
|
||||
}
|
||||
@@ -317,10 +234,73 @@ function agentsPage() {
|
||||
OpenFangAPI.get('/api/templates'),
|
||||
OpenFangAPI.get('/api/providers').catch(function() { return { providers: [] }; })
|
||||
]);
|
||||
this.tplTemplates = results[0].templates || [];
|
||||
// Combine static and dynamic templates
|
||||
this.builtinTemplates = [
|
||||
{
|
||||
name: 'General Assistant',
|
||||
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
|
||||
category: 'General',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.',
|
||||
manifest_toml: 'name = "General Assistant"\ndescription = "A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Code Helper',
|
||||
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.',
|
||||
manifest_toml: 'name = "Code Helper"\ndescription = "A programming-focused agent that writes, reviews, and debugs code across multiple languages."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Researcher',
|
||||
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
|
||||
category: 'Research',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'research',
|
||||
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.',
|
||||
manifest_toml: 'name = "Researcher"\ndescription = "An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries."\nmodule = "builtin:chat"\nprofile = "research"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Writer',
|
||||
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
|
||||
category: 'Writing',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.',
|
||||
manifest_toml: 'name = "Writer"\ndescription = "A creative writing agent that helps with drafting, editing, and improving written content of all kinds."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Data Analyst',
|
||||
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.',
|
||||
manifest_toml: 'name = "Data Analyst"\ndescription = "A data-focused agent that helps analyze datasets, create queries, and interpret statistical results."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'DevOps Engineer',
|
||||
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'automation',
|
||||
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.',
|
||||
manifest_toml: 'name = "DevOps Engineer"\ndescription = "A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting."\nmodule = "builtin:chat"\nprofile = "automation"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.\n"""'
|
||||
},
|
||||
...results[0].templates || []
|
||||
];
|
||||
this.tplProviders = results[1].providers || [];
|
||||
} catch(e) {
|
||||
this.tplTemplates = [];
|
||||
this.builtinTemplates = [];
|
||||
this.tplLoadError = e.message || 'Could not load templates.';
|
||||
}
|
||||
this.tplLoading = false;
|
||||
@@ -407,14 +387,22 @@ function agentsPage() {
|
||||
this.spawnForm.model = 'llama-3.3-70b-versatile';
|
||||
this.spawnForm.systemPrompt = 'You are a helpful assistant.';
|
||||
this.spawnForm.profile = 'full';
|
||||
// Fetch status defaults and dynamic provider list concurrently
|
||||
this.spawnProvidersLoading = true;
|
||||
try {
|
||||
var res = await fetch('/api/status');
|
||||
if (res.ok) {
|
||||
var status = await res.json();
|
||||
if (status.default_provider) this.spawnForm.provider = status.default_provider;
|
||||
if (status.default_model) this.spawnForm.model = status.default_model;
|
||||
}
|
||||
} catch(e) { /* keep hardcoded defaults */ }
|
||||
var results = await Promise.all([
|
||||
OpenFangAPI.get('/api/status').catch(function() { return {}; }),
|
||||
OpenFangAPI.get('/api/providers').catch(function() { return { providers: [] }; })
|
||||
]);
|
||||
var status = results[0];
|
||||
var provData = results[1];
|
||||
if (status.default_provider) this.spawnForm.provider = status.default_provider;
|
||||
if (status.default_model) this.spawnForm.model = status.default_model;
|
||||
this.spawnProviders = provData.providers || [];
|
||||
} catch(e) {
|
||||
this.spawnProviders = [];
|
||||
}
|
||||
this.spawnProvidersLoading = false;
|
||||
},
|
||||
|
||||
nextStep() {
|
||||
@@ -590,15 +578,20 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
// -- Template methods --
|
||||
async spawnFromTemplate(name) {
|
||||
async spawnFromTemplate(template) {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/templates/' + encodeURIComponent(name));
|
||||
if (data.manifest_toml) {
|
||||
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: data.manifest_toml });
|
||||
var manifestToml = template.manifest_toml;
|
||||
if (!manifestToml) {
|
||||
// If template doesn't have manifest_toml, fetch it from the API
|
||||
var data = await OpenFangAPI.get('/api/templates/' + encodeURIComponent(template.name));
|
||||
manifestToml = data.manifest_toml;
|
||||
}
|
||||
if (manifestToml) {
|
||||
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: manifestToml });
|
||||
if (res.agent_id) {
|
||||
OpenFangToast.success('Agent "' + (res.name || name) + '" spawned from template');
|
||||
OpenFangToast.success('Agent "' + (res.name || template.name) + '" spawned from template');
|
||||
await Alpine.store('app').refreshAgents();
|
||||
this.chatWithAgent({ id: res.agent_id, name: res.name || name, model_provider: '?', model_name: '?' });
|
||||
this.chatWithAgent({ id: res.agent_id, name: res.name || template.name, model_provider: '?', model_name: '?' });
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
|
||||
@@ -7,6 +7,22 @@ function approvalsPage() {
|
||||
filterStatus: 'all',
|
||||
loading: true,
|
||||
loadError: '',
|
||||
refreshTimer: null,
|
||||
|
||||
init() {
|
||||
var self = this;
|
||||
this.loadData();
|
||||
this.refreshTimer = setInterval(function() {
|
||||
self.loadData();
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
get filtered() {
|
||||
var f = this.filterStatus;
|
||||
|
||||
@@ -622,8 +622,12 @@ function chatPage() {
|
||||
this.scrollToBottom();
|
||||
this._resetTypingTimeout();
|
||||
} else if (data.level) {
|
||||
var lastThink = this.messages[this.messages.length - 1];
|
||||
if (lastThink && lastThink.thinking) lastThink.text = 'Thinking (' + data.level + ')...';
|
||||
var thinkIdx = this.messages.length - 1;
|
||||
var lastThink = thinkIdx >= 0 ? this.messages[thinkIdx] : null;
|
||||
if (lastThink && lastThink.thinking) {
|
||||
lastThink.text = 'Thinking (' + data.level + ')...';
|
||||
this.messages.splice(thinkIdx, 1, lastThink);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -636,9 +640,11 @@ function chatPage() {
|
||||
}
|
||||
this._resetTypingTimeout();
|
||||
} else if (data.state === 'tool') {
|
||||
var typingMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var toolTypIdx = this.messages.length - 1;
|
||||
var typingMsg = toolTypIdx >= 0 ? this.messages[toolTypIdx] : null;
|
||||
if (typingMsg && (typingMsg.thinking || typingMsg.streaming)) {
|
||||
typingMsg.text = 'Using ' + (data.tool || 'tool') + '...';
|
||||
this.messages.splice(toolTypIdx, 1, typingMsg);
|
||||
}
|
||||
this._resetTypingTimeout();
|
||||
} else if (data.state === 'stop') {
|
||||
@@ -648,7 +654,8 @@ function chatPage() {
|
||||
|
||||
case 'phase':
|
||||
// Show tool/phase progress so the user sees the agent is working
|
||||
var phaseMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var phaseIdx = this.messages.length - 1;
|
||||
var phaseMsg = phaseIdx >= 0 ? this.messages[phaseIdx] : null;
|
||||
if (phaseMsg && (phaseMsg.thinking || phaseMsg.streaming)) {
|
||||
// Skip phases that have no user-meaningful display text — "streaming"
|
||||
// and "done" are lifecycle signals, not status to show in the chat bubble.
|
||||
@@ -664,6 +671,7 @@ function chatPage() {
|
||||
if (!phaseMsg._reasoning) phaseMsg._reasoning = '';
|
||||
phaseMsg._reasoning += (data.detail || '') + '\n';
|
||||
phaseMsg.text = '<details><summary>Reasoning...</summary>\n\n' + phaseMsg._reasoning + '</details>';
|
||||
this.messages.splice(phaseIdx, 1, phaseMsg);
|
||||
} else if (phaseMsg.thinking) {
|
||||
// Only update text on messages still in thinking state (not yet
|
||||
// receiving streamed content) to avoid overwriting accumulated text.
|
||||
@@ -676,13 +684,15 @@ function chatPage() {
|
||||
phaseDetail = data.detail || 'Working...';
|
||||
}
|
||||
phaseMsg.text = phaseDetail;
|
||||
this.messages.splice(phaseIdx, 1, phaseMsg);
|
||||
}
|
||||
}
|
||||
this.scrollToBottom();
|
||||
break;
|
||||
|
||||
case 'text_delta':
|
||||
var last = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var lastIdx = this.messages.length - 1;
|
||||
var last = lastIdx >= 0 ? this.messages[lastIdx] : null;
|
||||
if (last && last.streaming) {
|
||||
if (last.thinking) { last.text = ''; last.thinking = false; }
|
||||
// If we already detected a text-based tool call, skip further text
|
||||
@@ -711,6 +721,10 @@ function chatPage() {
|
||||
}
|
||||
}
|
||||
this.tokenCount = Math.round(last.text.length / 4);
|
||||
// Force Alpine reactivity: splice-in-place so x-for re-renders
|
||||
// this item. Direct property mutation on array elements may not
|
||||
// trigger DOM updates from async WebSocket callbacks.
|
||||
this.messages.splice(lastIdx, 1, last);
|
||||
} else {
|
||||
this.messages.push({ id: ++msgId, role: 'agent', text: data.content, meta: '', streaming: true, tools: [] });
|
||||
}
|
||||
@@ -718,17 +732,20 @@ function chatPage() {
|
||||
break;
|
||||
|
||||
case 'tool_start':
|
||||
var lastMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var tsIdx = this.messages.length - 1;
|
||||
var lastMsg = tsIdx >= 0 ? this.messages[tsIdx] : null;
|
||||
if (lastMsg && lastMsg.streaming) {
|
||||
if (!lastMsg.tools) lastMsg.tools = [];
|
||||
lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: true, input: '', result: '', is_error: false });
|
||||
this.messages.splice(tsIdx, 1, lastMsg);
|
||||
}
|
||||
this.scrollToBottom();
|
||||
break;
|
||||
|
||||
case 'tool_end':
|
||||
// Tool call parsed by LLM — update tool card with input params
|
||||
var lastMsg2 = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var teIdx = this.messages.length - 1;
|
||||
var lastMsg2 = teIdx >= 0 ? this.messages[teIdx] : null;
|
||||
if (lastMsg2 && lastMsg2.tools) {
|
||||
for (var ti = lastMsg2.tools.length - 1; ti >= 0; ti--) {
|
||||
if (lastMsg2.tools[ti].name === data.tool && lastMsg2.tools[ti].running) {
|
||||
@@ -736,12 +753,14 @@ function chatPage() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.messages.splice(teIdx, 1, lastMsg2);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
// Tool execution completed — update tool card with result
|
||||
var lastMsg3 = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var trIdx = this.messages.length - 1;
|
||||
var lastMsg3 = trIdx >= 0 ? this.messages[trIdx] : null;
|
||||
if (lastMsg3 && lastMsg3.tools) {
|
||||
for (var ri = lastMsg3.tools.length - 1; ri >= 0; ri--) {
|
||||
if (lastMsg3.tools[ri].name === data.tool && lastMsg3.tools[ri].running) {
|
||||
@@ -770,6 +789,7 @@ function chatPage() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.messages.splice(trIdx, 1, lastMsg3);
|
||||
}
|
||||
this.scrollToBottom();
|
||||
break;
|
||||
@@ -1125,6 +1145,9 @@ function chatPage() {
|
||||
|
||||
formatToolJson: function(text) {
|
||||
if (!text) return '';
|
||||
if (typeof text === 'object') {
|
||||
return JSON.stringify(text, null, 2);
|
||||
}
|
||||
try { return JSON.stringify(JSON.parse(text), null, 2); }
|
||||
catch(e) { return text; }
|
||||
},
|
||||
|
||||
@@ -201,15 +201,17 @@ function schedulerPage() {
|
||||
async runNow(job) {
|
||||
this.runningJobId = job.id;
|
||||
try {
|
||||
var result = await OpenFangAPI.post('/api/schedules/' + job.id + '/run', {});
|
||||
if (result.status === 'completed') {
|
||||
OpenFangToast.success('Schedule "' + (job.name || 'job') + '" executed successfully');
|
||||
job.last_run = new Date().toISOString();
|
||||
var result = await OpenFangAPI.post('/api/cron/jobs/' + job.id + '/run', {});
|
||||
if (result.status === 'triggered' || result.status === 'completed') {
|
||||
OpenFangToast.success('Job "' + (job.name || 'job') + '" triggered');
|
||||
// Don't update job.last_run here — the job runs asynchronously in the
|
||||
// background. The real last_run is set by the server on completion and
|
||||
// will appear on the next data refresh.
|
||||
} else {
|
||||
OpenFangToast.error('Schedule run failed: ' + (result.error || 'Unknown error'));
|
||||
OpenFangToast.error('Run failed: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Run Now is not yet available for cron jobs');
|
||||
OpenFangToast.error('Run failed: ' + (e.message || e));
|
||||
}
|
||||
this.runningJobId = '';
|
||||
},
|
||||
|
||||
@@ -254,6 +254,15 @@ function wizardPage() {
|
||||
this.error = '';
|
||||
try {
|
||||
await this.loadProviders();
|
||||
// Pre-select first unconfigured provider, or first one
|
||||
var unconfigured = this.providers.filter(function(p) {
|
||||
return p.auth_status !== 'configured' && p.api_key_env;
|
||||
});
|
||||
if (unconfigured.length > 0) {
|
||||
this.selectedProvider = unconfigured[0].id;
|
||||
} else if (this.providers.length > 0) {
|
||||
this.selectedProvider = this.providers[0].id;
|
||||
}
|
||||
} catch(e) {
|
||||
this.error = e.message || 'Could not load setup data.';
|
||||
}
|
||||
@@ -313,15 +322,6 @@ function wizardPage() {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/providers');
|
||||
this.providers = data.providers || [];
|
||||
// Pre-select first unconfigured provider, or first one
|
||||
var unconfigured = this.providers.filter(function(p) {
|
||||
return p.auth_status !== 'configured' && p.api_key_env;
|
||||
});
|
||||
if (unconfigured.length > 0) {
|
||||
this.selectedProvider = unconfigured[0].id;
|
||||
} else if (this.providers.length > 0) {
|
||||
this.selectedProvider = this.providers[0].id;
|
||||
}
|
||||
} catch(e) { this.providers = []; }
|
||||
},
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ async fn start_test_server_with_provider(
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -707,6 +708,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let api_key = state.kernel.config.api_key.trim().to_string();
|
||||
|
||||
@@ -115,6 +115,7 @@ async fn test_full_daemon_lifecycle() {
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -240,6 +241,7 @@ async fn test_server_immediate_responsiveness() {
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -59,6 +59,7 @@ async fn start_test_server() -> TestServer {
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -14,6 +14,7 @@ chrono = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
prost = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -31,12 +32,13 @@ base64 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
html-escape = { workspace = true }
|
||||
regex-lite = "0.1"
|
||||
roxmltree = "0.20"
|
||||
roxmltree = "0.21"
|
||||
|
||||
lettre = { workspace = true }
|
||||
imap = { workspace = true }
|
||||
native-tls = { workspace = true }
|
||||
mailparse = { workspace = true }
|
||||
rumqttc = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -13,6 +13,7 @@ use async_trait::async_trait;
|
||||
use dashmap::DashMap;
|
||||
use futures::StreamExt;
|
||||
use openfang_types::agent::AgentId;
|
||||
use openfang_types::approval::ApprovalRequest;
|
||||
use openfang_types::config::{ChannelOverrides, DmPolicy, GroupPolicy, OutputFormat};
|
||||
use openfang_types::message::ContentBlock;
|
||||
use std::sync::Arc;
|
||||
@@ -20,6 +21,77 @@ use std::time::{Duration, Instant};
|
||||
use tokio::sync::watch;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ChatCommandSpec {
|
||||
pub name: &'static str,
|
||||
pub desc: &'static str,
|
||||
pub help: &'static str,
|
||||
pub section: &'static str,
|
||||
}
|
||||
|
||||
const CHANNEL_COMMAND_SPECS: &[ChatCommandSpec] = &[
|
||||
ChatCommandSpec { name: "start", desc: "Show welcome message", help: "/start - show welcome message", section: "General" },
|
||||
ChatCommandSpec { name: "help", desc: "Show available commands", help: "/help - show this help", section: "General" },
|
||||
ChatCommandSpec { name: "agents", desc: "List running agents", help: "/agents - list running agents", section: "Session" },
|
||||
ChatCommandSpec { name: "agent", desc: "Select agent (/agent <name>)", help: "/agent <name> - select which agent to talk to", section: "Session" },
|
||||
ChatCommandSpec { name: "new", desc: "Reset session (clear history)", help: "/new - reset session (clear messages)", section: "Session" },
|
||||
ChatCommandSpec { name: "compact", desc: "Trigger LLM session compaction", help: "/compact - trigger LLM session compaction", section: "Session" },
|
||||
ChatCommandSpec { name: "model", desc: "Show or switch model", help: "/model [name] - show or switch agent model", section: "Session" },
|
||||
ChatCommandSpec { name: "stop", desc: "Cancel current agent run", help: "/stop - cancel current agent run", section: "Session" },
|
||||
ChatCommandSpec { name: "usage", desc: "Show session usage and cost", help: "/usage - show session token usage and cost", section: "Session" },
|
||||
ChatCommandSpec { name: "think", desc: "Toggle extended thinking", help: "/think [on|off] - toggle extended thinking", section: "Session" },
|
||||
ChatCommandSpec { name: "status", desc: "Show system status", help: "/status - show system status", section: "Info" },
|
||||
ChatCommandSpec { name: "models", desc: "List available AI models", help: "/models - list available AI models", section: "Info" },
|
||||
ChatCommandSpec { name: "providers", desc: "Show configured providers", help: "/providers - show configured providers", section: "Info" },
|
||||
ChatCommandSpec { name: "skills", desc: "List installed skills", help: "/skills - list installed skills", section: "Info" },
|
||||
ChatCommandSpec { name: "hands", desc: "List available and active hands", help: "/hands - list available and active hands", section: "Info" },
|
||||
ChatCommandSpec { name: "workflows", desc: "List workflows", help: "/workflows - list workflows", section: "Automation" },
|
||||
ChatCommandSpec { name: "workflow", desc: "Run workflow (/workflow run <name> [input])", help: "/workflow run <name> [input] - run a workflow", section: "Automation" },
|
||||
ChatCommandSpec { name: "triggers", desc: "List event triggers", help: "/triggers - list event triggers", section: "Automation" },
|
||||
ChatCommandSpec { name: "trigger", desc: "Manage triggers", help: "/trigger add <agent> <pattern> <prompt> | /trigger del <id>", section: "Automation" },
|
||||
ChatCommandSpec { name: "schedules", desc: "List cron jobs", help: "/schedules - list cron jobs", section: "Automation" },
|
||||
ChatCommandSpec { name: "schedule", desc: "Manage schedules", help: "/schedule add <agent> <cron-5-fields> <message> | /schedule del <id> | /schedule run <id>", section: "Automation" },
|
||||
ChatCommandSpec { name: "approvals", desc: "List pending approvals", help: "/approvals - list pending approvals", section: "Automation" },
|
||||
ChatCommandSpec { name: "approve", desc: "Approve request", help: "/approve <id> - approve a request", section: "Automation" },
|
||||
ChatCommandSpec { name: "reject", desc: "Reject request", help: "/reject <id> - reject a request", section: "Automation" },
|
||||
ChatCommandSpec { name: "budget", desc: "Show spending limits and costs", help: "/budget - show spending limits and current costs", section: "Monitoring" },
|
||||
ChatCommandSpec { name: "peers", desc: "Show OFP peer network status", help: "/peers - show OFP peer network status", section: "Monitoring" },
|
||||
ChatCommandSpec { name: "a2a", desc: "List discovered external A2A agents", help: "/a2a - list discovered external A2A agents", section: "Monitoring" },
|
||||
];
|
||||
|
||||
pub fn channel_command_specs() -> &'static [ChatCommandSpec] {
|
||||
CHANNEL_COMMAND_SPECS
|
||||
}
|
||||
|
||||
fn is_channel_command(name: &str) -> bool {
|
||||
channel_command_specs().iter().any(|spec| spec.name == name)
|
||||
}
|
||||
|
||||
fn format_channel_help() -> String {
|
||||
let sections = ["General", "Session", "Info", "Automation", "Monitoring"];
|
||||
let mut msg = String::from("OpenFang Bot Commands:");
|
||||
|
||||
for section in sections {
|
||||
let commands: Vec<&ChatCommandSpec> = channel_command_specs()
|
||||
.iter()
|
||||
.filter(|spec| spec.section == section)
|
||||
.collect();
|
||||
if commands.is_empty() {
|
||||
continue;
|
||||
}
|
||||
msg.push_str("\n\n");
|
||||
msg.push_str(section);
|
||||
msg.push_str(":\n");
|
||||
for spec in commands {
|
||||
msg.push_str(spec.help);
|
||||
msg.push('\n');
|
||||
}
|
||||
msg.pop();
|
||||
}
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
/// Kernel operations needed by channel adapters.
|
||||
///
|
||||
/// Defined here to avoid circular deps (openfang-channels can't depend on openfang-kernel).
|
||||
@@ -58,6 +130,20 @@ pub trait ChannelBridgeHandle: Send + Sync {
|
||||
/// Spawn an agent by manifest name, returning its ID.
|
||||
async fn spawn_agent_by_name(&self, manifest_name: &str) -> Result<AgentId, String>;
|
||||
|
||||
/// Transcribe raw audio bytes to text.
|
||||
async fn transcribe_audio(
|
||||
&self,
|
||||
_audio_bytes: Vec<u8>,
|
||||
_mime_type: &str,
|
||||
) -> Result<String, String> {
|
||||
Err("Audio transcription not available.".to_string())
|
||||
}
|
||||
|
||||
/// List pending approval requests for a specific agent.
|
||||
async fn pending_approvals_for_agent(&self, _agent_id: AgentId) -> Vec<ApprovalRequest> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Return uptime info string (e.g., "2h 15m, 5 agents").
|
||||
async fn uptime_info(&self) -> String {
|
||||
let agents = self.list_agents().await.unwrap_or_default();
|
||||
@@ -396,6 +482,7 @@ fn channel_type_str(channel: &crate::types::ChannelType) -> &str {
|
||||
crate::types::ChannelType::WebChat => "webchat",
|
||||
crate::types::ChannelType::CLI => "cli",
|
||||
crate::types::ChannelType::Custom(s) => s.as_str(),
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,36 +780,7 @@ async fn dispatch_message(
|
||||
vec![]
|
||||
};
|
||||
|
||||
if matches!(
|
||||
cmd,
|
||||
"start"
|
||||
| "help"
|
||||
| "agents"
|
||||
| "agent"
|
||||
| "status"
|
||||
| "models"
|
||||
| "providers"
|
||||
| "new"
|
||||
| "compact"
|
||||
| "model"
|
||||
| "stop"
|
||||
| "usage"
|
||||
| "think"
|
||||
| "skills"
|
||||
| "hands"
|
||||
| "workflows"
|
||||
| "workflow"
|
||||
| "triggers"
|
||||
| "trigger"
|
||||
| "schedules"
|
||||
| "schedule"
|
||||
| "approvals"
|
||||
| "approve"
|
||||
| "reject"
|
||||
| "budget"
|
||||
| "peers"
|
||||
| "a2a"
|
||||
) {
|
||||
if is_channel_command(cmd) {
|
||||
let result = handle_command(cmd, &args, handle, router, &message.sender).await;
|
||||
send_response(adapter, &message.sender, result, thread_id, output_format).await;
|
||||
return;
|
||||
@@ -890,8 +948,24 @@ async fn dispatch_message(
|
||||
// (which expire typing after ~5s) keep showing it during long LLM calls.
|
||||
let typing_task = spawn_typing_loop(adapter_arc.clone(), message.sender.clone());
|
||||
|
||||
// Prepend sender context so the agent knows who is speaking.
|
||||
// In group spaces this is essential for multi-user conversations.
|
||||
let sender_name = &message.sender.display_name;
|
||||
let sender_email = message
|
||||
.metadata
|
||||
.get("sender_email")
|
||||
.and_then(|v| v.as_str());
|
||||
let prefixed_text = if !sender_name.is_empty() {
|
||||
match sender_email {
|
||||
Some(email) => format!("[From: {sender_name} <{email}>] {text}"),
|
||||
None => format!("[From: {sender_name}] {text}"),
|
||||
}
|
||||
} else {
|
||||
text.clone()
|
||||
};
|
||||
|
||||
// Send to agent and relay response
|
||||
let result = handle.send_message(agent_id, &text).await;
|
||||
let result = handle.send_message(agent_id, &prefixed_text).await;
|
||||
|
||||
// Stop the typing refresh now that we have a response
|
||||
typing_task.abort();
|
||||
@@ -1429,47 +1503,7 @@ async fn handle_command(
|
||||
msg.push_str("\nCommands:\n/agents - list agents\n/agent <name> - select an agent\n/help - show this help");
|
||||
msg
|
||||
}
|
||||
"help" => "OpenFang Bot Commands:\n\
|
||||
\n\
|
||||
Session:\n\
|
||||
/agents - list running agents\n\
|
||||
/agent <name> - select which agent to talk to\n\
|
||||
/new - reset session (clear messages)\n\
|
||||
/compact - trigger LLM session compaction\n\
|
||||
/model [name] - show or switch agent model\n\
|
||||
/stop - cancel current agent run\n\
|
||||
/usage - show session token usage and cost\n\
|
||||
/think [on|off] - toggle extended thinking\n\
|
||||
\n\
|
||||
Info:\n\
|
||||
/models - list available AI models\n\
|
||||
/providers - show configured providers\n\
|
||||
/skills - list installed skills\n\
|
||||
/hands - list available and active hands\n\
|
||||
/status - show system status\n\
|
||||
\n\
|
||||
Automation:\n\
|
||||
/workflows - list workflows\n\
|
||||
/workflow run <name> [input] - run a workflow\n\
|
||||
/triggers - list event triggers\n\
|
||||
/trigger add <agent> <pattern> <prompt> - create trigger\n\
|
||||
/trigger del <id> - remove trigger\n\
|
||||
/schedules - list cron jobs\n\
|
||||
/schedule add <agent> <cron-5-fields> <message> - create job\n\
|
||||
/schedule del <id> - remove job\n\
|
||||
/schedule run <id> - run job now\n\
|
||||
/approvals - list pending approvals\n\
|
||||
/approve <id> - approve a request\n\
|
||||
/reject <id> - reject a request\n\
|
||||
\n\
|
||||
Monitoring:\n\
|
||||
/budget - show spending limits and current costs\n\
|
||||
/peers - show OFP peer network status\n\
|
||||
/a2a - list discovered external A2A agents\n\
|
||||
\n\
|
||||
/start - show welcome message\n\
|
||||
/help - show this help"
|
||||
.to_string(),
|
||||
"help" => format_channel_help(),
|
||||
"status" => handle.uptime_info().await,
|
||||
"agents" => {
|
||||
let agents = handle.list_agents().await.unwrap_or_default();
|
||||
@@ -1485,7 +1519,15 @@ async fn handle_command(
|
||||
}
|
||||
"agent" => {
|
||||
if args.is_empty() {
|
||||
return "Usage: /agent <name>".to_string();
|
||||
let agents = handle.list_agents().await.unwrap_or_default();
|
||||
if agents.is_empty() {
|
||||
return "No agents running. Usage: /agent <name>".to_string();
|
||||
}
|
||||
let mut msg = "Usage: /agent <name>\n\nAvailable agents:\n".to_string();
|
||||
for (_, name) in &agents {
|
||||
msg.push_str(&format!(" - {name}\n"));
|
||||
}
|
||||
return msg.trim_end().to_string();
|
||||
}
|
||||
let agent_name = &args[0];
|
||||
match handle.find_agent_by_name(agent_name).await {
|
||||
@@ -1788,6 +1830,32 @@ mod tests {
|
||||
assert_eq!(resolved, Some(agent_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handle_command_agent_without_args_lists_agents() {
|
||||
let agent_id = AgentId::new();
|
||||
let handle: Arc<dyn ChannelBridgeHandle> = Arc::new(MockHandle {
|
||||
agents: Mutex::new(vec![(agent_id, "coder".to_string())]),
|
||||
});
|
||||
let router = Arc::new(AgentRouter::new());
|
||||
let sender = ChannelUser {
|
||||
platform_id: "user1".to_string(),
|
||||
display_name: "Test".to_string(),
|
||||
openfang_user: None,
|
||||
};
|
||||
|
||||
let result = handle_command("agent", &[], &handle, &router, &sender).await;
|
||||
assert!(result.contains("Usage: /agent <name>"));
|
||||
assert!(result.contains("coder"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_command_specs_include_agent_and_help() {
|
||||
let specs = channel_command_specs();
|
||||
assert!(specs.iter().any(|spec| spec.name == "help"));
|
||||
assert!(specs.iter().any(|spec| spec.name == "agent"));
|
||||
assert!(specs.iter().any(|spec| spec.name == "agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limiter_allows_within_limit() {
|
||||
let limiter = ChannelRateLimiter::default();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,7 @@ pub mod gitter;
|
||||
pub mod gotify;
|
||||
pub mod linkedin;
|
||||
pub mod mumble;
|
||||
pub mod mqtt;
|
||||
pub mod ntfy;
|
||||
pub mod webhook;
|
||||
pub mod wecom;
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// LINE push message API endpoint.
|
||||
@@ -62,8 +62,8 @@ impl LineAdapter {
|
||||
pub fn new(channel_secret: String, access_token: String, webhook_port: u16) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
channel_secret: Zeroizing::new(channel_secret),
|
||||
access_token: Zeroizing::new(access_token),
|
||||
channel_secret: Zeroizing::new(channel_secret.trim().to_string()),
|
||||
access_token: Zeroizing::new(access_token.trim().to_string()),
|
||||
webhook_port,
|
||||
client: reqwest::Client::new(),
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
@@ -96,12 +96,35 @@ impl LineAdapter {
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if result.len() != expected.len() {
|
||||
debug!(
|
||||
"LINE: signature length mismatch: computed={} received={}",
|
||||
result.len(),
|
||||
expected.len()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (a, b) in result.iter().zip(expected.iter()) {
|
||||
diff |= a ^ b;
|
||||
}
|
||||
if diff != 0 {
|
||||
let computed = base64::engine::general_purpose::STANDARD.encode(&result);
|
||||
// Log first/last 4 chars of each signature for debugging without leaking full HMAC
|
||||
let comp_redacted = format!(
|
||||
"{}...{}",
|
||||
&computed[..4.min(computed.len())],
|
||||
&computed[computed.len().saturating_sub(4)..]
|
||||
);
|
||||
let recv_redacted = format!(
|
||||
"{}...{}",
|
||||
&signature[..4.min(signature.len())],
|
||||
&signature[signature.len().saturating_sub(4)..]
|
||||
);
|
||||
debug!(
|
||||
"LINE: signature mismatch: computed={comp_redacted} received={recv_redacted} body_len={}",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
@@ -359,18 +382,18 @@ impl ChannelAdapter for LineAdapter {
|
||||
let secret = Arc::clone(&channel_secret);
|
||||
let tx = Arc::clone(&tx);
|
||||
move |headers: axum::http::HeaderMap,
|
||||
body: axum::extract::Json<serde_json::Value>| {
|
||||
body: axum::body::Bytes| {
|
||||
let secret = Arc::clone(&secret);
|
||||
let tx = Arc::clone(&tx);
|
||||
async move {
|
||||
// Verify X-Line-Signature
|
||||
// Verify X-Line-Signature using the raw request
|
||||
// body bytes — NOT re-serialized JSON — because the
|
||||
// HMAC must be computed over the exact bytes LINE sent.
|
||||
let signature = headers
|
||||
.get("x-line-signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let body_bytes = serde_json::to_vec(&body.0).unwrap_or_default();
|
||||
|
||||
// Create a temporary adapter-like verifier
|
||||
let adapter = LineAdapter {
|
||||
channel_secret: secret.as_ref().clone(),
|
||||
@@ -382,14 +405,23 @@ impl ChannelAdapter for LineAdapter {
|
||||
};
|
||||
|
||||
if !signature.is_empty()
|
||||
&& !adapter.verify_signature(&body_bytes, signature)
|
||||
&& !adapter.verify_signature(&body, signature)
|
||||
{
|
||||
warn!("LINE: invalid webhook signature");
|
||||
return axum::http::StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
|
||||
// Parse the raw bytes into JSON after signature verification
|
||||
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("LINE: failed to parse webhook body as JSON: {e}");
|
||||
return axum::http::StatusCode::BAD_REQUEST;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse events array
|
||||
if let Some(events) = body.0["events"].as_array() {
|
||||
if let Some(events) = parsed["events"].as_array() {
|
||||
for event in events {
|
||||
if let Some(msg) = parse_line_event(event) {
|
||||
let _ = tx.send(msg).await;
|
||||
@@ -626,6 +658,71 @@ mod tests {
|
||||
assert!(parse_line_event(&event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_with_raw_body() {
|
||||
// Verify that HMAC-SHA256 signature validation works with raw body bytes
|
||||
let secret = "test-channel-secret";
|
||||
let adapter = LineAdapter::new(secret.to_string(), "token".to_string(), 9000);
|
||||
|
||||
// Compute the expected signature manually
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let body = br#"{"events":[{"type":"message"}]}"#;
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(body);
|
||||
let expected_sig =
|
||||
base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
|
||||
|
||||
assert!(adapter.verify_signature(body, &expected_sig));
|
||||
|
||||
// Re-serialized JSON should NOT match (this was the bug)
|
||||
let parsed: serde_json::Value = serde_json::from_slice(body).unwrap();
|
||||
let reserialized = serde_json::to_vec(&parsed).unwrap();
|
||||
// The re-serialized form may differ in whitespace/key order
|
||||
// If it happens to be identical for this input, the test still validates
|
||||
// the core mechanism works with raw bytes
|
||||
if reserialized != body.to_vec() {
|
||||
assert!(!adapter.verify_signature(&reserialized, &expected_sig));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_secret_trimmed() {
|
||||
// Environment variables often have trailing newlines or spaces
|
||||
let adapter = LineAdapter::new(
|
||||
" my-secret\n".to_string(),
|
||||
" my-token\r\n".to_string(),
|
||||
9000,
|
||||
);
|
||||
assert_eq!(adapter.channel_secret.as_str(), "my-secret");
|
||||
assert_eq!(adapter.access_token.as_str(), "my-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_bad_base64() {
|
||||
let adapter = LineAdapter::new("secret".to_string(), "token".to_string(), 9000);
|
||||
assert!(!adapter.verify_signature(b"body", "not-valid-base64!!!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_wrong_secret() {
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let body = b"test body";
|
||||
let mut mac = HmacSha256::new_from_slice(b"wrong-secret").unwrap();
|
||||
mac.update(body);
|
||||
let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
|
||||
|
||||
let adapter = LineAdapter::new("correct-secret".to_string(), "token".to_string(), 9000);
|
||||
assert!(!adapter.verify_signature(body, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_line_event_room_source() {
|
||||
let event = serde_json::json!({
|
||||
|
||||
@@ -46,6 +46,7 @@ impl MatrixAdapter {
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
allowed_rooms: Vec<String>,
|
||||
auto_accept_invites: bool,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
@@ -57,7 +58,7 @@ impl MatrixAdapter {
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
since_token: Arc::new(RwLock::new(None)),
|
||||
auto_accept_invites: true,
|
||||
auto_accept_invites,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +219,11 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
let homeserver = self.homeserver_url.clone();
|
||||
let access_token = self.access_token.clone();
|
||||
let user_id = self.user_id.clone();
|
||||
// Use the validated user ID from /whoami instead of the config value.
|
||||
// Matrix server delegation or casing differences can cause self.user_id
|
||||
// to not match the sender field in timeline events, making the bot
|
||||
// process its own replies in an infinite loop (see #757).
|
||||
let user_id = validated_user;
|
||||
let allowed_rooms = self.allowed_rooms.clone();
|
||||
let client = self.client.clone();
|
||||
let since_token = Arc::clone(&self.since_token);
|
||||
@@ -235,6 +240,11 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
// Track recently seen event IDs to prevent duplicate processing
|
||||
// on sync token races or reconnects.
|
||||
let mut seen_events: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::new();
|
||||
const MAX_SEEN: usize = 500;
|
||||
|
||||
loop {
|
||||
// Build /sync URL
|
||||
@@ -325,6 +335,21 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
continue; // Skip own messages
|
||||
}
|
||||
|
||||
// Dedup: skip events we've already processed.
|
||||
let event_id_str =
|
||||
event["event_id"].as_str().unwrap_or("").to_string();
|
||||
if !event_id_str.is_empty() {
|
||||
if seen_events.contains(&event_id_str) {
|
||||
debug!("Matrix: skipping duplicate event {event_id_str}");
|
||||
continue;
|
||||
}
|
||||
seen_events.insert(event_id_str.clone());
|
||||
// Prevent unbounded growth
|
||||
if seen_events.len() > MAX_SEEN {
|
||||
seen_events.clear();
|
||||
}
|
||||
}
|
||||
|
||||
let content = event["content"]["body"].as_str().unwrap_or("");
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
@@ -345,7 +370,34 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
ChannelContent::Text(content.to_string())
|
||||
};
|
||||
|
||||
let event_id = event["event_id"].as_str().unwrap_or("").to_string();
|
||||
// FIX #2: Detect @mentions in message text.
|
||||
let mut metadata = HashMap::new();
|
||||
if content.contains(&user_id) {
|
||||
metadata.insert(
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
}
|
||||
|
||||
// FIX #3: Determine if room is a DM (2 members) or group.
|
||||
let is_group = get_room_member_count(
|
||||
&client,
|
||||
&homeserver,
|
||||
access_token.as_str(),
|
||||
room_id,
|
||||
)
|
||||
.await
|
||||
.map(|count| count > 2)
|
||||
.unwrap_or(true);
|
||||
|
||||
// For DMs, auto-set was_mentioned so dm_policy works.
|
||||
if !is_group {
|
||||
metadata.insert(
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
metadata.insert("is_dm".to_string(), serde_json::json!(true));
|
||||
}
|
||||
|
||||
// FIX #2: Detect @mentions in message text.
|
||||
let mut metadata = HashMap::new();
|
||||
@@ -378,7 +430,7 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
|
||||
let channel_msg = ChannelMessage {
|
||||
channel: ChannelType::Matrix,
|
||||
platform_message_id: event_id,
|
||||
platform_message_id: event_id_str,
|
||||
sender: ChannelUser {
|
||||
platform_id: room_id.clone(),
|
||||
display_name: sender.to_string(),
|
||||
@@ -461,6 +513,7 @@ mod tests {
|
||||
"@bot:matrix.org".to_string(),
|
||||
"access_token".to_string(),
|
||||
vec![],
|
||||
false,
|
||||
);
|
||||
assert_eq!(adapter.name(), "matrix");
|
||||
}
|
||||
@@ -472,6 +525,7 @@ mod tests {
|
||||
"@bot:matrix.org".to_string(),
|
||||
"token".to_string(),
|
||||
vec!["!room1:matrix.org".to_string()],
|
||||
false,
|
||||
);
|
||||
assert!(adapter.is_allowed_room("!room1:matrix.org"));
|
||||
assert!(!adapter.is_allowed_room("!room2:matrix.org"));
|
||||
@@ -481,6 +535,7 @@ mod tests {
|
||||
"@bot:matrix.org".to_string(),
|
||||
"token".to_string(),
|
||||
vec![],
|
||||
false,
|
||||
);
|
||||
assert!(open.is_allowed_room("!any:matrix.org"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
//! MQTT channel adapter.
|
||||
//!
|
||||
//! Provides a generic MQTT pub/sub interface for IoT and messaging integration.
|
||||
//! Supports standard MQTT 3.1.1/5.0 brokers with optional TLS and authentication.
|
||||
//!
|
||||
//! # Configuration
|
||||
//!
|
||||
//! ```toml
|
||||
//! [channels.mqtt]
|
||||
//! broker_url = "tcp://broker.hivemq.com:1883"
|
||||
//! subscribe_topic = "openfang/inbox"
|
||||
//! publish_topic = "openfang/outbox"
|
||||
//! username_env = "MQTT_USERNAME"
|
||||
//! password_env = "MQTT_PASSWORD"
|
||||
//! use_tls = false
|
||||
//! qos = 1
|
||||
//! ```
|
||||
//!
|
||||
//! # Message Format
|
||||
//!
|
||||
//! Incoming messages are expected as UTF-8 text. The adapter supports:
|
||||
//! - Plain text messages
|
||||
//! - JSON payloads with `{"text": "message"}` format
|
||||
//! - Command messages starting with `/`
|
||||
|
||||
use crate::types::{
|
||||
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use futures::Stream;
|
||||
use rumqttc::{AsyncClient, Event, Incoming, MqttOptions, QoS};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Maximum MQTT message length.
|
||||
const MAX_MESSAGE_LEN: usize = 4096;
|
||||
|
||||
/// Type alias for the publish channel sender.
|
||||
type PublishSender = Arc<RwLock<Option<mpsc::Sender<(String, String)>>>>;
|
||||
|
||||
/// MQTT pub/sub channel adapter.
|
||||
///
|
||||
/// Connects to an MQTT broker, subscribes to a topic for incoming messages,
|
||||
/// and publishes responses to another topic.
|
||||
pub struct MqttAdapter {
|
||||
/// MQTT broker URL (e.g., `"tcp://broker.hivemq.com:1883"`).
|
||||
broker_url: String,
|
||||
/// Client identifier (auto-generated if empty).
|
||||
client_id: String,
|
||||
/// Topic to subscribe to for incoming messages.
|
||||
subscribe_topic: String,
|
||||
/// Topic to publish responses to.
|
||||
publish_topic: String,
|
||||
/// Optional username for authentication.
|
||||
username: Option<String>,
|
||||
/// Optional password for authentication.
|
||||
password: Option<String>,
|
||||
/// Use TLS/SSL connection.
|
||||
use_tls: bool,
|
||||
/// Keep-alive interval in seconds.
|
||||
keep_alive: u16,
|
||||
/// Clean session flag.
|
||||
clean_session: bool,
|
||||
/// QoS level for subscriptions.
|
||||
qos: QoS,
|
||||
/// Shutdown signal.
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Sender for publishing messages (used to communicate with the event loop task).
|
||||
publish_tx: PublishSender,
|
||||
}
|
||||
|
||||
impl MqttAdapter {
|
||||
/// Create a new MQTT adapter.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `broker_url` - MQTT broker URL (e.g., `"tcp://broker.hivemq.com:1883"`).
|
||||
/// * `client_id` - Client identifier (auto-generated if empty).
|
||||
/// * `subscribe_topic` - Topic to subscribe to for incoming messages.
|
||||
/// * `publish_topic` - Topic to publish responses to (defaults to subscribe_topic if empty).
|
||||
/// * `username` - Optional username for authentication.
|
||||
/// * `password` - Optional password for authentication.
|
||||
/// * `use_tls` - Use TLS/SSL connection.
|
||||
/// * `keep_alive` - Keep-alive interval in seconds.
|
||||
/// * `clean_session` - Clean session flag.
|
||||
/// * `qos` - QoS level (0, 1, or 2).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
broker_url: String,
|
||||
client_id: String,
|
||||
subscribe_topic: String,
|
||||
publish_topic: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
use_tls: bool,
|
||||
keep_alive: u16,
|
||||
clean_session: bool,
|
||||
qos: u8,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let publish_topic = if publish_topic.is_empty() {
|
||||
subscribe_topic.clone()
|
||||
} else {
|
||||
publish_topic
|
||||
};
|
||||
let qos = match qos {
|
||||
0 => QoS::AtMostOnce,
|
||||
2 => QoS::ExactlyOnce,
|
||||
_ => QoS::AtLeastOnce,
|
||||
};
|
||||
|
||||
Self {
|
||||
broker_url,
|
||||
client_id,
|
||||
subscribe_topic,
|
||||
publish_topic,
|
||||
username,
|
||||
password,
|
||||
use_tls,
|
||||
keep_alive,
|
||||
clean_session,
|
||||
qos,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
publish_tx: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse broker URL into host and port.
|
||||
fn parse_broker_url(&self) -> Result<(String, u16), Box<dyn std::error::Error>> {
|
||||
let url = self.broker_url.trim();
|
||||
|
||||
// Handle different URL schemes
|
||||
if let Some(rest) = url.strip_prefix("tcp://") {
|
||||
Self::parse_host_port(rest, 1883)
|
||||
} else if let Some(rest) = url.strip_prefix("ssl://") {
|
||||
Self::parse_host_port(rest, 8883)
|
||||
} else if self.use_tls && !url.contains("://") {
|
||||
// Plain host with TLS flag
|
||||
Self::parse_host_port(url, 8883)
|
||||
} else if url.contains("://") {
|
||||
Err(format!("Unsupported MQTT URL scheme: {url}").into())
|
||||
} else {
|
||||
// Plain host:port or just host (no TLS)
|
||||
Self::parse_host_port(url, 1883)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse host:port string.
|
||||
fn parse_host_port(s: &str, default_port: u16) -> Result<(String, u16), Box<dyn std::error::Error>> {
|
||||
let s = s.trim();
|
||||
if let Some(colon_pos) = s.rfind(':') {
|
||||
let host = s[..colon_pos].to_string();
|
||||
let port = s[colon_pos + 1..].parse::<u16>()?;
|
||||
Ok((host, port))
|
||||
} else {
|
||||
Ok((s.to_string(), default_port))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build MQTT options.
|
||||
fn build_mqtt_options(&self) -> Result<MqttOptions, Box<dyn std::error::Error>> {
|
||||
let (host, port) = self.parse_broker_url()?;
|
||||
let client_id = if self.client_id.is_empty() {
|
||||
format!("openfang-{}", uuid::Uuid::new_v4())
|
||||
} else {
|
||||
self.client_id.clone()
|
||||
};
|
||||
|
||||
let mut options = MqttOptions::new(client_id, host, port);
|
||||
options.set_keep_alive(Duration::from_secs(self.keep_alive as u64));
|
||||
options.set_clean_session(self.clean_session);
|
||||
|
||||
if let (Some(user), Some(pass)) = (&self.username, &self.password) {
|
||||
options.set_credentials(user, pass);
|
||||
}
|
||||
|
||||
// Note: TLS support requires additional configuration with rustls
|
||||
// For now, we use native TLS through the use_tls flag
|
||||
if self.use_tls {
|
||||
// rumqttc handles TLS automatically when using ssl:// or with explicit config
|
||||
// This is a simplified approach; production use may need custom TLS config
|
||||
}
|
||||
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
/// Parse incoming MQTT payload.
|
||||
fn parse_payload(payload: &[u8]) -> Option<String> {
|
||||
if payload.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Try UTF-8 first
|
||||
if let Ok(text) = std::str::from_utf8(payload) {
|
||||
// Check for JSON format {"text": "message"}
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(text) {
|
||||
if let Some(text_val) = json.get("text").and_then(|v| v.as_str()) {
|
||||
return Some(text_val.to_string());
|
||||
}
|
||||
}
|
||||
return Some(text.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Publish a message to the configured topic.
|
||||
async fn publish_message(&self, text: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let tx_guard = self.publish_tx.read().await;
|
||||
if let Some(tx) = tx_guard.as_ref() {
|
||||
let chunks = split_message(text, MAX_MESSAGE_LEN);
|
||||
for chunk in chunks {
|
||||
tx.send((self.publish_topic.clone(), chunk.to_string()))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send publish request: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err("MQTT client not connected".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelAdapter for MqttAdapter {
|
||||
fn name(&self) -> &str {
|
||||
"mqtt"
|
||||
}
|
||||
|
||||
fn channel_type(&self) -> ChannelType {
|
||||
ChannelType::Mqtt
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>> {
|
||||
let options = self.build_mqtt_options()?;
|
||||
let (client, mut eventloop) = AsyncClient::new(options, 10);
|
||||
|
||||
info!(
|
||||
"MQTT adapter connecting to {} (subscribe: {}, publish: {})",
|
||||
self.broker_url, self.subscribe_topic, self.publish_topic
|
||||
);
|
||||
|
||||
// Subscribe to topic
|
||||
client.subscribe(&self.subscribe_topic, self.qos).await?;
|
||||
|
||||
// Channel for incoming messages
|
||||
let (msg_tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
|
||||
// Channel for outgoing publish requests
|
||||
let (publish_tx, mut publish_rx) = mpsc::channel::<(String, String)>(64);
|
||||
|
||||
// Store the publish sender
|
||||
{
|
||||
let mut tx_guard = self.publish_tx.write().await;
|
||||
*tx_guard = Some(publish_tx);
|
||||
}
|
||||
|
||||
let subscribe_topic = self.subscribe_topic.clone();
|
||||
let qos = self.qos;
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
|
||||
// Spawn the event loop task
|
||||
tokio::spawn(async move {
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
let max_backoff = Duration::from_secs(60);
|
||||
|
||||
loop {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("MQTT adapter shutting down");
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("MQTT adapter shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
publish_req = publish_rx.recv() => {
|
||||
if let Some((topic, payload)) = publish_req {
|
||||
if let Err(e) = client.publish(&topic, qos, false, payload).await {
|
||||
warn!("MQTT publish error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
event = eventloop.poll() => {
|
||||
match event {
|
||||
Ok(Event::Incoming(Incoming::Publish(publish))) => {
|
||||
backoff = Duration::from_secs(1); // Reset backoff on success
|
||||
|
||||
let topic = publish.topic.clone();
|
||||
if topic != subscribe_topic {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(text) = Self::parse_payload(&publish.payload) {
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = if text.starts_with('/') {
|
||||
let parts: Vec<&str> = text.splitn(2, ' ').collect();
|
||||
let cmd = parts[0].trim_start_matches('/');
|
||||
let args: Vec<String> = parts
|
||||
.get(1)
|
||||
.map(|a| {
|
||||
a.split_whitespace()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ChannelContent::Command {
|
||||
name: cmd.to_string(),
|
||||
args,
|
||||
}
|
||||
} else {
|
||||
ChannelContent::Text(text)
|
||||
};
|
||||
|
||||
let msg = ChannelMessage {
|
||||
channel: ChannelType::Mqtt,
|
||||
platform_message_id: format!("{:?}", publish.pkid),
|
||||
sender: ChannelUser {
|
||||
platform_id: "mqtt-user".to_string(),
|
||||
display_name: "MQTT User".to_string(),
|
||||
openfang_user: None,
|
||||
},
|
||||
content,
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: true,
|
||||
thread_id: None,
|
||||
metadata: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(
|
||||
"topic".to_string(),
|
||||
serde_json::Value::String(topic.clone()),
|
||||
);
|
||||
m.insert(
|
||||
"qos".to_string(),
|
||||
serde_json::Value::Number((publish.qos as i64).into()),
|
||||
);
|
||||
m
|
||||
},
|
||||
};
|
||||
|
||||
if msg_tx.send(msg).await.is_err() {
|
||||
info!("MQTT receiver dropped, stopping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Incoming(Incoming::ConnAck(_))) => {
|
||||
info!("MQTT connected to broker");
|
||||
backoff = Duration::from_secs(1);
|
||||
}
|
||||
Ok(Event::Incoming(Incoming::Disconnect)) => {
|
||||
warn!("MQTT disconnected from broker");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("MQTT connection error: {}, backing off for {:?}", e, backoff);
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(max_backoff);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("MQTT event loop stopped");
|
||||
});
|
||||
|
||||
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
async fn send(
|
||||
&self,
|
||||
_user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let text = match content {
|
||||
ChannelContent::Text(t) => t,
|
||||
ChannelContent::Command { name, args } => {
|
||||
if args.is_empty() {
|
||||
format!("/{name}")
|
||||
} else {
|
||||
format!("/{} {}", name, args.join(" "))
|
||||
}
|
||||
}
|
||||
_ => "(Unsupported content type)".to_string(),
|
||||
};
|
||||
self.publish_message(&text).await
|
||||
}
|
||||
|
||||
async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// MQTT has no typing indicator concept.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
|
||||
// Clear the publish channel
|
||||
let mut tx_guard = self.publish_tx.write().await;
|
||||
*tx_guard = None;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mqtt_adapter_creation() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.hivemq.com:1883".to_string(),
|
||||
"test-client".to_string(),
|
||||
"test/topic".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
assert_eq!(adapter.name(), "mqtt");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Mqtt);
|
||||
assert_eq!(adapter.subscribe_topic, "test/topic");
|
||||
assert_eq!(adapter.publish_topic, "test/topic"); // Falls back to subscribe_topic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mqtt_adapter_with_separate_publish_topic() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.hivemq.com:1883".to_string(),
|
||||
String::new(),
|
||||
"inbox".to_string(),
|
||||
"outbox".to_string(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
assert_eq!(adapter.subscribe_topic, "inbox");
|
||||
assert_eq!(adapter.publish_topic, "outbox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_broker_url_tcp() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com:1883".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let (host, port) = adapter.parse_broker_url().unwrap();
|
||||
assert_eq!(host, "broker.example.com");
|
||||
assert_eq!(port, 1883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_broker_url_tcp_default_port() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let (host, port) = adapter.parse_broker_url().unwrap();
|
||||
assert_eq!(host, "broker.example.com");
|
||||
assert_eq!(port, 1883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_broker_url_ssl() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"ssl://broker.example.com:8883".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let (host, port) = adapter.parse_broker_url().unwrap();
|
||||
assert_eq!(host, "broker.example.com");
|
||||
assert_eq!(port, 8883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_broker_url_plain_host() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let (host, port) = adapter.parse_broker_url().unwrap();
|
||||
assert_eq!(host, "broker.example.com");
|
||||
assert_eq!(port, 1883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_payload_text() {
|
||||
let payload = b"Hello, MQTT!";
|
||||
let result = MqttAdapter::parse_payload(payload);
|
||||
assert_eq!(result, Some("Hello, MQTT!".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_payload_json() {
|
||||
let payload = br#"{"text": "Hello from JSON"}"#;
|
||||
let result = MqttAdapter::parse_payload(payload);
|
||||
assert_eq!(result, Some("Hello from JSON".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_payload_empty() {
|
||||
let payload = b"";
|
||||
let result = MqttAdapter::parse_payload(payload);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qos_conversion() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
0,
|
||||
);
|
||||
assert_eq!(adapter.qos, QoS::AtMostOnce);
|
||||
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
assert_eq!(adapter.qos, QoS::AtLeastOnce);
|
||||
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
2,
|
||||
);
|
||||
assert_eq!(adapter.qos, QoS::ExactlyOnce);
|
||||
}
|
||||
}
|
||||
@@ -355,6 +355,7 @@ fn channel_type_to_str(ct: &ChannelType) -> &str {
|
||||
ChannelType::WebChat => "webchat",
|
||||
ChannelType::CLI => "cli",
|
||||
ChannelType::Custom(s) => s.as_str(),
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
//! Uses long-polling via `getUpdates` with exponential backoff on failures.
|
||||
//! No external Telegram crate — just `reqwest` for full control over error handling.
|
||||
|
||||
use crate::bridge::channel_command_specs;
|
||||
use crate::types::{
|
||||
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
|
||||
LifecycleReaction,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
@@ -27,6 +29,12 @@ const LONG_POLL_TIMEOUT: u64 = 30;
|
||||
/// Default Telegram Bot API base URL.
|
||||
const DEFAULT_API_URL: &str = "https://api.telegram.org";
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TelegramBotCommand<'a> {
|
||||
command: &'a str,
|
||||
description: &'a str,
|
||||
}
|
||||
|
||||
/// Telegram Bot API adapter using long-polling.
|
||||
pub struct TelegramAdapter {
|
||||
/// SECURITY: Bot token is zeroized on drop to prevent memory disclosure.
|
||||
@@ -96,6 +104,36 @@ impl TelegramAdapter {
|
||||
Ok(bot_name)
|
||||
}
|
||||
|
||||
async fn register_bot_commands(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"{}/bot{}/setMyCommands",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
let commands: Vec<TelegramBotCommand<'_>> = channel_command_specs()
|
||||
.iter()
|
||||
.map(|spec| TelegramBotCommand {
|
||||
command: spec.name,
|
||||
description: spec.desc,
|
||||
})
|
||||
.collect();
|
||||
let resp: serde_json::Value = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "commands": commands }))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
if resp["ok"].as_bool() != Some(true) {
|
||||
let desc = resp["description"].as_str().unwrap_or("unknown error");
|
||||
return Err(format!("Telegram setMyCommands failed: {desc}").into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendMessage` on the Telegram API.
|
||||
///
|
||||
/// When `thread_id` is provided, includes `message_thread_id` in the request
|
||||
@@ -438,6 +476,14 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
match self.register_bot_commands().await {
|
||||
Ok(()) => info!(
|
||||
"Telegram: registered {} bot commands",
|
||||
channel_command_specs().len()
|
||||
),
|
||||
Err(e) => warn!("Telegram: setMyCommands failed (non-fatal): {e}"),
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
|
||||
let token = self.token.clone();
|
||||
|
||||
@@ -23,6 +23,8 @@ pub enum ChannelType {
|
||||
Mattermost,
|
||||
WebChat,
|
||||
CLI,
|
||||
/// MQTT pub/sub messaging.
|
||||
Mqtt,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
|
||||
@@ -258,7 +258,8 @@ impl ChannelAdapter for WhatsAppAdapter {
|
||||
"https://graph.facebook.com/v21.0/{}/messages",
|
||||
self.phone_number_id
|
||||
);
|
||||
let resp = self.client
|
||||
let resp = self
|
||||
.client
|
||||
.post(&api_url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.json(&body)
|
||||
@@ -284,7 +285,8 @@ impl ChannelAdapter for WhatsAppAdapter {
|
||||
"https://graph.facebook.com/v21.0/{}/messages",
|
||||
self.phone_number_id
|
||||
);
|
||||
let resp = self.client
|
||||
let resp = self
|
||||
.client
|
||||
.post(&api_url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.json(&body)
|
||||
@@ -310,7 +312,8 @@ impl ChannelAdapter for WhatsAppAdapter {
|
||||
"https://graph.facebook.com/v21.0/{}/messages",
|
||||
self.phone_number_id
|
||||
);
|
||||
let resp = self.client
|
||||
let resp = self
|
||||
.client
|
||||
.post(&api_url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.json(&body)
|
||||
|
||||
@@ -229,14 +229,23 @@ async fn test_bridge_dispatch_text_message() {
|
||||
let sent = adapter_ref.get_sent();
|
||||
assert_eq!(sent.len(), 1, "Expected 1 response, got {}", sent.len());
|
||||
assert_eq!(sent[0].0, "user1");
|
||||
assert_eq!(sent[0].1, "Echo: Hello agent!");
|
||||
// The bridge prepends sender identity: [From: Name] or [From: Name <email>]
|
||||
assert!(
|
||||
sent[0].1.contains("Hello agent!"),
|
||||
"Response should contain original text, got: {}",
|
||||
sent[0].1
|
||||
);
|
||||
|
||||
// Verify: handle received the message
|
||||
// Verify: handle received the message (with sender prefix)
|
||||
{
|
||||
let received = handle.received.lock().unwrap();
|
||||
assert_eq!(received.len(), 1);
|
||||
assert_eq!(received[0].0, agent_id);
|
||||
assert_eq!(received[0].1, "Hello agent!");
|
||||
assert!(
|
||||
received[0].1.contains("Hello agent!"),
|
||||
"Handle should receive text containing original message, got: {}",
|
||||
received[0].1
|
||||
);
|
||||
}
|
||||
|
||||
manager.stop().await;
|
||||
@@ -486,7 +495,10 @@ async fn test_bridge_manager_lifecycle() {
|
||||
assert_eq!(sent.len(), 5, "Expected 5 responses, got {}", sent.len());
|
||||
|
||||
for (i, (_, text)) in sent.iter().enumerate() {
|
||||
assert_eq!(*text, format!("Echo: message {i}"));
|
||||
assert!(
|
||||
text.contains(&format!("message {i}")),
|
||||
"Expected 'message {i}' in: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
// Stop — should complete without hanging
|
||||
@@ -535,11 +547,19 @@ async fn test_bridge_multiple_adapters() {
|
||||
|
||||
let tg_sent = tg_ref.get_sent();
|
||||
assert_eq!(tg_sent.len(), 1);
|
||||
assert_eq!(tg_sent[0].1, "Echo: from telegram");
|
||||
assert!(
|
||||
tg_sent[0].1.contains("from telegram"),
|
||||
"Expected 'from telegram' in: {}",
|
||||
tg_sent[0].1
|
||||
);
|
||||
|
||||
let dc_sent = dc_ref.get_sent();
|
||||
assert_eq!(dc_sent.len(), 1);
|
||||
assert_eq!(dc_sent[0].1, "Echo: from discord");
|
||||
assert!(
|
||||
dc_sent[0].1.contains("from discord"),
|
||||
"Expected 'from discord' in: {}",
|
||||
dc_sent[0].1
|
||||
);
|
||||
|
||||
manager.stop().await;
|
||||
}
|
||||
|
||||
@@ -31,3 +31,4 @@ openfang-runtime = { path = "../openfang-runtime" }
|
||||
uuid = { workspace = true }
|
||||
ratatui = { workspace = true }
|
||||
colored = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
+151
-26
@@ -829,6 +829,7 @@ fn init_tracing_stderr() {
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())),
|
||||
)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
}
|
||||
|
||||
@@ -869,6 +870,21 @@ fn init_tracing_file() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `msg` to stdout, silently exiting with code 0 on BrokenPipe.
|
||||
/// Use this instead of `println!` for machine-readable (JSON) output that is
|
||||
/// commonly piped into other tools.
|
||||
fn write_stdout_safe(msg: &str) {
|
||||
let out = std::io::stdout();
|
||||
let mut lock = out.lock();
|
||||
if let Err(e) = writeln!(lock, "{}", msg) {
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
std::process::exit(0);
|
||||
}
|
||||
eprintln!("error: failed writing to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Load ~/.openfang/.env into process environment (system env takes priority).
|
||||
dotenv::load_dotenv();
|
||||
@@ -2115,18 +2131,14 @@ fn cmd_doctor(json: bool, repair: bool) {
|
||||
ui::check_ok(".env file (permissions fixed to 0600)");
|
||||
}
|
||||
repaired = true;
|
||||
} else {
|
||||
if !json {
|
||||
ui::check_warn(&format!(
|
||||
".env file has loose permissions ({:o}), should be 0600",
|
||||
mode
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !json {
|
||||
ui::check_ok(".env file");
|
||||
} else if !json {
|
||||
ui::check_warn(&format!(
|
||||
".env file has loose permissions ({:o}), should be 0600",
|
||||
mode
|
||||
));
|
||||
}
|
||||
} else if !json {
|
||||
ui::check_ok(".env file");
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
@@ -2414,11 +2426,14 @@ decay_rate = 0.05
|
||||
if !json {
|
||||
ui::provider_status(name, env_var, true);
|
||||
}
|
||||
} else if !json {
|
||||
ui::check_warn(&format!("{name} ({env_var}) - key rejected (401/403)"));
|
||||
} else {
|
||||
if !json {
|
||||
ui::check_fail(&format!("{name} ({env_var}) - key rejected (401/403)"));
|
||||
}
|
||||
all_ok = false;
|
||||
}
|
||||
any_key_set = true;
|
||||
checks.push(serde_json::json!({"check": "provider", "name": name, "env_var": env_var, "status": if valid { "ok" } else { "warn" }, "live_test": !valid}));
|
||||
checks.push(serde_json::json!({"check": "provider", "name": name, "env_var": env_var, "status": if valid { "ok" } else { "fail" }, "live_test": !valid}));
|
||||
} else {
|
||||
if !json {
|
||||
ui::provider_status(name, env_var, false);
|
||||
@@ -2580,7 +2595,8 @@ decay_rate = 0.05
|
||||
checks.push(serde_json::json!({"check": "mcp_server_config", "status": "warn", "name": server.name}));
|
||||
}
|
||||
}
|
||||
openfang_types::config::McpTransportEntry::Sse { url } => {
|
||||
openfang_types::config::McpTransportEntry::Sse { url }
|
||||
| openfang_types::config::McpTransportEntry::Http { url } => {
|
||||
if url.is_empty() {
|
||||
if !json {
|
||||
ui::check_warn(&format!(
|
||||
@@ -2626,9 +2642,7 @@ decay_rate = 0.05
|
||||
// Check workspace skills if home dir available
|
||||
if skills_dir.exists() {
|
||||
match skill_reg.load_workspace_skills(&skills_dir) {
|
||||
Ok(_) => {
|
||||
let total = skill_reg.count();
|
||||
let ws_count = total.saturating_sub(bundled_count);
|
||||
Ok(ws_count) => {
|
||||
if ws_count > 0 {
|
||||
if !json {
|
||||
ui::check_ok(&format!("Workspace skills loaded: {ws_count}"));
|
||||
@@ -2670,8 +2684,15 @@ decay_rate = 0.05
|
||||
}
|
||||
}
|
||||
}
|
||||
if injection_warnings > 0 {
|
||||
checks.push(serde_json::json!({"check": "skill_injection_scan", "status": "warn", "warnings": injection_warnings}));
|
||||
let blocked = skill_reg.blocked_count();
|
||||
if injection_warnings > 0 || blocked > 0 {
|
||||
let total_warnings = injection_warnings + blocked;
|
||||
if blocked > 0 && !json {
|
||||
ui::check_warn(&format!(
|
||||
"{blocked} workspace skill(s) were blocked for critical prompt injection"
|
||||
));
|
||||
}
|
||||
checks.push(serde_json::json!({"check": "skill_injection_scan", "status": "warn", "warnings": total_warnings, "blocked": blocked}));
|
||||
} else {
|
||||
if !json {
|
||||
ui::check_ok("All skills pass prompt injection scan");
|
||||
@@ -2909,19 +2930,20 @@ decay_rate = 0.05
|
||||
}
|
||||
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
write_stdout_safe(
|
||||
&serde_json::to_string_pretty(&serde_json::json!({
|
||||
"all_ok": all_ok,
|
||||
"checks": checks,
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
} else {
|
||||
println!();
|
||||
if all_ok {
|
||||
ui::success("All checks passed! OpenFang is ready.");
|
||||
ui::hint("Start the daemon: openfang start");
|
||||
if find_daemon().is_none() {
|
||||
ui::hint("Start the daemon: openfang start");
|
||||
}
|
||||
} else if repaired {
|
||||
ui::success("Repairs applied. Re-run `openfang doctor` to verify.");
|
||||
} else {
|
||||
@@ -3488,6 +3510,7 @@ fn cmd_skill_install(source: &str) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("Installed OpenClaw skill: {}", manifest.skill.name);
|
||||
notify_daemon_skill_reload();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to convert OpenClaw skill: {e}");
|
||||
@@ -3517,6 +3540,86 @@ fn cmd_skill_install(source: &str) {
|
||||
"Installed skill: {} v{}",
|
||||
manifest.skill.name, manifest.skill.version
|
||||
);
|
||||
notify_daemon_skill_reload();
|
||||
} else if source.starts_with("https://")
|
||||
|| source.starts_with("http://")
|
||||
|| source.starts_with("git@")
|
||||
{
|
||||
// Git URL install — clone to temp dir then install from there
|
||||
ui::step(&format!("Cloning skill from {source}..."));
|
||||
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| {
|
||||
eprintln!("Failed to create temp directory: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let clone_path = tmp_dir.path().join("skill");
|
||||
let status = std::process::Command::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
source,
|
||||
clone_path.to_str().unwrap(),
|
||||
])
|
||||
.status();
|
||||
match status {
|
||||
Ok(s) if s.success() => {}
|
||||
Ok(_) => {
|
||||
eprintln!("Failed to clone repository: {source}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to run git: {e}");
|
||||
ui::hint("Make sure git is installed and available on your PATH.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse the local directory install logic on the cloned repo
|
||||
let manifest_path = clone_path.join("skill.toml");
|
||||
if !manifest_path.exists() {
|
||||
if openfang_skills::openclaw_compat::detect_openclaw_skill(&clone_path) {
|
||||
println!("Detected OpenClaw skill format. Converting...");
|
||||
match openfang_skills::openclaw_compat::convert_openclaw_skill(&clone_path) {
|
||||
Ok(manifest) => {
|
||||
let dest = skills_dir.join(&manifest.skill.name);
|
||||
copy_dir_recursive(&clone_path, &dest);
|
||||
if let Err(e) = openfang_skills::openclaw_compat::write_openfang_manifest(
|
||||
&dest, &manifest,
|
||||
) {
|
||||
eprintln!("Failed to write manifest: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("Installed OpenClaw skill: {}", manifest.skill.name);
|
||||
notify_daemon_skill_reload();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to convert OpenClaw skill: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
eprintln!("No skill.toml found in cloned repository: {source}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let toml_str = std::fs::read_to_string(&manifest_path).unwrap_or_else(|e| {
|
||||
eprintln!("Error reading skill.toml: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let manifest: openfang_skills::SkillManifest =
|
||||
toml::from_str(&toml_str).unwrap_or_else(|e| {
|
||||
eprintln!("Error parsing skill.toml: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let dest = skills_dir.join(&manifest.skill.name);
|
||||
copy_dir_recursive(&clone_path, &dest);
|
||||
println!(
|
||||
"Installed skill: {} v{}",
|
||||
manifest.skill.name, manifest.skill.version
|
||||
);
|
||||
notify_daemon_skill_reload();
|
||||
} else {
|
||||
// Remote install from FangHub
|
||||
println!("Installing {source} from FangHub...");
|
||||
@@ -3525,7 +3628,10 @@ fn cmd_skill_install(source: &str) {
|
||||
openfang_skills::marketplace::MarketplaceConfig::default(),
|
||||
);
|
||||
match rt.block_on(client.install(source, &skills_dir)) {
|
||||
Ok(version) => println!("Installed {source} {version}"),
|
||||
Ok(version) => {
|
||||
println!("Installed {source} {version}");
|
||||
notify_daemon_skill_reload();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to install skill: {e}");
|
||||
std::process::exit(1);
|
||||
@@ -3534,6 +3640,25 @@ fn cmd_skill_install(source: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Notify the running daemon to hot-reload its skill registry after a CLI install.
|
||||
///
|
||||
/// If the daemon is not running, this is a no-op with a hint to the user.
|
||||
fn notify_daemon_skill_reload() {
|
||||
if let Some(base) = find_daemon() {
|
||||
let client = daemon_client();
|
||||
match client.post(format!("{base}/api/skills/reload")).send() {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
ui::step("Daemon notified — skill registry reloaded.");
|
||||
}
|
||||
_ => {
|
||||
ui::check_warn("Could not notify daemon. Restart with: openfang restart");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ui::hint("Start the daemon to make this skill available to agents: openfang start");
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_skill_list() {
|
||||
let home = openfang_home();
|
||||
let skills_dir = home.join("skills");
|
||||
|
||||
@@ -153,6 +153,7 @@ impl StandaloneChat {
|
||||
name,
|
||||
result_preview,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
self.chat.tool_result(&name, &result_preview, is_error);
|
||||
}
|
||||
|
||||
@@ -303,7 +303,7 @@ pub fn spawn_inprocess_stream(
|
||||
// send_message_streaming() finds the reactor.
|
||||
let _guard = rt.enter();
|
||||
|
||||
match kernel.send_message_streaming(agent_id, &message, None, None, None) {
|
||||
match kernel.send_message_streaming(agent_id, &message, None, None, None, None) {
|
||||
Ok((mut rx, handle)) => {
|
||||
rt.block_on(async {
|
||||
while let Some(ev) = rx.recv().await {
|
||||
@@ -1417,14 +1417,24 @@ pub fn spawn_fetch_skills(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
|
||||
let client = daemon_client();
|
||||
if let Ok(resp) = client.get(format!("{base_url}/api/skills")).send() {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let skills: Vec<SkillInfo> = body
|
||||
.as_array()
|
||||
// API returns {"skills": [...], "total": N} — extract the inner array.
|
||||
// Fall back to bare array for backward compat.
|
||||
let items = body
|
||||
.get("skills")
|
||||
.and_then(|v| v.as_array())
|
||||
.or_else(|| body.as_array());
|
||||
let skills: Vec<SkillInfo> = items
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|s| SkillInfo {
|
||||
name: s["name"].as_str().unwrap_or("").to_string(),
|
||||
runtime: s["runtime"].as_str().unwrap_or("").to_string(),
|
||||
source: s["source"].as_str().unwrap_or("").to_string(),
|
||||
// "source" is an object {"type": "..."} — extract the type string
|
||||
source: s["source"]["type"]
|
||||
.as_str()
|
||||
.or_else(|| s["source"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
description: s["description"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
|
||||
@@ -1183,6 +1183,7 @@ impl App {
|
||||
name,
|
||||
result_preview,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
self.chat.tool_result(&name, &result_preview, is_error);
|
||||
}
|
||||
|
||||
@@ -188,6 +188,14 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "nvidia",
|
||||
display: "NVIDIA NIM",
|
||||
env_var: "NVIDIA_API_KEY",
|
||||
default_model: "nvidia/llama-3.1-nemotron-70b-instruct",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "claude-code",
|
||||
display: "Claude Code",
|
||||
|
||||
@@ -127,6 +127,12 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
default_model: "codegeex-4",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "nvidia",
|
||||
env_var: "NVIDIA_API_KEY",
|
||||
default_model: "nvidia/llama-3.1-nemotron-70b-instruct",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "claude-code",
|
||||
env_var: "",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenFang",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.5",
|
||||
"identifier": "ai.openfang.desktop",
|
||||
"build": {},
|
||||
"app": {
|
||||
|
||||
@@ -11,7 +11,7 @@ command = "npx"
|
||||
args = ["-y", "@notionhq/notion-mcp-server"]
|
||||
|
||||
[[required_env]]
|
||||
name = "NOTION_API_KEY"
|
||||
name = "NOTION_TOKEN"
|
||||
label = "Notion Integration Token"
|
||||
help = "An internal integration token created in your Notion workspace settings"
|
||||
is_secret = true
|
||||
@@ -24,5 +24,5 @@ unhealthy_threshold = 3
|
||||
setup_instructions = """
|
||||
1. Go to https://www.notion.so/my-integrations and click 'New integration'.
|
||||
2. Give it a name, select your workspace, and grant the required capabilities (Read/Update/Insert content).
|
||||
3. Copy the Internal Integration Token and paste it into the NOTION_API_KEY field above. Then share relevant pages with the integration in Notion.
|
||||
3. Copy the Internal Integration Token and paste it into the NOTION_TOKEN field above. Then share relevant pages with the integration in Notion.
|
||||
"""
|
||||
|
||||
@@ -146,6 +146,9 @@ mod tests {
|
||||
crate::McpTransportTemplate::Sse { .. } => {
|
||||
panic!("{} unexpectedly uses SSE transport", id);
|
||||
}
|
||||
crate::McpTransportTemplate::Http { .. } => {
|
||||
panic!("{} unexpectedly uses HTTP transport", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,13 @@ impl CredentialResolver {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear a credential from the in-memory dotenv cache.
|
||||
/// Call this when a key is deleted via the dashboard so the resolver
|
||||
/// doesn't return a stale value from the boot-time snapshot.
|
||||
pub fn clear_dotenv_cache(&mut self, key: &str) {
|
||||
self.dotenv.remove(key);
|
||||
}
|
||||
|
||||
/// Remove a credential from the vault (if available).
|
||||
pub fn remove_from_vault(&mut self, key: &str) -> ExtensionResult<bool> {
|
||||
if let Some(ref mut vault) = self.vault {
|
||||
|
||||
@@ -327,7 +327,7 @@ mod tests {
|
||||
|
||||
// Provide key directly
|
||||
let mut keys = HashMap::new();
|
||||
keys.insert("NOTION_API_KEY".to_string(), "ntn_test_key_123".to_string());
|
||||
keys.insert("NOTION_TOKEN".to_string(), "ntn_test_key_123".to_string());
|
||||
|
||||
let result = install_integration(&mut registry, &mut resolver, "notion", &keys).unwrap();
|
||||
assert_eq!(result.id, "notion");
|
||||
|
||||
@@ -88,6 +88,9 @@ pub enum McpTransportTemplate {
|
||||
Sse {
|
||||
url: String,
|
||||
},
|
||||
Http {
|
||||
url: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// An environment variable required by an integration.
|
||||
|
||||
@@ -191,6 +191,9 @@ impl IntegrationRegistry {
|
||||
crate::McpTransportTemplate::Sse { url } => {
|
||||
McpTransportEntry::Sse { url: url.clone() }
|
||||
}
|
||||
crate::McpTransportTemplate::Http { url } => {
|
||||
McpTransportEntry::Http { url: url.clone() }
|
||||
}
|
||||
};
|
||||
let env: Vec<String> = template
|
||||
.required_env
|
||||
@@ -202,6 +205,7 @@ impl IntegrationRegistry {
|
||||
transport,
|
||||
timeout_secs: 30,
|
||||
env,
|
||||
headers: Vec::new(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -15,10 +15,10 @@ tools = [
|
||||
|
||||
[[requires]]
|
||||
key = "python3"
|
||||
label = "Python 3 must be installed"
|
||||
label = "Python 3 must be installed (python3 or python)"
|
||||
requirement_type = "binary"
|
||||
check_value = "python3"
|
||||
description = "Python 3 is required for installing and running the Playwright browser automation library. Python 3.8 or newer is recommended."
|
||||
description = "Python 3 is required for installing and running the Playwright browser automation library. Python 3.8 or newer is recommended. Either 'python3' or 'python' (pointing to Python 3) will be detected."
|
||||
|
||||
[requires.install]
|
||||
macos = "brew install python3"
|
||||
@@ -26,7 +26,6 @@ windows = "winget install Python.Python.3.12"
|
||||
linux_apt = "sudo apt install python3"
|
||||
linux_dnf = "sudo dnf install python3"
|
||||
linux_pacman = "sudo pacman -S python"
|
||||
pip = "python3 --version"
|
||||
manual_url = "https://www.python.org/downloads/"
|
||||
estimated_time = "1-3 min"
|
||||
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
id = "infisical-sync"
|
||||
name = "Infisical Sync Hand"
|
||||
description = "Autonomous secrets synchronisation between a self-hosted Infisical instance and the agent's local credential vault. Keeps agents in sync with a shared Infisical instance as a single source of truth, and lets agents push new secrets back to Infisical."
|
||||
category = "security"
|
||||
icon = "🔐"
|
||||
tools = [
|
||||
# Core Einstein tools (schedule + memory + knowledge graph + event bus)
|
||||
"schedule_create", "schedule_list", "schedule_delete",
|
||||
"memory_store", "memory_recall",
|
||||
"knowledge_add_entity", "knowledge_add_relation", "knowledge_query",
|
||||
"event_publish",
|
||||
# Infisical-specific tools
|
||||
"shell_exec",
|
||||
"file_read", "file_write",
|
||||
"vault_set", "vault_get", "vault_list", "vault_delete",
|
||||
]
|
||||
|
||||
# ─── Requirements ─────────────────────────────────────────────────────────────
|
||||
|
||||
[[requires]]
|
||||
key = "INFISICAL_URL"
|
||||
label = "Infisical Instance URL"
|
||||
requirement_type = "env_var"
|
||||
check_value = "INFISICAL_URL"
|
||||
description = "Base URL of the self-hosted Infisical instance, e.g. https://infisical.example.com"
|
||||
|
||||
[[requires]]
|
||||
key = "INFISICAL_CLIENT_ID"
|
||||
label = "Infisical Machine Identity Client ID"
|
||||
requirement_type = "env_var"
|
||||
check_value = "INFISICAL_CLIENT_ID"
|
||||
description = "Machine identity Client ID for this agent. Created in Infisical under Access Control → Machine Identities."
|
||||
|
||||
[[requires]]
|
||||
key = "INFISICAL_CLIENT_SECRET"
|
||||
label = "Infisical Machine Identity Client Secret"
|
||||
requirement_type = "env_var"
|
||||
check_value = "INFISICAL_CLIENT_SECRET"
|
||||
description = "Machine identity Client Secret for this agent."
|
||||
|
||||
# ─── Settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[[settings]]
|
||||
key = "sync_interval_minutes"
|
||||
label = "Sync Interval (minutes)"
|
||||
description = "How often to pull secrets from Infisical into the local vault. Overridden by the INFISICAL_SYNC_INTERVAL env var when present."
|
||||
setting_type = "select"
|
||||
default = "15"
|
||||
|
||||
[[settings.options]]
|
||||
value = "5"
|
||||
label = "Every 5 minutes (high-frequency)"
|
||||
|
||||
[[settings.options]]
|
||||
value = "15"
|
||||
label = "Every 15 minutes (default)"
|
||||
|
||||
[[settings.options]]
|
||||
value = "30"
|
||||
label = "Every 30 minutes"
|
||||
|
||||
[[settings.options]]
|
||||
value = "60"
|
||||
label = "Every hour"
|
||||
|
||||
[[settings]]
|
||||
key = "environment"
|
||||
label = "Infisical Environment"
|
||||
description = "The environment slug to sync from (e.g. prod, staging, dev). Can also be set via INFISICAL_ENVIRONMENT env var."
|
||||
setting_type = "select"
|
||||
default = "prod"
|
||||
|
||||
[[settings.options]]
|
||||
value = "prod"
|
||||
label = "Production"
|
||||
|
||||
[[settings.options]]
|
||||
value = "staging"
|
||||
label = "Staging"
|
||||
|
||||
[[settings.options]]
|
||||
value = "dev"
|
||||
label = "Development"
|
||||
|
||||
[[settings]]
|
||||
key = "push_on_vault_write"
|
||||
label = "Push on Vault Write"
|
||||
description = "When the agent writes a new secret to the local vault, automatically push it to Infisical as well."
|
||||
setting_type = "toggle"
|
||||
default = "true"
|
||||
|
||||
[[settings]]
|
||||
key = "delete_orphans"
|
||||
label = "Delete Orphaned Local Secrets"
|
||||
description = "Remove local vault entries that no longer exist in Infisical after a sync."
|
||||
setting_type = "toggle"
|
||||
default = "false"
|
||||
|
||||
# ─── Agent configuration ──────────────────────────────────────────────────────
|
||||
|
||||
[agent]
|
||||
name = "infisical-sync-hand"
|
||||
description = "Autonomous secrets sync agent — authenticates with Infisical, pulls secrets into the local vault, and pushes local secrets back to Infisical on demand."
|
||||
module = "builtin:chat"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 8192
|
||||
temperature = 0.1
|
||||
max_iterations = 40
|
||||
system_prompt = """You are Infisical Sync Hand — an autonomous secrets synchronisation agent.
|
||||
|
||||
Your single purpose: keep the local credential vault in sync with a self-hosted Infisical instance and make Infisical the shared source of truth for every secret in your agent fleet.
|
||||
|
||||
You are security-critical. Never log secret values. Never expose credentials in error messages. Always authenticate before any Infisical API call.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 0 — Startup & State Recovery
|
||||
|
||||
Run this every time you are activated (scheduled or on-demand).
|
||||
|
||||
1. **Read configuration** from User Configuration:
|
||||
- `sync_interval_minutes` — how often to schedule syncs
|
||||
- `environment` — Infisical environment slug
|
||||
- `push_on_vault_write` — whether to push on local write
|
||||
- `delete_orphans` — whether to delete orphaned local entries
|
||||
|
||||
2. **Read environment variables** (env vars override settings):
|
||||
```
|
||||
INFISICAL_URL — base URL (required)
|
||||
INFISICAL_CLIENT_ID — machine identity client ID (required)
|
||||
INFISICAL_CLIENT_SECRET — machine identity client secret (required)
|
||||
INFISICAL_PROJECT_ID — project ID (optional; if absent, list all)
|
||||
INFISICAL_ENVIRONMENT — environment slug (default: "prod", overrides setting)
|
||||
INFISICAL_SYNC_INTERVAL — sync interval in minutes (overrides setting)
|
||||
```
|
||||
Read them with shell_exec:
|
||||
```bash
|
||||
echo "URL=$INFISICAL_URL ENV=$INFISICAL_ENVIRONMENT PROJECT=$INFISICAL_PROJECT_ID"
|
||||
```
|
||||
|
||||
3. **Recover state** from memory:
|
||||
```
|
||||
memory_recall "infisical_sync_state"
|
||||
memory_recall "infisical_sync_last_token_expiry"
|
||||
```
|
||||
|
||||
4. **Check for existing schedule**:
|
||||
```
|
||||
schedule_list
|
||||
```
|
||||
If no `infisical-sync` schedule exists, create one (see Phase 1).
|
||||
|
||||
5. **Read sync state file** if present:
|
||||
```
|
||||
file_read "infisical_sync_state.json"
|
||||
```
|
||||
This file holds the last known secret hashes so we can skip unchanged values.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 1 — Schedule Bootstrap (first run only)
|
||||
|
||||
If no schedule for this hand exists:
|
||||
|
||||
1. Determine interval: check `INFISICAL_SYNC_INTERVAL` env var; fall back to `sync_interval_minutes` setting.
|
||||
|
||||
2. Create schedule:
|
||||
```
|
||||
schedule_create
|
||||
name: "infisical-sync"
|
||||
interval_minutes: <interval>
|
||||
description: "Pull secrets from Infisical into local vault"
|
||||
```
|
||||
|
||||
3. Log to memory:
|
||||
```
|
||||
memory_store "infisical_sync_schedule_created" "<ISO timestamp>"
|
||||
```
|
||||
|
||||
4. Add Infisical instance to knowledge graph:
|
||||
```
|
||||
knowledge_add_entity
|
||||
type: "service"
|
||||
name: "Infisical"
|
||||
properties: { url: "<INFISICAL_URL>", environment: "<env>", project_id: "<project_id>" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2 — Authentication
|
||||
|
||||
Obtain a short-lived access token using Universal Auth.
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$INFISICAL_URL/api/v1/auth/universal-auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"clientId\":\"$INFISICAL_CLIENT_ID\",\"clientSecret\":\"$INFISICAL_CLIENT_SECRET\"}"
|
||||
```
|
||||
|
||||
Parse the response and extract `accessToken`. If the call fails:
|
||||
- Log to memory: `memory_store "infisical_sync_last_error" "auth_failed: <timestamp>"`
|
||||
- Increment error counter in state
|
||||
- `event_publish` an alert: "Infisical Sync: authentication failed — check INFISICAL_CLIENT_ID and INFISICAL_CLIENT_SECRET"
|
||||
- STOP. Do not proceed. Do not crash.
|
||||
|
||||
Store the token in a local variable for use in subsequent API calls. **Never store the raw token in memory or the vault** — it is ephemeral.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3 — Resolve Project
|
||||
|
||||
If `INFISICAL_PROJECT_ID` is set, use it directly.
|
||||
|
||||
If not set, list accessible projects:
|
||||
```bash
|
||||
curl -s -X GET "$INFISICAL_URL/api/v1/workspace" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
If multiple projects are returned, sync all of them. Store each project ID in the knowledge graph as a `secret_project` entity.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 4 — Pull Secrets from Infisical
|
||||
|
||||
For each project ID, fetch all secrets:
|
||||
```bash
|
||||
curl -s -X GET \
|
||||
"$INFISICAL_URL/api/v4/secrets?projectId=<PROJECT_ID>&environment=<ENV>&secretPath=/" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
The response contains an array of secrets. Each secret has:
|
||||
- `secretKey` — the name
|
||||
- `secretValue` — the value
|
||||
- `id` — internal Infisical ID
|
||||
- `version` — version number
|
||||
|
||||
**For each secret returned:**
|
||||
|
||||
1. Compute a hash of `secretKey + secretValue` to detect changes.
|
||||
2. Compare with stored hash in `infisical_sync_state.json`.
|
||||
3. If unchanged, skip.
|
||||
4. If new or changed, write to local vault:
|
||||
```
|
||||
vault_set key=<secretKey> value=<secretValue>
|
||||
```
|
||||
5. Record entity in knowledge graph:
|
||||
```
|
||||
knowledge_add_entity
|
||||
type: "secret"
|
||||
name: <secretKey>
|
||||
properties: { project_id: <id>, environment: <env>, version: <version>, last_synced: <ISO timestamp> }
|
||||
```
|
||||
|
||||
**Orphan handling** (if `delete_orphans` setting is "true"):
|
||||
1. Collect the set of all secret keys returned by Infisical.
|
||||
2. Call `vault_list` to get all local keys.
|
||||
3. For any local key NOT in the Infisical set, call `vault_delete`.
|
||||
4. Log each deletion to memory.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 5 — Push Secrets to Infisical (on-demand)
|
||||
|
||||
When the user (or another agent) asks you to share a secret with the fleet:
|
||||
|
||||
1. Read the secret from the local vault:
|
||||
```
|
||||
vault_get key=<secretName>
|
||||
```
|
||||
|
||||
2. Push to Infisical using a create-then-update pattern:
|
||||
|
||||
**Step 1 — Try to create (POST):**
|
||||
```bash
|
||||
HTTP_STATUS=$(curl -s -o /tmp/infisical_push_response.json -w "%{http_code}" \
|
||||
-X POST "$INFISICAL_URL/api/v4/secrets/<SECRET_NAME>" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"projectId\":\"<PROJECT_ID>\",\"environment\":\"<ENV>\",\"secretValue\":\"<VALUE>\",\"secretPath\":\"/\"}")
|
||||
```
|
||||
|
||||
**Step 2 — If 409 (secret already exists), update via PATCH:**
|
||||
```bash
|
||||
if [ "$HTTP_STATUS" = "409" ]; then
|
||||
HTTP_STATUS=$(curl -s -o /tmp/infisical_push_response.json -w "%{http_code}" \
|
||||
-X PATCH "$INFISICAL_URL/api/v4/secrets/<SECRET_NAME>" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"projectId\":\"<PROJECT_ID>\",\"environment\":\"<ENV>\",\"secretValue\":\"<VALUE>\",\"secretPath\":\"/\"}")
|
||||
fi
|
||||
```
|
||||
|
||||
POST returns 201 on success. PATCH returns 200 on success. Any other status is an error — log it and notify via `event_publish`.
|
||||
|
||||
3. On success, log to knowledge graph and memory. On failure, log the error and notify via `event_publish`.
|
||||
|
||||
**Never log secret values.** Use placeholders like `<redacted>` in all log messages and memory entries.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 6 — Delete a Secret from Infisical (on-demand)
|
||||
|
||||
When asked to remove a secret from the shared fleet store:
|
||||
|
||||
1. Confirm with the user before deleting (event_publish a confirmation request).
|
||||
2. On confirmation, call:
|
||||
```bash
|
||||
curl -s -X DELETE \
|
||||
"$INFISICAL_URL/api/v4/secrets/<SECRET_NAME>?projectId=<PROJECT_ID>&environment=<ENV>&secretPath=/" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN"
|
||||
```
|
||||
3. Delete the local vault entry: `vault_delete key=<secretName>`
|
||||
4. Update knowledge graph: remove the `secret` entity.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 7 — State Persistence & Metrics
|
||||
|
||||
After every sync cycle:
|
||||
|
||||
1. **Update sync state file**:
|
||||
```
|
||||
file_write "infisical_sync_state.json" <JSON with secret key→hash map and sync timestamp>
|
||||
```
|
||||
|
||||
2. **Update dashboard metrics via memory_store**:
|
||||
- `infisical_sync_secrets_count` — integer: number of secrets currently in vault
|
||||
- `infisical_sync_last_sync` — string: "YYYY-MM-DD HH:MM UTC"
|
||||
- `infisical_sync_last_error` — string: last error message (or "none")
|
||||
- `infisical_sync_projects_count` — integer: number of Infisical projects synced
|
||||
- `infisical_sync_push_count` — integer: cumulative secrets pushed to Infisical
|
||||
- `infisical_sync_pull_count` — integer: cumulative secrets pulled from Infisical
|
||||
|
||||
3. **Persist state summary**:
|
||||
```
|
||||
memory_store "infisical_sync_state" <JSON summary>
|
||||
```
|
||||
|
||||
4. **Publish sync complete event**:
|
||||
```
|
||||
event_publish "infisical_sync_complete" { secrets_synced: N, project_ids: [...], timestamp: "..." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Never crash.** Always catch errors gracefully:
|
||||
|
||||
- Authentication failure → log + notify + stop cycle
|
||||
- API rate limit (HTTP 429) → wait 60 seconds, retry once, then log + stop
|
||||
- Network timeout → log + retry with 10-second delay once, then log + stop
|
||||
- Partial sync failure → log which keys failed, continue with remaining keys
|
||||
- vault_set failure → log + notify, do not mark as synced
|
||||
|
||||
All errors go to memory:
|
||||
```
|
||||
memory_store "infisical_sync_last_error" "<type>: <message> at <ISO timestamp>"
|
||||
```
|
||||
|
||||
And if severity is high (auth failure, total API unreachable):
|
||||
```
|
||||
event_publish "infisical_sync_error" { error: "<type>", message: "<message>", timestamp: "..." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Rules
|
||||
|
||||
1. **Never** log, print, or store secret values — use `<redacted>` in all messages.
|
||||
2. **Never** expose secret values in `event_publish` payloads.
|
||||
3. **Never** store the Infisical access token in memory or the vault — it is session-local only.
|
||||
4. Only sync secrets to/from the environment and project configured for this agent.
|
||||
5. When unsure whether to push a secret, ask the user first via `event_publish`.
|
||||
"""
|
||||
|
||||
# ─── Dashboard metrics ────────────────────────────────────────────────────────
|
||||
|
||||
[dashboard]
|
||||
|
||||
[[dashboard.metrics]]
|
||||
label = "Secrets in Vault"
|
||||
memory_key = "infisical_sync_secrets_count"
|
||||
format = "number"
|
||||
|
||||
[[dashboard.metrics]]
|
||||
label = "Last Sync"
|
||||
memory_key = "infisical_sync_last_sync"
|
||||
format = "text"
|
||||
|
||||
[[dashboard.metrics]]
|
||||
label = "Last Error"
|
||||
memory_key = "infisical_sync_last_error"
|
||||
format = "text"
|
||||
|
||||
[[dashboard.metrics]]
|
||||
label = "Projects Synced"
|
||||
memory_key = "infisical_sync_projects_count"
|
||||
format = "number"
|
||||
|
||||
[[dashboard.metrics]]
|
||||
label = "Secrets Pushed"
|
||||
memory_key = "infisical_sync_push_count"
|
||||
format = "number"
|
||||
|
||||
[[dashboard.metrics]]
|
||||
label = "Secrets Pulled"
|
||||
memory_key = "infisical_sync_pull_count"
|
||||
format = "number"
|
||||
@@ -0,0 +1,317 @@
|
||||
---
|
||||
name: infisical-sync-skill
|
||||
version: "1.0.0"
|
||||
description: "Expert knowledge for the Infisical Sync Hand — Infisical API reference, vault operations, error patterns, security guidance"
|
||||
author: OpenFang
|
||||
tags: [secrets, infisical, vault, security, sync]
|
||||
tools: [shell_exec, vault_set, vault_get, vault_list, vault_delete, memory_store, memory_recall]
|
||||
runtime: prompt_only
|
||||
---
|
||||
|
||||
# Infisical Sync Expert Knowledge
|
||||
|
||||
## 1. Infisical API Reference
|
||||
|
||||
### Base URL
|
||||
All requests go to `$INFISICAL_URL`. This is the self-hosted instance base URL, e.g. `https://infisical.example.com`.
|
||||
|
||||
### Authentication — Universal Auth
|
||||
Infisical uses Machine Identities with Universal Auth for agent-to-agent communication.
|
||||
|
||||
**Endpoint**: `POST /api/v1/auth/universal-auth/login`
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"clientId": "<INFISICAL_CLIENT_ID>",
|
||||
"clientSecret": "<INFISICAL_CLIENT_SECRET>"
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (success):
|
||||
```json
|
||||
{
|
||||
"accessToken": "eyJ...",
|
||||
"expiresIn": 7200,
|
||||
"accessTokenMaxTTL": 43200,
|
||||
"tokenType": "Bearer"
|
||||
}
|
||||
```
|
||||
|
||||
**curl example**:
|
||||
```bash
|
||||
RESPONSE=$(curl -s -X POST "$INFISICAL_URL/api/v1/auth/universal-auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"clientId\":\"$INFISICAL_CLIENT_ID\",\"clientSecret\":\"$INFISICAL_CLIENT_SECRET\"}")
|
||||
|
||||
ACCESS_TOKEN=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessToken'])")
|
||||
```
|
||||
|
||||
Token lifetime: `expiresIn` seconds (usually 7200 = 2 hours). Re-authenticate when expired.
|
||||
|
||||
---
|
||||
|
||||
### List Secrets
|
||||
**Endpoint**: `GET /api/v4/secrets`
|
||||
|
||||
**Query parameters**:
|
||||
| Param | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `projectId` | Yes | Infisical project ID |
|
||||
| `environment` | Yes | Environment slug (e.g. `prod`, `staging`, `dev`) |
|
||||
| `secretPath` | No | Path prefix, default `/` |
|
||||
| `includeImports` | No | Include imported secrets, default `false` |
|
||||
| `recursive` | No | Include secrets in sub-paths, default `false` |
|
||||
|
||||
**curl example**:
|
||||
```bash
|
||||
curl -s -X GET \
|
||||
"$INFISICAL_URL/api/v4/secrets?projectId=$PROJECT_ID&environment=$ENVIRONMENT&secretPath=/" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
**Response shape**:
|
||||
```json
|
||||
{
|
||||
"secrets": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"version": 1,
|
||||
"secretKey": "DATABASE_URL",
|
||||
"secretValue": "postgres://...",
|
||||
"secretComment": "",
|
||||
"environment": "prod",
|
||||
"workspace": "uuid"
|
||||
}
|
||||
],
|
||||
"imports": []
|
||||
}
|
||||
```
|
||||
|
||||
Parse with:
|
||||
```bash
|
||||
echo "$RESPONSE" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
for s in data.get('secrets', []):
|
||||
print(s['secretKey'])
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create or Update a Secret
|
||||
|
||||
The API does **not** provide a single upsert endpoint. `POST` creates only (returns 409 if the secret already exists); `PATCH` updates only (returns 404 if missing). Use the create-then-update pattern:
|
||||
|
||||
**Step 1 — Try to create (POST)**
|
||||
**Endpoint**: `POST /api/v4/secrets/{secretName}`
|
||||
|
||||
**Request body**:
|
||||
```json
|
||||
{
|
||||
"projectId": "<PROJECT_ID>",
|
||||
"environment": "<ENV>",
|
||||
"secretValue": "<VALUE>",
|
||||
"secretPath": "/"
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
HTTP_STATUS=$(curl -s -o /tmp/infisical_response.json -w "%{http_code}" \
|
||||
-X POST "$INFISICAL_URL/api/v4/secrets/$SECRET_NAME" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"projectId\":\"$PROJECT_ID\",\"environment\":\"$ENVIRONMENT\",\"secretValue\":\"$SECRET_VALUE\",\"secretPath\":\"/\"}")
|
||||
```
|
||||
|
||||
Returns `201` on success.
|
||||
|
||||
**Step 2 — If 409, update via PATCH**
|
||||
**Endpoint**: `PATCH /api/v4/secrets/{secretName}`
|
||||
|
||||
```bash
|
||||
if [ "$HTTP_STATUS" = "409" ]; then
|
||||
HTTP_STATUS=$(curl -s -o /tmp/infisical_response.json -w "%{http_code}" \
|
||||
-X PATCH "$INFISICAL_URL/api/v4/secrets/$SECRET_NAME" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"projectId\":\"$PROJECT_ID\",\"environment\":\"$ENVIRONMENT\",\"secretValue\":\"$SECRET_VALUE\",\"secretPath\":\"/\"}")
|
||||
fi
|
||||
```
|
||||
|
||||
Returns `200` on success. Any other status code is an error.
|
||||
|
||||
**Important**: URL-encode the secret name if it contains special characters.
|
||||
|
||||
---
|
||||
|
||||
### Delete a Secret
|
||||
**Endpoint**: `DELETE /api/v4/secrets/{secretName}`
|
||||
|
||||
**Query parameters**: `projectId`, `environment`, `secretPath` (default `/`)
|
||||
|
||||
**curl example**:
|
||||
```bash
|
||||
curl -s -X DELETE \
|
||||
"$INFISICAL_URL/api/v4/secrets/$SECRET_NAME?projectId=$PROJECT_ID&environment=$ENVIRONMENT&secretPath=/" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### List Accessible Projects (Workspaces)
|
||||
**Endpoint**: `GET /api/v1/workspace`
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$INFISICAL_URL/api/v1/workspace" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
Response: `{ "workspaces": [{ "id": "uuid", "name": "...", "environments": [...] }] }`
|
||||
|
||||
---
|
||||
|
||||
## 2. HTTP Error Codes
|
||||
|
||||
| Code | Meaning | Action |
|
||||
|------|---------|--------|
|
||||
| 200/201 | Success | Continue |
|
||||
| 400 | Bad Request | Log the response body — likely malformed JSON or missing field |
|
||||
| 401 | Unauthorized | Re-authenticate; token may have expired |
|
||||
| 403 | Forbidden | Machine identity lacks permissions — check Infisical Access Control |
|
||||
| 404 | Not Found | Secret or project doesn't exist |
|
||||
| 429 | Rate Limited | Wait 60 seconds, retry once |
|
||||
| 500/503 | Server Error | Log + retry once after 30 seconds; notify if still failing |
|
||||
|
||||
Always check HTTP status before trusting response body:
|
||||
```bash
|
||||
HTTP_STATUS=$(curl -s -o /tmp/infisical_response.json -w "%{http_code}" ...)
|
||||
if [ "$HTTP_STATUS" != "200" ] && [ "$HTTP_STATUS" != "201" ]; then
|
||||
# handle error
|
||||
fi
|
||||
RESPONSE=$(cat /tmp/infisical_response.json)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Sync State File Format
|
||||
|
||||
Stored at `infisical_sync_state.json`:
|
||||
```json
|
||||
{
|
||||
"last_sync": "2025-01-15T10:30:00Z",
|
||||
"project_ids": ["uuid1", "uuid2"],
|
||||
"environment": "prod",
|
||||
"secrets": {
|
||||
"DATABASE_URL": {
|
||||
"hash": "sha256_of_key_plus_value",
|
||||
"version": 3,
|
||||
"last_synced": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
},
|
||||
"error_count": 0,
|
||||
"push_count": 12,
|
||||
"pull_count": 47
|
||||
}
|
||||
```
|
||||
|
||||
**Hash computation** (to detect changes without storing values):
|
||||
```bash
|
||||
echo -n "DATABASE_URL:postgres://..." | sha256sum | awk '{print $1}'
|
||||
```
|
||||
|
||||
Or with Python:
|
||||
```python
|
||||
import hashlib
|
||||
h = hashlib.sha256(f"{key}:{value}".encode()).hexdigest()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Vault Operations Reference
|
||||
|
||||
The local vault provides encrypted key-value storage. All secrets synced from Infisical go here.
|
||||
|
||||
| Operation | Description |
|
||||
|-----------|-------------|
|
||||
| `vault_set key=K value=V` | Write or overwrite secret K |
|
||||
| `vault_get key=K` | Read secret K |
|
||||
| `vault_list` | List all keys (values not returned) |
|
||||
| `vault_delete key=K` | Delete secret K |
|
||||
|
||||
**Bulk sync pattern**:
|
||||
```
|
||||
// Pull from Infisical → vault
|
||||
for each (key, value) in infisical_secrets:
|
||||
vault_set key=<key> value=<value>
|
||||
|
||||
// Optionally remove orphans
|
||||
vault_list → local_keys
|
||||
infisical_keys = set of keys returned by Infisical
|
||||
for key in local_keys - infisical_keys:
|
||||
vault_delete key=<key>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Security Checklist
|
||||
|
||||
Before every sync cycle, verify:
|
||||
- [ ] `INFISICAL_URL` is set and non-empty
|
||||
- [ ] `INFISICAL_CLIENT_ID` is set and non-empty
|
||||
- [ ] `INFISICAL_CLIENT_SECRET` is set and non-empty
|
||||
- [ ] The access token was freshly obtained this cycle (never reuse across cycles)
|
||||
- [ ] No secret values appear in curl command echo output (use variables, not inline values)
|
||||
- [ ] Response body is never logged verbatim (strip `secretValue` fields before logging)
|
||||
|
||||
---
|
||||
|
||||
## 6. Common Failure Modes
|
||||
|
||||
### "Failed to fetch secrets: 403 Forbidden"
|
||||
The Machine Identity exists but lacks permissions. In Infisical:
|
||||
1. Go to Access Control → Machine Identities
|
||||
2. Find this agent's identity
|
||||
3. Assign it the `member` role (or `viewer` for read-only) on the project
|
||||
|
||||
### "Connection refused / Could not connect to server"
|
||||
`INFISICAL_URL` is wrong or the instance is down. Verify the URL is reachable:
|
||||
```bash
|
||||
curl -s "$INFISICAL_URL/api/status" | python3 -c "import sys,json; print(json.load(sys.stdin))"
|
||||
```
|
||||
|
||||
### "invalid character in secret name"
|
||||
Secret names in Infisical must match `[A-Z0-9_]`. If the vault has mixed-case keys, normalise before pushing:
|
||||
```bash
|
||||
echo "my_secret_key" | tr '[:lower:]' '[:upper:]'
|
||||
```
|
||||
|
||||
### "accessToken undefined in response"
|
||||
Authentication failed. The response body will contain an error message. Check:
|
||||
1. `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET` are correct
|
||||
2. The Machine Identity is not disabled in Infisical
|
||||
3. The Machine Identity's token TTL hasn't been set to 0
|
||||
|
||||
---
|
||||
|
||||
## 7. Knowledge Graph Entities
|
||||
|
||||
Track fleet-wide secrets metadata without exposing values.
|
||||
|
||||
### Entity types
|
||||
- `service` — the Infisical instance itself
|
||||
- `secret_project` — an Infisical workspace/project
|
||||
- `secret` — a named secret (key only, never value)
|
||||
|
||||
### Relation types
|
||||
- `secret` → `belongs_to` → `secret_project`
|
||||
- `secret_project` → `hosted_by` → `service`
|
||||
- `secret` → `synced_to` → `agent_vault`
|
||||
|
||||
### Query examples
|
||||
```
|
||||
knowledge_query type=secret // list all known secrets
|
||||
knowledge_query type=secret_project // list all projects
|
||||
knowledge_query relation=belongs_to target=<project_id> // secrets in a project
|
||||
```
|
||||
@@ -161,7 +161,10 @@ provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 16384
|
||||
temperature = 0.3
|
||||
max_iterations = 80
|
||||
max_iterations = 25
|
||||
# Researcher makes long LLM calls; 30s (kernel default) causes false-positive
|
||||
# recovery triggers. 120s matches typical deep-research call latency.
|
||||
heartbeat_interval_secs = 120
|
||||
system_prompt = """You are Researcher Hand — an autonomous deep research agent that conducts exhaustive investigations, cross-references sources, fact-checks claims, and produces comprehensive structured reports.
|
||||
|
||||
## Phase 0 — Platform Detection & Context (ALWAYS DO THIS FIRST)
|
||||
|
||||
@@ -45,6 +45,11 @@ pub fn bundled_hands() -> Vec<(&'static str, &'static str, &'static str)> {
|
||||
include_str!("../bundled/trader/HAND.toml"),
|
||||
include_str!("../bundled/trader/SKILL.md"),
|
||||
),
|
||||
(
|
||||
"infisical-sync",
|
||||
include_str!("../bundled/infisical-sync/HAND.toml"),
|
||||
include_str!("../bundled/infisical-sync/SKILL.md"),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -76,7 +81,7 @@ mod tests {
|
||||
#[test]
|
||||
fn bundled_hands_count() {
|
||||
let hands = bundled_hands();
|
||||
assert_eq!(hands.len(), 8);
|
||||
assert_eq!(hands.len(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -161,7 +166,7 @@ mod tests {
|
||||
assert!(def.tools.contains(&"event_publish".to_string()));
|
||||
assert!(!def.settings.is_empty());
|
||||
assert!(!def.dashboard.metrics.is_empty());
|
||||
assert_eq!(def.agent.max_iterations, Some(80));
|
||||
assert_eq!(def.agent.max_iterations, Some(25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -298,6 +303,126 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_infisical_sync_hand() {
|
||||
let (id, toml_content, skill_content) = bundled_hands()
|
||||
.into_iter()
|
||||
.find(|(id, _, _)| *id == "infisical-sync")
|
||||
.expect("infisical-sync hand must be in bundled_hands()");
|
||||
let def = parse_bundled(id, toml_content, skill_content).unwrap();
|
||||
assert_eq!(def.id, "infisical-sync");
|
||||
assert_eq!(def.name, "Infisical Sync Hand");
|
||||
assert_eq!(def.category, crate::HandCategory::Security);
|
||||
assert!(def.skill_content.is_some());
|
||||
// Required env vars
|
||||
assert!(
|
||||
!def.requires.is_empty(),
|
||||
"infisical-sync must declare env var requirements"
|
||||
);
|
||||
let req_keys: Vec<&str> = def.requires.iter().map(|r| r.key.as_str()).collect();
|
||||
assert!(
|
||||
req_keys.contains(&"INFISICAL_URL"),
|
||||
"must require INFISICAL_URL"
|
||||
);
|
||||
assert!(
|
||||
req_keys.contains(&"INFISICAL_CLIENT_ID"),
|
||||
"must require INFISICAL_CLIENT_ID"
|
||||
);
|
||||
assert!(
|
||||
req_keys.contains(&"INFISICAL_CLIENT_SECRET"),
|
||||
"must require INFISICAL_CLIENT_SECRET"
|
||||
);
|
||||
// Einstein scheduling tools
|
||||
assert!(
|
||||
def.tools.contains(&"schedule_create".to_string()),
|
||||
"must have schedule_create"
|
||||
);
|
||||
assert!(
|
||||
def.tools.contains(&"schedule_list".to_string()),
|
||||
"must have schedule_list"
|
||||
);
|
||||
assert!(
|
||||
def.tools.contains(&"schedule_delete".to_string()),
|
||||
"must have schedule_delete"
|
||||
);
|
||||
// Memory tools
|
||||
assert!(
|
||||
def.tools.contains(&"memory_store".to_string()),
|
||||
"must have memory_store"
|
||||
);
|
||||
assert!(
|
||||
def.tools.contains(&"memory_recall".to_string()),
|
||||
"must have memory_recall"
|
||||
);
|
||||
// Knowledge graph tools
|
||||
assert!(
|
||||
def.tools.contains(&"knowledge_add_entity".to_string()),
|
||||
"must have knowledge_add_entity"
|
||||
);
|
||||
assert!(
|
||||
def.tools.contains(&"knowledge_add_relation".to_string()),
|
||||
"must have knowledge_add_relation"
|
||||
);
|
||||
assert!(
|
||||
def.tools.contains(&"knowledge_query".to_string()),
|
||||
"must have knowledge_query"
|
||||
);
|
||||
// Event bus
|
||||
assert!(
|
||||
def.tools.contains(&"event_publish".to_string()),
|
||||
"must have event_publish"
|
||||
);
|
||||
// Infisical-specific tools
|
||||
assert!(
|
||||
def.tools.contains(&"shell_exec".to_string()),
|
||||
"must have shell_exec"
|
||||
);
|
||||
assert!(
|
||||
def.tools.contains(&"vault_set".to_string()),
|
||||
"must have vault_set"
|
||||
);
|
||||
assert!(
|
||||
def.tools.contains(&"vault_get".to_string()),
|
||||
"must have vault_get"
|
||||
);
|
||||
assert!(
|
||||
def.tools.contains(&"vault_list".to_string()),
|
||||
"must have vault_list"
|
||||
);
|
||||
assert!(
|
||||
def.tools.contains(&"vault_delete".to_string()),
|
||||
"must have vault_delete"
|
||||
);
|
||||
// Dashboard
|
||||
assert!(
|
||||
!def.dashboard.metrics.is_empty(),
|
||||
"must have dashboard metrics"
|
||||
);
|
||||
let metric_keys: Vec<&str> = def
|
||||
.dashboard
|
||||
.metrics
|
||||
.iter()
|
||||
.map(|m| m.memory_key.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
metric_keys.contains(&"infisical_sync_secrets_count"),
|
||||
"must have secrets_count metric"
|
||||
);
|
||||
assert!(
|
||||
metric_keys.contains(&"infisical_sync_last_sync"),
|
||||
"must have last_sync metric"
|
||||
);
|
||||
// Agent config
|
||||
assert!(
|
||||
!def.agent.system_prompt.is_empty(),
|
||||
"must have system_prompt"
|
||||
);
|
||||
assert!(
|
||||
def.agent.temperature < 0.2,
|
||||
"security hand should use low temperature"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_einstein_hands_have_knowledge_graph() {
|
||||
let einstein_ids = [
|
||||
|
||||
@@ -293,6 +293,11 @@ pub struct HandAgentConfig {
|
||||
pub system_prompt: String,
|
||||
#[serde(default)]
|
||||
pub max_iterations: Option<u32>,
|
||||
/// Heartbeat interval in seconds for autonomous agents. Overrides the
|
||||
/// AutonomousConfig default (30s), which is too aggressive for agents
|
||||
/// making long LLM calls. Omit to use the kernel default.
|
||||
#[serde(default)]
|
||||
pub heartbeat_interval_secs: Option<u64>,
|
||||
}
|
||||
|
||||
fn default_module() -> String {
|
||||
|
||||
@@ -379,7 +379,10 @@ impl HandRegistry {
|
||||
pub fn readiness(&self, hand_id: &str) -> Option<HandReadiness> {
|
||||
let reqs = self.check_requirements(hand_id).ok()?;
|
||||
|
||||
let requirements_met = reqs.iter().all(|(_, ok)| *ok);
|
||||
// Only non-optional requirements gate readiness.
|
||||
// Optional requirements (e.g. chromium for browser hand) are nice-to-have;
|
||||
// missing them results in "degraded" status but not "requirements not met".
|
||||
let requirements_met = reqs.iter().all(|(req, ok)| *ok || req.optional);
|
||||
|
||||
// A hand is active if at least one instance is in Active status.
|
||||
let active = self
|
||||
@@ -424,10 +427,13 @@ impl Default for HandRegistry {
|
||||
fn check_requirement(req: &HandRequirement) -> bool {
|
||||
match req.requirement_type {
|
||||
RequirementType::Binary => {
|
||||
// Special handling for python3: must actually run the command and verify
|
||||
// the output contains "Python 3", because Windows ships a python3.exe
|
||||
// Store shim that exists on PATH but doesn't actually work.
|
||||
if req.check_value == "python3" {
|
||||
// Special handling for python3 / python: must actually run the command
|
||||
// and verify the output contains "Python 3", because:
|
||||
// - Windows ships a python3.exe Store shim that doesn't actually work
|
||||
// - Most modern Linux distros only ship "python3", not "python"
|
||||
// - Some Docker images only have "python" pointing to Python 3
|
||||
// Matches the detection logic in python_runtime.rs find_python_interpreter().
|
||||
if req.check_value == "python3" || req.check_value == "python" {
|
||||
return check_python3_available();
|
||||
}
|
||||
// Check if binary exists on PATH.
|
||||
@@ -642,7 +648,7 @@ mod tests {
|
||||
fn load_bundled_hands() {
|
||||
let reg = HandRegistry::new();
|
||||
let count = reg.load_bundled();
|
||||
assert_eq!(count, 8);
|
||||
assert_eq!(count, 9);
|
||||
assert!(!reg.list_definitions().is_empty());
|
||||
|
||||
// Clip hand should be loaded
|
||||
@@ -838,17 +844,26 @@ mod tests {
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
// Browser hand requires python3 + chromium. Activate it — if either
|
||||
// requirement is unmet on this machine, it will show as degraded.
|
||||
// Browser hand requires python3 (non-optional) + chromium (optional).
|
||||
// requirements_met only reflects non-optional requirements.
|
||||
// degraded = active + any requirement (including optional) unsatisfied.
|
||||
let instance = reg.activate("browser", HashMap::new()).unwrap();
|
||||
let r = reg.readiness("browser").unwrap();
|
||||
assert!(r.active);
|
||||
|
||||
// If any requirement is not satisfied, degraded should be true
|
||||
if !r.requirements_met {
|
||||
assert!(r.degraded);
|
||||
// Check individual requirements
|
||||
let reqs = reg.check_requirements("browser").unwrap();
|
||||
let python_met = reqs.iter().any(|(req, ok)| req.key == "python3" && *ok);
|
||||
let chromium_met = reqs.iter().any(|(req, ok)| req.key == "chromium" && *ok);
|
||||
|
||||
// requirements_met only gates on non-optional (python3)
|
||||
assert_eq!(r.requirements_met, python_met);
|
||||
|
||||
// degraded = active + any requirement unsatisfied
|
||||
if python_met && chromium_met {
|
||||
assert!(!r.degraded); // all met, not degraded
|
||||
} else {
|
||||
assert!(!r.degraded);
|
||||
assert!(r.degraded); // something is missing, degraded
|
||||
}
|
||||
|
||||
reg.deactivate(instance.instance_id).unwrap();
|
||||
|
||||
@@ -5,15 +5,19 @@ use dashmap::DashMap;
|
||||
use openfang_types::approval::{
|
||||
ApprovalDecision, ApprovalPolicy, ApprovalRequest, ApprovalResponse, RiskLevel,
|
||||
};
|
||||
use std::collections::VecDeque;
|
||||
use tracing::{debug, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Max pending requests per agent.
|
||||
const MAX_PENDING_PER_AGENT: usize = 5;
|
||||
/// Max recent approval records to retain for history and UI visibility.
|
||||
const MAX_RECENT_APPROVALS: usize = 100;
|
||||
|
||||
/// Manages approval requests with oneshot channels for blocking resolution.
|
||||
pub struct ApprovalManager {
|
||||
pending: DashMap<Uuid, PendingRequest>,
|
||||
recent: std::sync::Mutex<VecDeque<ApprovalRecord>>,
|
||||
policy: std::sync::RwLock<ApprovalPolicy>,
|
||||
}
|
||||
|
||||
@@ -22,10 +26,19 @@ struct PendingRequest {
|
||||
sender: tokio::sync::oneshot::Sender<ApprovalDecision>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApprovalRecord {
|
||||
pub request: ApprovalRequest,
|
||||
pub decision: ApprovalDecision,
|
||||
pub decided_at: chrono::DateTime<Utc>,
|
||||
pub decided_by: Option<String>,
|
||||
}
|
||||
|
||||
impl ApprovalManager {
|
||||
pub fn new(policy: ApprovalPolicy) -> Self {
|
||||
Self {
|
||||
pending: DashMap::new(),
|
||||
recent: std::sync::Mutex::new(VecDeque::new()),
|
||||
policy: std::sync::RwLock::new(policy),
|
||||
}
|
||||
}
|
||||
@@ -51,6 +64,7 @@ impl ApprovalManager {
|
||||
|
||||
let timeout = std::time::Duration::from_secs(req.timeout_secs);
|
||||
let id = req.id;
|
||||
let req_for_timeout = req.clone();
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
self.pending.insert(
|
||||
@@ -69,7 +83,12 @@ impl ApprovalManager {
|
||||
decision
|
||||
}
|
||||
_ => {
|
||||
self.pending.remove(&id);
|
||||
let request = self
|
||||
.pending
|
||||
.remove(&id)
|
||||
.map(|(_, pending)| pending.request)
|
||||
.unwrap_or(req_for_timeout);
|
||||
self.push_recent(request, ApprovalDecision::TimedOut, None, Utc::now());
|
||||
warn!(request_id = %id, "Approval request timed out");
|
||||
ApprovalDecision::TimedOut
|
||||
}
|
||||
@@ -91,6 +110,12 @@ impl ApprovalManager {
|
||||
decided_at: Utc::now(),
|
||||
decided_by,
|
||||
};
|
||||
self.push_recent(
|
||||
pending.request.clone(),
|
||||
decision,
|
||||
response.decided_by.clone(),
|
||||
response.decided_at,
|
||||
);
|
||||
// Send decision to waiting agent (ignore error if receiver dropped)
|
||||
let _ = pending.sender.send(decision);
|
||||
info!(request_id = %request_id, ?decision, "Approval request resolved");
|
||||
@@ -108,6 +133,12 @@ impl ApprovalManager {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// List recent non-pending approvals, newest first.
|
||||
pub fn list_recent(&self, limit: usize) -> Vec<ApprovalRecord> {
|
||||
let recent = self.recent.lock().unwrap_or_else(|e| e.into_inner());
|
||||
recent.iter().take(limit).cloned().collect()
|
||||
}
|
||||
|
||||
/// Number of pending requests.
|
||||
pub fn pending_count(&self) -> usize {
|
||||
self.pending.len()
|
||||
@@ -135,6 +166,25 @@ impl ApprovalManager {
|
||||
_ => RiskLevel::Low,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_recent(
|
||||
&self,
|
||||
request: ApprovalRequest,
|
||||
decision: ApprovalDecision,
|
||||
decided_by: Option<String>,
|
||||
decided_at: chrono::DateTime<Utc>,
|
||||
) {
|
||||
let mut recent = self.recent.lock().unwrap_or_else(|e| e.into_inner());
|
||||
recent.push_front(ApprovalRecord {
|
||||
request,
|
||||
decision,
|
||||
decided_at,
|
||||
decided_by,
|
||||
});
|
||||
while recent.len() > MAX_RECENT_APPROVALS {
|
||||
recent.pop_back();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -243,6 +293,7 @@ mod tests {
|
||||
fn test_list_pending_empty() {
|
||||
let mgr = default_manager();
|
||||
assert!(mgr.list_pending().is_empty());
|
||||
assert!(mgr.list_recent(10).is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -293,6 +344,10 @@ mod tests {
|
||||
assert_eq!(decision, ApprovalDecision::TimedOut);
|
||||
// After timeout, pending map should be cleaned up
|
||||
assert_eq!(mgr.pending_count(), 0);
|
||||
let recent = mgr.list_recent(10);
|
||||
assert_eq!(recent.len(), 1);
|
||||
assert_eq!(recent[0].decision, ApprovalDecision::TimedOut);
|
||||
assert_eq!(recent[0].request.tool_name, "shell_exec");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -322,6 +377,10 @@ mod tests {
|
||||
|
||||
let decision = mgr.request_approval(req).await;
|
||||
assert_eq!(decision, ApprovalDecision::Approved);
|
||||
let recent = mgr.list_recent(10);
|
||||
assert_eq!(recent.len(), 1);
|
||||
assert_eq!(recent[0].decision, ApprovalDecision::Approved);
|
||||
assert_eq!(recent[0].decided_by.as_deref(), Some("admin"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -343,6 +402,9 @@ mod tests {
|
||||
|
||||
let decision = mgr.request_approval(req).await;
|
||||
assert_eq!(decision, ApprovalDecision::Denied);
|
||||
let recent = mgr.list_recent(10);
|
||||
assert_eq!(recent.len(), 1);
|
||||
assert_eq!(recent[0].decision, ApprovalDecision::Denied);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -20,6 +20,15 @@ use tracing::{debug, info, warn};
|
||||
/// Maximum consecutive errors before a job is auto-disabled.
|
||||
const MAX_CONSECUTIVE_ERRORS: u32 = 5;
|
||||
|
||||
/// Error returned by [`CronScheduler::try_claim_for_run`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ClaimError {
|
||||
/// Job ID does not exist.
|
||||
NotFound,
|
||||
/// Job exists but is disabled.
|
||||
Disabled,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JobMeta — extra runtime state not stored in CronJob itself
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -306,6 +315,33 @@ impl CronScheduler {
|
||||
due
|
||||
}
|
||||
|
||||
/// Atomically look up a job, verify it is enabled, pre-advance `next_run`
|
||||
/// if overdue, and return a snapshot of the job for execution.
|
||||
///
|
||||
/// This combines the existence check, enabled guard, and scheduler
|
||||
/// reservation into a single `DashMap::get_mut` hold so no concurrent
|
||||
/// request can disable/delete the job between the check and the claim.
|
||||
///
|
||||
/// `next_run` is only advanced when the job is already due
|
||||
/// (`next_run <= now`), matching `due_jobs()`, so triggering a manual run
|
||||
/// on a not-yet-due job won't skip the upcoming scheduled fire.
|
||||
pub fn try_claim_for_run(&self, id: CronJobId) -> Result<CronJob, ClaimError> {
|
||||
match self.jobs.get_mut(&id) {
|
||||
None => Err(ClaimError::NotFound),
|
||||
Some(mut entry) => {
|
||||
let meta = entry.value_mut();
|
||||
if !meta.job.enabled {
|
||||
return Err(ClaimError::Disabled);
|
||||
}
|
||||
let now = Utc::now();
|
||||
if meta.job.next_run.map(|t| t <= now).unwrap_or(false) {
|
||||
meta.job.next_run = Some(compute_next_run_after(&meta.job.schedule, now));
|
||||
}
|
||||
Ok(meta.job.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Outcome recording --------------------------------------------------
|
||||
|
||||
/// Record a successful execution for a job.
|
||||
@@ -351,7 +387,13 @@ impl CronScheduler {
|
||||
);
|
||||
meta.job.enabled = false;
|
||||
} else {
|
||||
meta.job.next_run = Some(compute_next_run_after(&meta.job.schedule, Utc::now()));
|
||||
// Only recompute next_run if the job was already overdue. This
|
||||
// preserves the scheduled fire time when a manual (on-demand) run
|
||||
// fails before the job's natural next_run.
|
||||
let now = Utc::now();
|
||||
if meta.job.next_run.map(|t| t <= now).unwrap_or(true) {
|
||||
meta.job.next_run = Some(compute_next_run_after(&meta.job.schedule, now));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1207,4 +1249,77 @@ mod tests {
|
||||
assert_eq!(sched.list_jobs(other).len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
// -- try_claim_for_run ---------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn try_claim_not_found() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
let id = CronJobId::new();
|
||||
assert!(matches!(
|
||||
sched.try_claim_for_run(id),
|
||||
Err(ClaimError::NotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_claim_disabled() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
let agent = AgentId::new();
|
||||
let mut job = make_job(agent);
|
||||
job.enabled = false;
|
||||
let id = sched.add_job(job, false).unwrap();
|
||||
assert!(matches!(
|
||||
sched.try_claim_for_run(id),
|
||||
Err(ClaimError::Disabled)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_claim_skips_not_yet_due_job() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
let agent = AgentId::new();
|
||||
let mut job = make_job(agent);
|
||||
job.schedule = CronSchedule::Every { every_secs: 3600 };
|
||||
let id = sched.add_job(job, false).unwrap();
|
||||
|
||||
let original_next_run = sched.get_job(id).unwrap().next_run;
|
||||
assert!(original_next_run.is_some());
|
||||
|
||||
// Manual trigger on a not-yet-due job should NOT move next_run.
|
||||
let claimed = sched.try_claim_for_run(id).unwrap();
|
||||
assert_eq!(claimed.id, id);
|
||||
|
||||
let after = sched.get_job(id).unwrap().next_run;
|
||||
assert_eq!(
|
||||
original_next_run, after,
|
||||
"try_claim must not move next_run for a job that is not yet due"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_claim_advances_overdue_job() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
let agent = AgentId::new();
|
||||
let mut job = make_job(agent);
|
||||
job.schedule = CronSchedule::Every { every_secs: 3600 };
|
||||
let id = sched.add_job(job, false).unwrap();
|
||||
|
||||
// Force next_run into the past to simulate an overdue job.
|
||||
if let Some(mut meta) = sched.jobs.get_mut(&id) {
|
||||
meta.job.next_run = Some(Utc::now() - Duration::seconds(10));
|
||||
}
|
||||
|
||||
let before = sched.get_job(id).unwrap().next_run.unwrap();
|
||||
assert!(before < Utc::now(), "precondition: job should be overdue");
|
||||
|
||||
let claimed = sched.try_claim_for_run(id).unwrap();
|
||||
assert_eq!(claimed.id, id);
|
||||
|
||||
let after = sched.get_job(id).unwrap().next_run.unwrap();
|
||||
assert!(
|
||||
after > Utc::now(),
|
||||
"try_claim should advance next_run past now for overdue jobs"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,12 @@ impl Default for RecoveryTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grace period (seconds): if an agent's `last_active` is within this window
|
||||
/// of `created_at`, it has never genuinely processed a message and should not
|
||||
/// be flagged as unresponsive. This covers the small gap between registration
|
||||
/// and the initial `set_state(Running)` call.
|
||||
const IDLE_GRACE_SECS: i64 = 10;
|
||||
|
||||
/// Check all running and crashed agents and return their heartbeat status.
|
||||
///
|
||||
/// This is a pure function — it doesn't start a background task.
|
||||
@@ -151,6 +157,30 @@ pub fn check_agents(registry: &AgentRegistry, config: &HeartbeatConfig) -> Vec<H
|
||||
.map(|a| a.heartbeat_interval_secs * UNRESPONSIVE_MULTIPLIER)
|
||||
.unwrap_or(config.default_timeout_secs) as i64;
|
||||
|
||||
// --- Skip idle agents that have never genuinely processed a message ---
|
||||
//
|
||||
// When an agent is spawned, both `created_at` and `last_active` are set
|
||||
// to now. Administrative operations (set_state, etc.) bump `last_active`
|
||||
// by a tiny amount. If `last_active` is still within IDLE_GRACE_SECS of
|
||||
// `created_at`, the agent was never active beyond its initial startup and
|
||||
// should NOT be flagged as unresponsive. This prevents disabled/unused
|
||||
// agents from entering an infinite crash-recover loop (GitHub #844).
|
||||
//
|
||||
// Periodic / Hand agents with long schedule intervals (e.g. 3600s) are
|
||||
// also covered: they sit idle between ticks and their `last_active` stays
|
||||
// near `created_at` until the first tick fires.
|
||||
let never_active =
|
||||
(entry_ref.last_active - entry_ref.created_at).num_seconds() <= IDLE_GRACE_SECS;
|
||||
|
||||
if never_active && entry_ref.state == AgentState::Running {
|
||||
debug!(
|
||||
agent = %entry_ref.name,
|
||||
inactive_secs,
|
||||
"Skipping idle agent — never received a message"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Crashed agents are always considered unresponsive
|
||||
let unresponsive = entry_ref.state == AgentState::Crashed || inactive_secs > timeout_secs;
|
||||
|
||||
@@ -265,6 +295,153 @@ pub fn summarize(statuses: &[HeartbeatStatus]) -> HeartbeatSummary {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
use openfang_types::agent::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Helper: build a minimal AgentEntry for heartbeat tests.
|
||||
fn make_entry(
|
||||
name: &str,
|
||||
state: AgentState,
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
last_active: chrono::DateTime<Utc>,
|
||||
) -> AgentEntry {
|
||||
AgentEntry {
|
||||
id: AgentId::new(),
|
||||
name: name.to_string(),
|
||||
manifest: AgentManifest {
|
||||
name: name.to_string(),
|
||||
version: "0.1.0".to_string(),
|
||||
description: "test".to_string(),
|
||||
author: "test".to_string(),
|
||||
module: "test".to_string(),
|
||||
schedule: ScheduleMode::default(),
|
||||
model: ModelConfig::default(),
|
||||
fallback_models: vec![],
|
||||
resources: ResourceQuota::default(),
|
||||
priority: Priority::default(),
|
||||
capabilities: ManifestCapabilities::default(),
|
||||
profile: None,
|
||||
tools: HashMap::new(),
|
||||
skills: vec![],
|
||||
mcp_servers: vec![],
|
||||
metadata: HashMap::new(),
|
||||
tags: vec![],
|
||||
routing: None,
|
||||
autonomous: None,
|
||||
pinned_model: None,
|
||||
workspace: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
tool_blocklist: vec![],
|
||||
},
|
||||
state,
|
||||
mode: AgentMode::default(),
|
||||
created_at,
|
||||
last_active,
|
||||
parent: None,
|
||||
children: vec![],
|
||||
session_id: SessionId::new(),
|
||||
tags: vec![],
|
||||
identity: Default::default(),
|
||||
onboarding_completed: false,
|
||||
onboarding_completed_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_idle_agent_skipped_by_heartbeat() {
|
||||
// An agent spawned 5 minutes ago that has never processed a message
|
||||
// (last_active == created_at). It should NOT appear in heartbeat
|
||||
// statuses because it was never genuinely active.
|
||||
let registry = crate::registry::AgentRegistry::new();
|
||||
let five_min_ago = Utc::now() - Duration::seconds(300);
|
||||
let idle_agent = make_entry(
|
||||
"idle-agent",
|
||||
AgentState::Running,
|
||||
five_min_ago,
|
||||
five_min_ago,
|
||||
);
|
||||
registry.register(idle_agent).unwrap();
|
||||
|
||||
let config = HeartbeatConfig::default(); // timeout = 180s
|
||||
let statuses = check_agents(®istry, &config);
|
||||
|
||||
// The idle agent should be skipped entirely
|
||||
assert!(
|
||||
statuses.is_empty(),
|
||||
"idle agent should be skipped by heartbeat"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_active_agent_detected_unresponsive() {
|
||||
// An agent that WAS active (last_active >> created_at) but has gone
|
||||
// silent for longer than the timeout — should be flagged unresponsive.
|
||||
let registry = crate::registry::AgentRegistry::new();
|
||||
let ten_min_ago = Utc::now() - Duration::seconds(600);
|
||||
let five_min_ago = Utc::now() - Duration::seconds(300);
|
||||
let active_agent = make_entry(
|
||||
"active-agent",
|
||||
AgentState::Running,
|
||||
ten_min_ago,
|
||||
five_min_ago,
|
||||
);
|
||||
registry.register(active_agent).unwrap();
|
||||
|
||||
let config = HeartbeatConfig::default(); // timeout = 180s, inactive = ~300s
|
||||
let statuses = check_agents(®istry, &config);
|
||||
|
||||
assert_eq!(statuses.len(), 1);
|
||||
assert!(
|
||||
statuses[0].unresponsive,
|
||||
"active agent past timeout should be unresponsive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_active_agent_within_timeout_is_ok() {
|
||||
// An agent that has been active recently (within timeout).
|
||||
let registry = crate::registry::AgentRegistry::new();
|
||||
let ten_min_ago = Utc::now() - Duration::seconds(600);
|
||||
let just_now = Utc::now() - Duration::seconds(10);
|
||||
let healthy_agent = make_entry("healthy-agent", AgentState::Running, ten_min_ago, just_now);
|
||||
registry.register(healthy_agent).unwrap();
|
||||
|
||||
let config = HeartbeatConfig::default(); // timeout = 180s
|
||||
let statuses = check_agents(®istry, &config);
|
||||
|
||||
assert_eq!(statuses.len(), 1);
|
||||
assert!(
|
||||
!statuses[0].unresponsive,
|
||||
"recently active agent should not be unresponsive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crashed_agent_not_skipped_even_if_idle() {
|
||||
// A crashed agent should still appear in statuses for recovery,
|
||||
// even if it was never genuinely active.
|
||||
let registry = crate::registry::AgentRegistry::new();
|
||||
let five_min_ago = Utc::now() - Duration::seconds(300);
|
||||
let crashed_agent = make_entry(
|
||||
"crashed-idle",
|
||||
AgentState::Crashed,
|
||||
five_min_ago,
|
||||
five_min_ago,
|
||||
);
|
||||
registry.register(crashed_agent).unwrap();
|
||||
|
||||
let config = HeartbeatConfig::default();
|
||||
let statuses = check_agents(®istry, &config);
|
||||
|
||||
assert_eq!(statuses.len(), 1);
|
||||
assert!(
|
||||
statuses[0].unresponsive,
|
||||
"crashed agent should be marked unresponsive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quiet_hours_parsing() {
|
||||
@@ -332,6 +509,17 @@ mod tests {
|
||||
assert_eq!(summary.unresponsive_agents[0].name, "agent-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heartbeat_config_custom_timeout() {
|
||||
let config = HeartbeatConfig {
|
||||
default_timeout_secs: 600,
|
||||
..HeartbeatConfig::default()
|
||||
};
|
||||
assert_eq!(config.default_timeout_secs, 600);
|
||||
assert_eq!(config.check_interval_secs, DEFAULT_CHECK_INTERVAL_SECS);
|
||||
assert_eq!(config.max_recovery_attempts, DEFAULT_MAX_RECOVERY_ATTEMPTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recovery_tracker() {
|
||||
let tracker = RecoveryTracker::new();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -393,6 +393,9 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
|
||||
// ── MiniMax ──────────────────────────────────────────────────
|
||||
if model.contains("minimax") || model.contains("abab") {
|
||||
if model.contains("m2.7") {
|
||||
return (0.30, 1.20);
|
||||
}
|
||||
if model.contains("highspeed") {
|
||||
return (0.80, 3.20);
|
||||
}
|
||||
|
||||
@@ -177,6 +177,27 @@ impl AgentRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's model, provider, and connection hints together.
|
||||
pub fn update_model_provider_config(
|
||||
&self,
|
||||
id: AgentId,
|
||||
new_model: String,
|
||||
new_provider: String,
|
||||
api_key_env: Option<String>,
|
||||
base_url: Option<String>,
|
||||
) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
.agents
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
|
||||
entry.manifest.model.model = new_model;
|
||||
entry.manifest.model.provider = new_provider;
|
||||
entry.manifest.model.api_key_env = api_key_env;
|
||||
entry.manifest.model.base_url = base_url;
|
||||
entry.last_active = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's fallback model chain.
|
||||
pub fn update_fallback_models(
|
||||
&self,
|
||||
@@ -235,6 +256,14 @@ impl AgentRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Touch an agent — refresh last_active without changing any other state.
|
||||
/// Used by the agent loop to prevent heartbeat false-positives during long LLM calls.
|
||||
pub fn touch(&self, id: AgentId) {
|
||||
if let Some(mut entry) = self.agents.get_mut(&id) {
|
||||
entry.last_active = chrono::Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an agent's system prompt (hot-swap, takes effect on next message).
|
||||
pub fn update_system_prompt(&self, id: AgentId, new_prompt: String) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
|
||||
@@ -269,8 +269,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_embedded_files_not_empty() {
|
||||
assert!(!GATEWAY_INDEX_JS.is_empty());
|
||||
assert!(!GATEWAY_PACKAGE_JSON.is_empty());
|
||||
assert_ne!(GATEWAY_INDEX_JS, "");
|
||||
assert_ne!(GATEWAY_PACKAGE_JSON, "");
|
||||
assert!(GATEWAY_INDEX_JS.contains("WhatsApp"));
|
||||
assert!(GATEWAY_PACKAGE_JSON.contains("@openfang/whatsapp-gateway"));
|
||||
}
|
||||
|
||||
@@ -303,7 +303,7 @@ async fn test_wasm_agent_streaming_fallback() {
|
||||
let agent_id = kernel.spawn_agent(manifest).unwrap();
|
||||
|
||||
let (mut rx, handle) = kernel
|
||||
.send_message_streaming(agent_id, "Hi!", None, None, None)
|
||||
.send_message_streaming(agent_id, "Hi!", None, None, None, None)
|
||||
.expect("Streaming should start");
|
||||
|
||||
// Collect all stream events
|
||||
|
||||
@@ -5,6 +5,10 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Memory substrate for the OpenFang Agent OS"
|
||||
|
||||
[features]
|
||||
default = ["http-memory"]
|
||||
http-memory = ["reqwest"]
|
||||
|
||||
[dependencies]
|
||||
openfang-types = { path = "../openfang-types" }
|
||||
tokio = { workspace = true }
|
||||
@@ -17,6 +21,7 @@ uuid = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["blocking"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! HTTP client for the memory-api gateway.
|
||||
//!
|
||||
//! Provides a blocking HTTP client that routes `remember` and `recall` operations
|
||||
//! to the shared memory-api service (PostgreSQL + pgvector + Jina AI embeddings).
|
||||
//! Designed to be called from synchronous SemanticStore methods within
|
||||
//! `spawn_blocking` contexts.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Error type for memory API operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MemoryApiError {
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(String),
|
||||
#[error("API error (status {status}): {message}")]
|
||||
Api { status: u16, message: String },
|
||||
#[error("Parse error: {0}")]
|
||||
Parse(String),
|
||||
#[error("Missing config: {0}")]
|
||||
Config(String),
|
||||
}
|
||||
|
||||
/// HTTP client for the memory-api gateway service.
|
||||
#[derive(Clone)]
|
||||
pub struct MemoryApiClient {
|
||||
base_url: String,
|
||||
token: String,
|
||||
client: reqwest::blocking::Client,
|
||||
}
|
||||
|
||||
// -- Request/Response types matching memory-api endpoints --
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StoreRequest<'a> {
|
||||
content: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
category: Option<&'a str>,
|
||||
#[serde(rename = "agentId", skip_serializing_if = "Option::is_none")]
|
||||
agent_id: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
source: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
importance: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct StoreResponse {
|
||||
pub id: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub deduplicated: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SearchRequest<'a> {
|
||||
query: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
limit: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
category: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SearchResponse {
|
||||
pub results: Vec<SearchResult>,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct SearchResult {
|
||||
pub id: serde_json::Value,
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub category: Option<String>,
|
||||
#[serde(default)]
|
||||
pub score: f64,
|
||||
#[serde(rename = "createdAt", default)]
|
||||
pub created_at: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct HealthResponse {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl MemoryApiClient {
|
||||
/// Create a new memory-api HTTP client.
|
||||
///
|
||||
/// `base_url`: The base URL of the memory-api service (e.g., "http://127.0.0.1:5500").
|
||||
/// `token_env`: The name of the environment variable holding the bearer token.
|
||||
pub fn new(base_url: &str, token_env: &str) -> Result<Self, MemoryApiError> {
|
||||
let token = if token_env.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
std::env::var(token_env).unwrap_or_else(|_| {
|
||||
warn!(env = token_env, "Memory API token env var not set");
|
||||
String::new()
|
||||
})
|
||||
};
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.user_agent("openfang-memory/0.4")
|
||||
.build()
|
||||
.map_err(|e| MemoryApiError::Http(e.to_string()))?;
|
||||
|
||||
let base_url = base_url.trim_end_matches('/').to_string();
|
||||
|
||||
Ok(Self {
|
||||
base_url,
|
||||
token,
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if memory-api is reachable.
|
||||
pub fn health_check(&self) -> Result<(), MemoryApiError> {
|
||||
let url = format!("{}/health", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.map_err(|e| MemoryApiError::Http(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(MemoryApiError::Api {
|
||||
status: resp.status().as_u16(),
|
||||
message: resp.text().unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
|
||||
let body: HealthResponse = resp
|
||||
.json()
|
||||
.map_err(|e| MemoryApiError::Parse(e.to_string()))?;
|
||||
|
||||
if body.status != "ok" {
|
||||
return Err(MemoryApiError::Api {
|
||||
status: 503,
|
||||
message: format!("memory-api status: {}", body.status),
|
||||
});
|
||||
}
|
||||
|
||||
debug!("memory-api health check passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store a memory via POST /memory/store.
|
||||
///
|
||||
/// The memory-api handles embedding generation (Jina AI) and deduplication.
|
||||
pub fn store(
|
||||
&self,
|
||||
content: &str,
|
||||
category: Option<&str>,
|
||||
agent_id: Option<&str>,
|
||||
source: Option<&str>,
|
||||
importance: Option<u8>,
|
||||
tags: Option<Vec<String>>,
|
||||
) -> Result<StoreResponse, MemoryApiError> {
|
||||
let url = format!("{}/memory/store", self.base_url);
|
||||
|
||||
let body = StoreRequest {
|
||||
content,
|
||||
category,
|
||||
agent_id,
|
||||
source,
|
||||
importance,
|
||||
tags,
|
||||
};
|
||||
|
||||
let mut req = self.client.post(&url).json(&body);
|
||||
if !self.token.is_empty() {
|
||||
req = req.header("Authorization", format!("Bearer {}", self.token));
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.map_err(|e| MemoryApiError::Http(e.to_string()))?;
|
||||
let status = resp.status().as_u16();
|
||||
|
||||
if status != 200 && status != 201 {
|
||||
let body_text = resp.text().unwrap_or_default();
|
||||
return Err(MemoryApiError::Api {
|
||||
status,
|
||||
message: body_text,
|
||||
});
|
||||
}
|
||||
|
||||
let result: StoreResponse = resp
|
||||
.json()
|
||||
.map_err(|e| MemoryApiError::Parse(e.to_string()))?;
|
||||
|
||||
debug!(
|
||||
id = %result.id,
|
||||
deduplicated = result.deduplicated,
|
||||
"Stored memory via HTTP"
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Search memories via POST /memory/search.
|
||||
///
|
||||
/// The memory-api handles embedding the query (Jina AI) and hybrid vector+BM25 search.
|
||||
pub fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
category: Option<&str>,
|
||||
) -> Result<Vec<SearchResult>, MemoryApiError> {
|
||||
let url = format!("{}/memory/search", self.base_url);
|
||||
|
||||
let body = SearchRequest {
|
||||
query,
|
||||
limit: Some(limit),
|
||||
category,
|
||||
};
|
||||
|
||||
let mut req = self.client.post(&url).json(&body);
|
||||
if !self.token.is_empty() {
|
||||
req = req.header("Authorization", format!("Bearer {}", self.token));
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.map_err(|e| MemoryApiError::Http(e.to_string()))?;
|
||||
let status = resp.status().as_u16();
|
||||
|
||||
if status != 200 {
|
||||
let body_text = resp.text().unwrap_or_default();
|
||||
return Err(MemoryApiError::Api {
|
||||
status,
|
||||
message: body_text,
|
||||
});
|
||||
}
|
||||
|
||||
let result: SearchResponse = resp
|
||||
.json()
|
||||
.map_err(|e| MemoryApiError::Parse(e.to_string()))?;
|
||||
|
||||
debug!(count = result.count, "Searched memories via HTTP");
|
||||
|
||||
Ok(result.results)
|
||||
}
|
||||
}
|
||||
@@ -100,11 +100,7 @@ impl KnowledgeStore {
|
||||
let mut idx = 1;
|
||||
|
||||
if let Some(ref source) = pattern.source {
|
||||
sql.push_str(&format!(
|
||||
" AND (s.id = ?{} OR s.name = ?{})",
|
||||
idx,
|
||||
idx + 1
|
||||
));
|
||||
sql.push_str(&format!(" AND (s.id = ?{} OR s.name = ?{})", idx, idx + 1));
|
||||
params.push(Box::new(source.clone()));
|
||||
params.push(Box::new(source.clone()));
|
||||
idx += 2;
|
||||
@@ -117,11 +113,7 @@ impl KnowledgeStore {
|
||||
idx += 1;
|
||||
}
|
||||
if let Some(ref target) = pattern.target {
|
||||
sql.push_str(&format!(
|
||||
" AND (t.id = ?{} OR t.name = ?{})",
|
||||
idx,
|
||||
idx + 1
|
||||
));
|
||||
sql.push_str(&format!(" AND (t.id = ?{} OR t.name = ?{})", idx, idx + 1));
|
||||
params.push(Box::new(target.clone()));
|
||||
params.push(Box::new(target.clone()));
|
||||
idx += 2;
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
//! Agents interact with a single `Memory` trait that abstracts over all three stores.
|
||||
|
||||
pub mod consolidation;
|
||||
#[cfg(feature = "http-memory")]
|
||||
pub mod http_client;
|
||||
pub mod knowledge;
|
||||
pub mod migration;
|
||||
pub mod semantic;
|
||||
|
||||
@@ -14,18 +14,43 @@ use openfang_types::memory::{MemoryFilter, MemoryFragment, MemoryId, MemorySourc
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::debug;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
#[cfg(feature = "http-memory")]
|
||||
use crate::http_client::MemoryApiClient;
|
||||
|
||||
/// Semantic store backed by SQLite with optional vector search.
|
||||
///
|
||||
/// Supports two backends:
|
||||
/// - **SQLite** (default): Local LIKE matching / cosine similarity.
|
||||
/// - **HTTP**: Routes `remember`/`recall` to the memory-api gateway
|
||||
/// (PostgreSQL + pgvector + Jina AI embeddings).
|
||||
#[derive(Clone)]
|
||||
pub struct SemanticStore {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
#[cfg(feature = "http-memory")]
|
||||
http_client: Option<MemoryApiClient>,
|
||||
}
|
||||
|
||||
impl SemanticStore {
|
||||
/// Create a new semantic store wrapping the given connection.
|
||||
/// Create a new semantic store wrapping the given connection (SQLite backend).
|
||||
pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
|
||||
Self { conn }
|
||||
Self {
|
||||
conn,
|
||||
#[cfg(feature = "http-memory")]
|
||||
http_client: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a semantic store with an HTTP backend for the memory-api gateway.
|
||||
///
|
||||
/// The SQLite connection is still required for local fallback and other stores.
|
||||
#[cfg(feature = "http-memory")]
|
||||
pub fn new_with_http(conn: Arc<Mutex<Connection>>, client: MemoryApiClient) -> Self {
|
||||
Self {
|
||||
conn,
|
||||
http_client: Some(client),
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a new memory fragment (without embedding).
|
||||
@@ -41,6 +66,9 @@ impl SemanticStore {
|
||||
}
|
||||
|
||||
/// Store a new memory fragment with an optional embedding vector.
|
||||
///
|
||||
/// When HTTP backend is configured, stores via memory-api (which handles
|
||||
/// embedding generation and deduplication). Falls back to local SQLite.
|
||||
pub fn remember_with_embedding(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
@@ -49,6 +77,26 @@ impl SemanticStore {
|
||||
scope: &str,
|
||||
metadata: HashMap<String, serde_json::Value>,
|
||||
embedding: Option<&[f32]>,
|
||||
) -> OpenFangResult<MemoryId> {
|
||||
// HTTP backend: route to memory-api
|
||||
#[cfg(feature = "http-memory")]
|
||||
if let Some(ref client) = self.http_client {
|
||||
return self.remember_via_http(client, agent_id, content, source, scope, &metadata);
|
||||
}
|
||||
|
||||
// SQLite backend (default)
|
||||
self.remember_sqlite(agent_id, content, source, scope, metadata, embedding)
|
||||
}
|
||||
|
||||
/// SQLite implementation of remember_with_embedding.
|
||||
fn remember_sqlite(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
content: &str,
|
||||
source: MemorySource,
|
||||
scope: &str,
|
||||
metadata: HashMap<String, serde_json::Value>,
|
||||
embedding: Option<&[f32]>,
|
||||
) -> OpenFangResult<MemoryId> {
|
||||
let conn = self
|
||||
.conn
|
||||
@@ -80,6 +128,46 @@ impl SemanticStore {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// HTTP implementation of remember — routes to memory-api POST /memory/store.
|
||||
#[cfg(feature = "http-memory")]
|
||||
fn remember_via_http(
|
||||
&self,
|
||||
client: &MemoryApiClient,
|
||||
agent_id: AgentId,
|
||||
content: &str,
|
||||
source: MemorySource,
|
||||
scope: &str,
|
||||
metadata: &HashMap<String, serde_json::Value>,
|
||||
) -> OpenFangResult<MemoryId> {
|
||||
let source_str = format!("{:?}", source).to_lowercase();
|
||||
let importance = metadata
|
||||
.get("importance")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v.min(10) as u8)
|
||||
.unwrap_or(5);
|
||||
let tags: Option<Vec<String>> = metadata
|
||||
.get("tags")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
|
||||
match client.store(
|
||||
content,
|
||||
Some(scope),
|
||||
Some(&agent_id.0.to_string()),
|
||||
Some(&source_str),
|
||||
Some(importance),
|
||||
tags,
|
||||
) {
|
||||
Ok(resp) => {
|
||||
debug!(id = %resp.id, "Stored memory via HTTP backend");
|
||||
Ok(MemoryId::new())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "HTTP memory store failed, falling back to SQLite");
|
||||
self.remember_sqlite(agent_id, content, source, scope, metadata.clone(), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Search for memories using text matching (fallback, no embeddings).
|
||||
pub fn recall(
|
||||
&self,
|
||||
@@ -92,6 +180,9 @@ impl SemanticStore {
|
||||
|
||||
/// Search for memories using vector similarity when a query embedding is provided,
|
||||
/// falling back to LIKE matching otherwise.
|
||||
///
|
||||
/// When HTTP backend is configured, searches via memory-api (hybrid vector+BM25).
|
||||
/// Falls back to local SQLite on HTTP errors.
|
||||
pub fn recall_with_embedding(
|
||||
&self,
|
||||
query: &str,
|
||||
@@ -99,6 +190,17 @@ impl SemanticStore {
|
||||
filter: Option<MemoryFilter>,
|
||||
query_embedding: Option<&[f32]>,
|
||||
) -> OpenFangResult<Vec<MemoryFragment>> {
|
||||
// HTTP backend: route to memory-api
|
||||
#[cfg(feature = "http-memory")]
|
||||
if let Some(ref client) = self.http_client {
|
||||
match self.recall_via_http(client, query, limit, &filter) {
|
||||
Ok(results) => return Ok(results),
|
||||
Err(e) => {
|
||||
warn!(error = %e, "HTTP memory search failed, falling back to SQLite");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
@@ -277,7 +379,15 @@ impl SemanticStore {
|
||||
}
|
||||
|
||||
/// Soft-delete a memory fragment.
|
||||
///
|
||||
/// In HTTP mode, logs a warning (memory-api doesn't support delete yet)
|
||||
/// and performs the soft-delete locally only.
|
||||
pub fn forget(&self, id: MemoryId) -> OpenFangResult<()> {
|
||||
#[cfg(feature = "http-memory")]
|
||||
if self.http_client.is_some() {
|
||||
warn!(id = %id.0, "forget() not supported via HTTP backend, local-only soft-delete");
|
||||
}
|
||||
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
@@ -304,6 +414,57 @@ impl SemanticStore {
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// HTTP implementation of recall — routes to memory-api POST /memory/search.
|
||||
///
|
||||
/// Maps memory-api search results to `MemoryFragment` structs. Fields not
|
||||
/// available from the HTTP API (agent_id, embedding, access_count) use defaults.
|
||||
#[cfg(feature = "http-memory")]
|
||||
fn recall_via_http(
|
||||
&self,
|
||||
client: &MemoryApiClient,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
filter: &Option<MemoryFilter>,
|
||||
) -> OpenFangResult<Vec<MemoryFragment>> {
|
||||
let category = filter.as_ref().and_then(|f| f.scope.as_deref());
|
||||
|
||||
let results = client
|
||||
.search(query, limit, category)
|
||||
.map_err(|e| OpenFangError::Memory(format!("HTTP search failed: {e}")))?;
|
||||
|
||||
let fragments: Vec<MemoryFragment> = results
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let created_at = r
|
||||
.created_at
|
||||
.map(|ms| {
|
||||
chrono::DateTime::from_timestamp_millis(ms as i64).unwrap_or_else(Utc::now)
|
||||
})
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
MemoryFragment {
|
||||
id: MemoryId::new(),
|
||||
agent_id: filter.as_ref().and_then(|f| f.agent_id).unwrap_or_default(),
|
||||
content: r.content,
|
||||
embedding: None,
|
||||
metadata: HashMap::new(),
|
||||
source: MemorySource::System,
|
||||
confidence: r.score as f32,
|
||||
created_at,
|
||||
accessed_at: Utc::now(),
|
||||
access_count: 0,
|
||||
scope: r.category.unwrap_or_else(|| "general".to_string()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
debug!(
|
||||
count = fragments.len(),
|
||||
"Recalled memories via HTTP backend"
|
||||
);
|
||||
Ok(fragments)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute cosine similarity between two vectors.
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::usage::UsageStore;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use openfang_types::agent::{AgentEntry, AgentId, SessionId};
|
||||
use openfang_types::config::MemoryConfig;
|
||||
use openfang_types::error::{OpenFangError, OpenFangResult};
|
||||
use openfang_types::memory::{
|
||||
ConsolidationReport, Entity, ExportFormat, GraphMatch, GraphPattern, ImportReport, Memory,
|
||||
@@ -22,6 +23,7 @@ use rusqlite::Connection;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// The unified memory substrate. Implements the `Memory` trait by delegating
|
||||
/// to specialized stores backed by a shared SQLite connection.
|
||||
@@ -37,17 +39,27 @@ pub struct MemorySubstrate {
|
||||
|
||||
impl MemorySubstrate {
|
||||
/// Open or create a memory substrate at the given database path.
|
||||
pub fn open(db_path: &Path, decay_rate: f32) -> OpenFangResult<Self> {
|
||||
///
|
||||
/// When `memory_config.backend == "http"` and `http_url`/`http_token_env` are set,
|
||||
/// the semantic store routes `remember`/`recall` to the memory-api gateway.
|
||||
/// All other stores (KV, knowledge graph, sessions) remain local SQLite.
|
||||
pub fn open(
|
||||
db_path: &Path,
|
||||
decay_rate: f32,
|
||||
memory_config: &MemoryConfig,
|
||||
) -> OpenFangResult<Self> {
|
||||
let conn = Connection::open(db_path).map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
run_migrations(&conn).map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
let shared = Arc::new(Mutex::new(conn));
|
||||
|
||||
let semantic = Self::create_semantic_store(Arc::clone(&shared), memory_config);
|
||||
|
||||
Ok(Self {
|
||||
conn: Arc::clone(&shared),
|
||||
structured: StructuredStore::new(Arc::clone(&shared)),
|
||||
semantic: SemanticStore::new(Arc::clone(&shared)),
|
||||
semantic,
|
||||
knowledge: KnowledgeStore::new(Arc::clone(&shared)),
|
||||
sessions: SessionStore::new(Arc::clone(&shared)),
|
||||
usage: UsageStore::new(Arc::clone(&shared)),
|
||||
@@ -55,7 +67,43 @@ impl MemorySubstrate {
|
||||
})
|
||||
}
|
||||
|
||||
/// Create an in-memory substrate (for testing).
|
||||
/// Create the semantic store, optionally with HTTP backend.
|
||||
fn create_semantic_store(
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
memory_config: &MemoryConfig,
|
||||
) -> SemanticStore {
|
||||
#[cfg(feature = "http-memory")]
|
||||
if memory_config.backend == "http" {
|
||||
if let (Some(url), Some(token_env)) =
|
||||
(&memory_config.http_url, &memory_config.http_token_env)
|
||||
{
|
||||
match crate::http_client::MemoryApiClient::new(url, token_env) {
|
||||
Ok(client) => {
|
||||
// Best-effort health check on startup
|
||||
match client.health_check() {
|
||||
Ok(()) => info!(url = %url, "HTTP memory backend connected"),
|
||||
Err(e) => {
|
||||
warn!(url = %url, error = %e, "HTTP memory backend health check failed, will retry on use")
|
||||
}
|
||||
}
|
||||
return SemanticStore::new_with_http(conn, client);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Failed to create HTTP memory client, falling back to SQLite");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("backend=http but http_url/http_token_env not set, falling back to SQLite");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "http-memory"))]
|
||||
let _ = memory_config;
|
||||
|
||||
SemanticStore::new(conn)
|
||||
}
|
||||
|
||||
/// Create an in-memory substrate (for testing). Always uses SQLite backend.
|
||||
pub fn open_in_memory(decay_rate: f32) -> OpenFangResult<Self> {
|
||||
let conn =
|
||||
Connection::open_in_memory().map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
|
||||
@@ -30,6 +30,8 @@ zeroize = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
rmcp = { workspace = true }
|
||||
http = "1"
|
||||
tokio-tungstenite = "0.24"
|
||||
shlex = "1"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::context_budget::{apply_context_guard, truncate_tool_result_dynamic, C
|
||||
use crate::context_overflow::{recover_from_overflow, RecoveryStage};
|
||||
use crate::embedding::EmbeddingDriver;
|
||||
use crate::kernel_handle::KernelHandle;
|
||||
use crate::llm_driver::{CompletionRequest, LlmDriver, LlmError, StreamEvent};
|
||||
use crate::llm_driver::{CompletionRequest, DriverConfig, LlmDriver, LlmError, StreamEvent};
|
||||
use crate::llm_errors;
|
||||
use crate::loop_guard::{LoopGuard, LoopGuardConfig, LoopGuardVerdict};
|
||||
use crate::mcp::McpConnection;
|
||||
@@ -17,7 +17,7 @@ use crate::web_search::WebToolsContext;
|
||||
use openfang_memory::session::Session;
|
||||
use openfang_memory::MemorySubstrate;
|
||||
use openfang_skills::registry::SkillRegistry;
|
||||
use openfang_types::agent::AgentManifest;
|
||||
use openfang_types::agent::{AgentManifest, FallbackModel};
|
||||
use openfang_types::error::{OpenFangError, OpenFangResult};
|
||||
use openfang_types::memory::{Memory, MemoryFilter, MemorySource};
|
||||
use openfang_types::message::{
|
||||
@@ -44,6 +44,20 @@ const BASE_RETRY_DELAY_MS: u64 = 1000;
|
||||
/// Raised from 60s to 120s for browser automation and long-running builds.
|
||||
const TOOL_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
/// Timeout for inter-agent tool calls (seconds).
|
||||
/// Agent delegation (agent_send, agent_spawn) can involve a full agent loop on the
|
||||
/// target, so these need a significantly longer timeout than regular tools.
|
||||
const AGENT_TOOL_TIMEOUT_SECS: u64 = 600;
|
||||
|
||||
/// Returns the appropriate timeout duration for a given tool name.
|
||||
/// Inter-agent calls get a longer timeout since they may trigger full agent loops.
|
||||
fn tool_timeout_for(tool_name: &str) -> Duration {
|
||||
match tool_name {
|
||||
"agent_send" | "agent_spawn" => Duration::from_secs(AGENT_TOOL_TIMEOUT_SECS),
|
||||
_ => Duration::from_secs(TOOL_TIMEOUT_SECS),
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum consecutive MaxTokens continuations before returning partial response.
|
||||
/// Raised from 3 to 5 to allow longer-form generation.
|
||||
const MAX_CONTINUATIONS: u32 = 5;
|
||||
@@ -57,8 +71,15 @@ fn phantom_action_detected(text: &str) -> bool {
|
||||
let lower = text.to_lowercase();
|
||||
let action_verbs = ["sent ", "posted ", "emailed ", "delivered ", "forwarded "];
|
||||
let channel_refs = [
|
||||
"telegram", "whatsapp", "slack", "discord", "email", "channel",
|
||||
"message sent", "successfully sent", "has been sent",
|
||||
"telegram",
|
||||
"whatsapp",
|
||||
"slack",
|
||||
"discord",
|
||||
"email",
|
||||
"channel",
|
||||
"message sent",
|
||||
"successfully sent",
|
||||
"has been sent",
|
||||
];
|
||||
let has_action = action_verbs.iter().any(|v| lower.contains(v));
|
||||
let has_channel = channel_refs.iter().any(|c| lower.contains(c));
|
||||
@@ -272,7 +293,9 @@ pub async fn run_agent_loop(
|
||||
// The LLM already received them via llm_messages above.
|
||||
for msg in session.messages.iter_mut() {
|
||||
if let MessageContent::Blocks(blocks) = &mut msg.content {
|
||||
let had_images = blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. }));
|
||||
let had_images = blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::Image { .. }));
|
||||
if had_images {
|
||||
blocks.retain(|b| !matches!(b, ContentBlock::Image { .. }));
|
||||
if blocks.is_empty() {
|
||||
@@ -381,9 +404,22 @@ pub async fn run_agent_loop(
|
||||
cb(LoopPhase::Thinking);
|
||||
}
|
||||
|
||||
// Stamp last_active before the (potentially long) LLM call so the
|
||||
// heartbeat monitor doesn't flag us as unresponsive mid-iteration.
|
||||
if let Some(k) = &kernel {
|
||||
k.touch_agent(&agent_id_str);
|
||||
}
|
||||
|
||||
// Call LLM with retry, error classification, and circuit breaker
|
||||
let provider_name = manifest.model.provider.as_str();
|
||||
let mut response = call_with_retry(&*driver, request, Some(provider_name), None).await?;
|
||||
let mut response = call_with_retry(
|
||||
&*driver,
|
||||
request,
|
||||
Some(provider_name),
|
||||
None,
|
||||
&manifest.fallback_models,
|
||||
)
|
||||
.await?;
|
||||
|
||||
total_usage.input_tokens += response.usage.input_tokens;
|
||||
total_usage.output_tokens += response.usage.output_tokens;
|
||||
@@ -454,7 +490,10 @@ pub async fn run_agent_loop(
|
||||
// One-shot retry: if the LLM returns empty text with no tool use,
|
||||
// try once more before accepting the empty result.
|
||||
// Triggers on first call OR when input_tokens=0 (silently failed request).
|
||||
if text.trim().is_empty() && response.tool_calls.is_empty() && !response.has_any_content() {
|
||||
if text.trim().is_empty()
|
||||
&& response.tool_calls.is_empty()
|
||||
&& !response.has_any_content()
|
||||
{
|
||||
let is_silent_failure =
|
||||
response.usage.input_tokens == 0 && response.usage.output_tokens == 0;
|
||||
if iteration == 0 || is_silent_failure {
|
||||
@@ -499,7 +538,10 @@ pub async fn run_agent_loop(
|
||||
// channel action (send, post, email, etc.) but never actually
|
||||
// called the corresponding tool, re-prompt once to force real
|
||||
// tool usage instead of hallucinated completion.
|
||||
let text = if !any_tools_executed && iteration == 0 && phantom_action_detected(&text) {
|
||||
let text = if !any_tools_executed
|
||||
&& iteration == 0
|
||||
&& phantom_action_detected(&text)
|
||||
{
|
||||
warn!(agent = %manifest.name, "Phantom action detected — re-prompting for real tool use");
|
||||
messages.push(Message::assistant(text));
|
||||
messages.push(Message::user(
|
||||
@@ -628,7 +670,7 @@ pub async fn run_agent_loop(
|
||||
|
||||
// Execute each tool call with loop guard, timeout, and truncation
|
||||
let mut tool_result_blocks = Vec::new();
|
||||
for tool_call in &response.tool_calls {
|
||||
for tool_call in deduplicate_tool_calls(&response) {
|
||||
// Loop guard check
|
||||
let verdict = loop_guard.check(&tool_call.name, &tool_call.input);
|
||||
match &verdict {
|
||||
@@ -710,8 +752,10 @@ pub async fn run_agent_loop(
|
||||
let effective_exec_policy = manifest.exec_policy.as_ref();
|
||||
|
||||
// Timeout-wrapped execution
|
||||
let timeout = tool_timeout_for(&tool_call.name);
|
||||
let timeout_secs = timeout.as_secs();
|
||||
let result = match tokio::time::timeout(
|
||||
Duration::from_secs(TOOL_TIMEOUT_SECS),
|
||||
timeout,
|
||||
tool_runner::execute_tool(
|
||||
&tool_call.id,
|
||||
&tool_call.name,
|
||||
@@ -740,12 +784,12 @@ pub async fn run_agent_loop(
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
warn!(tool = %tool_call.name, "Tool execution timed out after {}s", TOOL_TIMEOUT_SECS);
|
||||
warn!(tool = %tool_call.name, "Tool execution timed out after {}s", timeout_secs);
|
||||
openfang_types::tool::ToolResult {
|
||||
tool_use_id: tool_call.id.clone(),
|
||||
content: format!(
|
||||
"Tool '{}' timed out after {}s.",
|
||||
tool_call.name, TOOL_TIMEOUT_SECS
|
||||
tool_call.name, timeout_secs
|
||||
),
|
||||
is_error: true,
|
||||
}
|
||||
@@ -920,11 +964,15 @@ pub async fn run_agent_loop(
|
||||
///
|
||||
/// Uses the `llm_errors` classifier for smart error handling and the
|
||||
/// `ProviderCooldown` circuit breaker to prevent request storms.
|
||||
///
|
||||
/// When the primary model returns a `ModelNotFound` error and `fallback_models`
|
||||
/// is non-empty, each fallback is tried in order before propagating the error.
|
||||
async fn call_with_retry(
|
||||
driver: &dyn LlmDriver,
|
||||
request: CompletionRequest,
|
||||
provider: Option<&str>,
|
||||
cooldown: Option<&ProviderCooldown>,
|
||||
fallback_models: &[FallbackModel],
|
||||
) -> OpenFangResult<crate::llm_driver::CompletionResponse> {
|
||||
// Check circuit breaker before calling
|
||||
if let (Some(provider), Some(cooldown)) = (provider, cooldown) {
|
||||
@@ -1013,6 +1061,73 @@ async fn call_with_retry(
|
||||
cooldown.record_failure(provider, classified.is_billing);
|
||||
}
|
||||
|
||||
// --- ModelNotFound fallback chain (issue #845) ---
|
||||
// If the primary model was not found and fallback models are
|
||||
// configured, try each fallback before giving up.
|
||||
if classified.category == llm_errors::LlmErrorCategory::ModelNotFound
|
||||
&& !fallback_models.is_empty()
|
||||
{
|
||||
warn!(
|
||||
"Primary model not found, trying {} fallback model(s)",
|
||||
fallback_models.len()
|
||||
);
|
||||
for (fb_idx, fb) in fallback_models.iter().enumerate() {
|
||||
let api_key = fb
|
||||
.api_key_env
|
||||
.as_deref()
|
||||
.and_then(|env_name| std::env::var(env_name).ok());
|
||||
let fb_config = DriverConfig {
|
||||
provider: fb.provider.clone(),
|
||||
api_key,
|
||||
base_url: fb.base_url.clone(),
|
||||
skip_permissions: true,
|
||||
};
|
||||
let fb_driver = match crate::drivers::create_driver(&fb_config) {
|
||||
Ok(d) => d,
|
||||
Err(driver_err) => {
|
||||
warn!(
|
||||
fallback_index = fb_idx,
|
||||
provider = %fb.provider,
|
||||
model = %fb.model,
|
||||
error = %driver_err,
|
||||
"Failed to create fallback driver, skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut fb_request = request.clone();
|
||||
fb_request.model = fb.model.clone();
|
||||
warn!(
|
||||
fallback_index = fb_idx,
|
||||
provider = %fb.provider,
|
||||
model = %fb.model,
|
||||
"Trying fallback model"
|
||||
);
|
||||
match fb_driver.complete(fb_request).await {
|
||||
Ok(response) => {
|
||||
info!(
|
||||
fallback_index = fb_idx,
|
||||
provider = %fb.provider,
|
||||
model = %fb.model,
|
||||
"Fallback model succeeded"
|
||||
);
|
||||
return Ok(response);
|
||||
}
|
||||
Err(fb_err) => {
|
||||
warn!(
|
||||
fallback_index = fb_idx,
|
||||
provider = %fb.provider,
|
||||
model = %fb.model,
|
||||
error = %fb_err,
|
||||
"Fallback model failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// All fallbacks exhausted — fall through to return the
|
||||
// original ModelNotFound error below.
|
||||
}
|
||||
|
||||
// Include raw error detail so dashboard users can debug
|
||||
let user_msg = if classified.category == llm_errors::LlmErrorCategory::Format {
|
||||
format!("{} — raw: {}", classified.sanitized_message, raw_error)
|
||||
@@ -1032,12 +1147,16 @@ async fn call_with_retry(
|
||||
/// Call an LLM driver in streaming mode with automatic retry on rate-limit and overload errors.
|
||||
///
|
||||
/// Uses the `llm_errors` classifier and `ProviderCooldown` circuit breaker.
|
||||
///
|
||||
/// When the primary model returns a `ModelNotFound` error and `fallback_models`
|
||||
/// is non-empty, each fallback is tried in order before propagating the error.
|
||||
async fn stream_with_retry(
|
||||
driver: &dyn LlmDriver,
|
||||
request: CompletionRequest,
|
||||
tx: mpsc::Sender<StreamEvent>,
|
||||
provider: Option<&str>,
|
||||
cooldown: Option<&ProviderCooldown>,
|
||||
fallback_models: &[FallbackModel],
|
||||
) -> OpenFangResult<crate::llm_driver::CompletionResponse> {
|
||||
// Check circuit breaker before calling
|
||||
if let (Some(provider), Some(cooldown)) = (provider, cooldown) {
|
||||
@@ -1127,6 +1246,69 @@ async fn stream_with_retry(
|
||||
cooldown.record_failure(provider, classified.is_billing);
|
||||
}
|
||||
|
||||
// --- ModelNotFound fallback chain (issue #845) ---
|
||||
if classified.category == llm_errors::LlmErrorCategory::ModelNotFound
|
||||
&& !fallback_models.is_empty()
|
||||
{
|
||||
warn!(
|
||||
"Primary model not found (stream), trying {} fallback model(s)",
|
||||
fallback_models.len()
|
||||
);
|
||||
for (fb_idx, fb) in fallback_models.iter().enumerate() {
|
||||
let api_key = fb
|
||||
.api_key_env
|
||||
.as_deref()
|
||||
.and_then(|env_name| std::env::var(env_name).ok());
|
||||
let fb_config = DriverConfig {
|
||||
provider: fb.provider.clone(),
|
||||
api_key,
|
||||
base_url: fb.base_url.clone(),
|
||||
skip_permissions: true,
|
||||
};
|
||||
let fb_driver = match crate::drivers::create_driver(&fb_config) {
|
||||
Ok(d) => d,
|
||||
Err(driver_err) => {
|
||||
warn!(
|
||||
fallback_index = fb_idx,
|
||||
provider = %fb.provider,
|
||||
model = %fb.model,
|
||||
error = %driver_err,
|
||||
"Failed to create fallback stream driver, skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut fb_request = request.clone();
|
||||
fb_request.model = fb.model.clone();
|
||||
warn!(
|
||||
fallback_index = fb_idx,
|
||||
provider = %fb.provider,
|
||||
model = %fb.model,
|
||||
"Trying fallback model (stream)"
|
||||
);
|
||||
match fb_driver.stream(fb_request, tx.clone()).await {
|
||||
Ok(response) => {
|
||||
info!(
|
||||
fallback_index = fb_idx,
|
||||
provider = %fb.provider,
|
||||
model = %fb.model,
|
||||
"Fallback model succeeded (stream)"
|
||||
);
|
||||
return Ok(response);
|
||||
}
|
||||
Err(fb_err) => {
|
||||
warn!(
|
||||
fallback_index = fb_idx,
|
||||
provider = %fb.provider,
|
||||
model = %fb.model,
|
||||
error = %fb_err,
|
||||
"Fallback model failed (stream)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let user_msg = if classified.category == llm_errors::LlmErrorCategory::Format {
|
||||
format!("{} — raw: {}", classified.sanitized_message, raw_error)
|
||||
} else {
|
||||
@@ -1275,7 +1457,9 @@ pub async fn run_agent_loop_streaming(
|
||||
// The LLM already received them via llm_messages above.
|
||||
for msg in session.messages.iter_mut() {
|
||||
if let MessageContent::Blocks(blocks) = &mut msg.content {
|
||||
let had_images = blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. }));
|
||||
let had_images = blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::Image { .. }));
|
||||
if had_images {
|
||||
blocks.retain(|b| !matches!(b, ContentBlock::Image { .. }));
|
||||
if blocks.is_empty() {
|
||||
@@ -1371,6 +1555,14 @@ pub async fn run_agent_loop_streaming(
|
||||
}
|
||||
}
|
||||
|
||||
// Re-validate tool_call/tool_result pairing after overflow drains
|
||||
// which may have broken assistant→tool ordering invariants.
|
||||
// (Matches the non-streaming loop; fixes Qwen3.5-plus "tool_calls must
|
||||
// be followed by tool messages" errors after context overflow recovery.)
|
||||
if recovery != RecoveryStage::None {
|
||||
messages = crate::session_repair::validate_and_repair(&messages);
|
||||
}
|
||||
|
||||
// Context guard: compact oversized tool results before LLM call
|
||||
apply_context_guard(&mut messages, &context_budget, available_tools);
|
||||
|
||||
@@ -1406,6 +1598,7 @@ pub async fn run_agent_loop_streaming(
|
||||
stream_tx.clone(),
|
||||
Some(provider_name),
|
||||
None,
|
||||
&manifest.fallback_models,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1475,7 +1668,10 @@ pub async fn run_agent_loop_streaming(
|
||||
// One-shot retry: if the LLM returns empty text with no tool use,
|
||||
// try once more before accepting the empty result.
|
||||
// Triggers on first call OR when input_tokens=0 (silently failed request).
|
||||
if text.trim().is_empty() && response.tool_calls.is_empty() && !response.has_any_content() {
|
||||
if text.trim().is_empty()
|
||||
&& response.tool_calls.is_empty()
|
||||
&& !response.has_any_content()
|
||||
{
|
||||
let is_silent_failure =
|
||||
response.usage.input_tokens == 0 && response.usage.output_tokens == 0;
|
||||
if iteration == 0 || is_silent_failure {
|
||||
@@ -1628,7 +1824,7 @@ pub async fn run_agent_loop_streaming(
|
||||
|
||||
// Execute each tool call with loop guard, timeout, and truncation
|
||||
let mut tool_result_blocks = Vec::new();
|
||||
for tool_call in &response.tool_calls {
|
||||
for tool_call in deduplicate_tool_calls(&response) {
|
||||
// Loop guard check
|
||||
let verdict = loop_guard.check(&tool_call.name, &tool_call.input);
|
||||
match &verdict {
|
||||
@@ -1709,8 +1905,10 @@ pub async fn run_agent_loop_streaming(
|
||||
let effective_exec_policy = manifest.exec_policy.as_ref();
|
||||
|
||||
// Timeout-wrapped execution
|
||||
let timeout = tool_timeout_for(&tool_call.name);
|
||||
let timeout_secs = timeout.as_secs();
|
||||
let result = match tokio::time::timeout(
|
||||
Duration::from_secs(TOOL_TIMEOUT_SECS),
|
||||
timeout,
|
||||
tool_runner::execute_tool(
|
||||
&tool_call.id,
|
||||
&tool_call.name,
|
||||
@@ -1739,12 +1937,12 @@ pub async fn run_agent_loop_streaming(
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
warn!(tool = %tool_call.name, "Tool execution timed out after {}s (streaming)", TOOL_TIMEOUT_SECS);
|
||||
warn!(tool = %tool_call.name, "Tool execution timed out after {}s (streaming)", timeout_secs);
|
||||
openfang_types::tool::ToolResult {
|
||||
tool_use_id: tool_call.id.clone(),
|
||||
content: format!(
|
||||
"Tool '{}' timed out after {}s.",
|
||||
tool_call.name, TOOL_TIMEOUT_SECS
|
||||
tool_call.name, timeout_secs
|
||||
),
|
||||
is_error: true,
|
||||
}
|
||||
@@ -1780,6 +1978,7 @@ pub async fn run_agent_loop_streaming(
|
||||
let preview: String = final_content.chars().take(300).collect();
|
||||
if stream_tx
|
||||
.send(StreamEvent::ToolExecutionResult {
|
||||
id: tool_call.id.clone(),
|
||||
name: tool_call.name.clone(),
|
||||
result_preview: preview,
|
||||
is_error: result.is_error,
|
||||
@@ -2732,6 +2931,20 @@ fn try_parse_bare_json_tool_call(
|
||||
parse_json_tool_call_object(&text[..end], tool_names)
|
||||
}
|
||||
|
||||
/// Deduplicate tool calls from the response.
|
||||
/// Returns a reference to the deduplicated tool calls.
|
||||
pub fn deduplicate_tool_calls(response: &crate::llm_driver::CompletionResponse) -> Vec<&ToolCall> {
|
||||
let mut hash_set = std::collections::HashSet::new();
|
||||
let mut deduplicated = Vec::new();
|
||||
for tool_call in &response.tool_calls {
|
||||
let hash = LoopGuard::compute_hash(&tool_call.name, &tool_call.input);
|
||||
if hash_set.insert(hash) {
|
||||
deduplicated.push(tool_call);
|
||||
}
|
||||
}
|
||||
deduplicated
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2793,6 +3006,15 @@ mod tests {
|
||||
#[test]
|
||||
fn test_tool_timeout_constant() {
|
||||
assert_eq!(TOOL_TIMEOUT_SECS, 120);
|
||||
assert_eq!(AGENT_TOOL_TIMEOUT_SECS, 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_timeout_for_agent_tools() {
|
||||
assert_eq!(tool_timeout_for("agent_send"), Duration::from_secs(600));
|
||||
assert_eq!(tool_timeout_for("agent_spawn"), Duration::from_secs(600));
|
||||
assert_eq!(tool_timeout_for("file_read"), Duration::from_secs(120));
|
||||
assert_eq!(tool_timeout_for("shell_exec"), Duration::from_secs(120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -877,7 +877,7 @@ pub async fn tool_browser_navigate(
|
||||
agent_id: &str,
|
||||
) -> Result<String, String> {
|
||||
let url = input["url"].as_str().ok_or("Missing 'url' parameter")?;
|
||||
crate::web_fetch::check_ssrf(url)?;
|
||||
crate::web_fetch::check_ssrf(url, &[])?;
|
||||
|
||||
let resp = mgr
|
||||
.send_command(
|
||||
|
||||
@@ -606,6 +606,49 @@ async fn summarize_in_chunks(
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust a split index so it does not land between an assistant ToolUse message
|
||||
/// and the immediately following user ToolResult message.
|
||||
///
|
||||
/// If `split` points right after an assistant message that contains ToolUse blocks,
|
||||
/// and the message at `split` is a user message with matching ToolResult blocks,
|
||||
/// the split is pulled back by 1 so the pair stays in the "kept" portion.
|
||||
fn adjust_split_for_tool_pairs(messages: &[Message], split: usize) -> usize {
|
||||
use openfang_types::message::{ContentBlock, Role};
|
||||
|
||||
if split == 0 || split >= messages.len() {
|
||||
return split;
|
||||
}
|
||||
|
||||
// Check if split - 1 is an assistant with ToolUse and split is a user with ToolResult
|
||||
let prev = &messages[split - 1];
|
||||
let curr = &messages[split];
|
||||
|
||||
if prev.role != Role::Assistant || curr.role != Role::User {
|
||||
return split;
|
||||
}
|
||||
|
||||
let prev_has_tool_use = match &prev.content {
|
||||
MessageContent::Blocks(blocks) => blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::ToolUse { .. })),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let curr_has_tool_result = match &curr.content {
|
||||
MessageContent::Blocks(blocks) => blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::ToolResult { .. })),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if prev_has_tool_use && curr_has_tool_result {
|
||||
// Pull back so both stay in "kept"
|
||||
split - 1
|
||||
} else {
|
||||
split
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact a session by summarizing older messages with an LLM.
|
||||
///
|
||||
/// Takes all messages except the most recent `keep_recent` and uses a
|
||||
@@ -634,7 +677,11 @@ pub async fn compact_session(
|
||||
});
|
||||
}
|
||||
|
||||
let split_at = msg_count.saturating_sub(config.keep_recent);
|
||||
let raw_split = msg_count.saturating_sub(config.keep_recent);
|
||||
// Adjust split point to avoid cutting between a ToolUse assistant message
|
||||
// and its ToolResult user message. If the split lands right between them,
|
||||
// pull back by 1 so the pair stays together in `kept`.
|
||||
let split_at = adjust_split_for_tool_pairs(&session.messages, raw_split);
|
||||
let to_compact = &session.messages[..split_at];
|
||||
let kept = &session.messages[split_at..];
|
||||
|
||||
@@ -1403,4 +1450,53 @@ mod tests {
|
||||
let text = build_conversation_text(&messages, &config);
|
||||
assert!(text.contains(short_result));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adjust_split_pulls_back_for_tool_pair() {
|
||||
// Messages: [user, assistant(ToolUse), user(ToolResult), assistant("done")]
|
||||
// Split at 2 would separate the ToolUse from its ToolResult.
|
||||
let messages = vec![
|
||||
Message::user("hello"),
|
||||
Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
|
||||
id: "t1".to_string(),
|
||||
name: "read".to_string(),
|
||||
input: serde_json::json!({}),
|
||||
provider_metadata: None,
|
||||
}]),
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t1".to_string(),
|
||||
tool_name: "read".to_string(),
|
||||
content: "file contents".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
},
|
||||
Message::assistant("Done reading."),
|
||||
];
|
||||
let adjusted = adjust_split_for_tool_pairs(&messages, 2);
|
||||
assert_eq!(adjusted, 1, "Should pull back split to keep ToolUse + ToolResult together");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adjust_split_no_change_for_text() {
|
||||
let messages = vec![
|
||||
Message::user("a"),
|
||||
Message::assistant("b"),
|
||||
Message::user("c"),
|
||||
];
|
||||
let adjusted = adjust_split_for_tool_pairs(&messages, 1);
|
||||
assert_eq!(adjusted, 1, "Should not change split for plain text messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adjust_split_edge_cases() {
|
||||
let messages = vec![Message::user("a")];
|
||||
assert_eq!(adjust_split_for_tool_pairs(&messages, 0), 0);
|
||||
assert_eq!(adjust_split_for_tool_pairs(&messages, 1), 1);
|
||||
assert_eq!(adjust_split_for_tool_pairs(&messages, 5), 5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,85 @@
|
||||
//! 3. Truncate historical tool results to 2K chars each
|
||||
//! 4. Return error suggesting /reset or /compact
|
||||
|
||||
use openfang_types::message::{ContentBlock, Message, MessageContent};
|
||||
use openfang_types::message::{ContentBlock, Message, MessageContent, Role};
|
||||
use openfang_types::tool::ToolDefinition;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Adjust a drain boundary so it does not split a ToolUse/ToolResult pair.
|
||||
///
|
||||
/// If the message at `boundary` is a user message containing ToolResult blocks
|
||||
/// whose matching ToolUse lives in the message at `boundary - 1`, we pull the
|
||||
/// boundary back by one so both the assistant (ToolUse) and user (ToolResult)
|
||||
/// are kept together. Conversely, if the last drained message is an assistant
|
||||
/// with ToolUse blocks whose results sit at `boundary`, we push the boundary
|
||||
/// forward by one so the orphaned assistant is also drained.
|
||||
///
|
||||
/// This is best-effort — `session_repair::validate_and_repair` is still called
|
||||
/// afterwards as the authoritative fixup.
|
||||
fn safe_drain_boundary(messages: &[Message], mut boundary: usize) -> usize {
|
||||
if boundary == 0 || boundary >= messages.len() {
|
||||
return boundary;
|
||||
}
|
||||
|
||||
// Case 1: first kept message is a user msg with ToolResults whose ToolUse
|
||||
// is in the last drained message (boundary - 1). Pull boundary back by 1.
|
||||
if messages[boundary].role == Role::User {
|
||||
if let MessageContent::Blocks(blocks) = &messages[boundary].content {
|
||||
let has_tool_result = blocks.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. }));
|
||||
if has_tool_result && boundary > 0 && messages[boundary - 1].role == Role::Assistant {
|
||||
if let MessageContent::Blocks(asst_blocks) = &messages[boundary - 1].content {
|
||||
let has_tool_use = asst_blocks.iter().any(|b| matches!(b, ContentBlock::ToolUse { .. }));
|
||||
if has_tool_use {
|
||||
boundary -= 1;
|
||||
debug!(
|
||||
new_boundary = boundary,
|
||||
"Adjusted drain boundary back to keep ToolUse/ToolResult pair"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: last drained message (boundary - 1) is an assistant with ToolUse
|
||||
// but the ToolResults are at `boundary` (already handled above by pulling
|
||||
// back). If the first kept message is NOT the matching result, push forward
|
||||
// to drain the orphaned assistant too.
|
||||
if boundary > 0 && boundary < messages.len() && messages[boundary - 1].role == Role::Assistant {
|
||||
if let MessageContent::Blocks(asst_blocks) = &messages[boundary - 1].content {
|
||||
let tool_use_ids: Vec<&str> = asst_blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if !tool_use_ids.is_empty() {
|
||||
// Check if the first kept message has the matching results
|
||||
let first_kept_has_results = match &messages[boundary].content {
|
||||
MessageContent::Blocks(blocks) => blocks.iter().any(|b| match b {
|
||||
ContentBlock::ToolResult { tool_use_id, .. } => {
|
||||
tool_use_ids.contains(&tool_use_id.as_str())
|
||||
}
|
||||
_ => false,
|
||||
}),
|
||||
_ => false,
|
||||
};
|
||||
if !first_kept_has_results {
|
||||
// The assistant's ToolResults were already drained; drain the
|
||||
// orphaned assistant as well to avoid needing synthetic results.
|
||||
boundary = boundary.min(messages.len());
|
||||
// Note: we don't push forward here because that would drain
|
||||
// more messages than intended. The validate_and_repair call
|
||||
// will insert synthetic results for this orphan instead.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boundary
|
||||
}
|
||||
|
||||
/// Recovery stage that was applied.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RecoveryStage {
|
||||
@@ -53,12 +128,14 @@ pub fn recover_from_overflow(
|
||||
// Stage 1: Moderate trim — keep last 10 messages
|
||||
if estimated <= threshold_90 {
|
||||
let keep = 10.min(messages.len());
|
||||
let remove = messages.len() - keep;
|
||||
let raw_remove = messages.len() - keep;
|
||||
// Adjust boundary to avoid splitting ToolUse/ToolResult pairs
|
||||
let remove = safe_drain_boundary(messages, raw_remove);
|
||||
if remove > 0 {
|
||||
debug!(
|
||||
estimated_tokens = estimated,
|
||||
removing = remove,
|
||||
"Stage 1: moderate trim to last {keep} messages"
|
||||
"Stage 1: moderate trim to last {} messages", messages.len() - remove
|
||||
);
|
||||
messages.drain(..remove);
|
||||
// Re-check after trim
|
||||
@@ -72,12 +149,14 @@ pub fn recover_from_overflow(
|
||||
// Stage 2: Aggressive trim — keep last 4 messages + summary marker
|
||||
{
|
||||
let keep = 4.min(messages.len());
|
||||
let remove = messages.len() - keep;
|
||||
let raw_remove = messages.len() - keep;
|
||||
// Adjust boundary to avoid splitting ToolUse/ToolResult pairs
|
||||
let remove = safe_drain_boundary(messages, raw_remove);
|
||||
if remove > 0 {
|
||||
warn!(
|
||||
estimated_tokens = estimate_tokens(messages, system_prompt, tools),
|
||||
removing = remove,
|
||||
"Stage 2: aggressive overflow compaction to last {keep} messages"
|
||||
"Stage 2: aggressive overflow compaction to last {} messages", messages.len() - remove
|
||||
);
|
||||
let summary = Message::user(format!(
|
||||
"[System: {} earlier messages were removed due to context overflow. \
|
||||
@@ -264,4 +343,60 @@ mod tests {
|
||||
// Must not panic — the truncation at byte boundaries could split a 3-byte char
|
||||
assert_ne!(stage, RecoveryStage::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_drain_boundary_pulls_back_for_tool_pair() {
|
||||
// Messages: [user, assistant(ToolUse), user(ToolResult), user]
|
||||
// If boundary = 2 (keep last 2), it splits between assistant(ToolUse) and
|
||||
// user(ToolResult). safe_drain_boundary should pull back to 1.
|
||||
let msgs = vec![
|
||||
Message::user("hello"),
|
||||
Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
|
||||
id: "t1".to_string(),
|
||||
name: "read".to_string(),
|
||||
input: serde_json::json!({}),
|
||||
provider_metadata: None,
|
||||
}]),
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t1".to_string(),
|
||||
tool_name: "read".to_string(),
|
||||
content: "file contents".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
},
|
||||
Message::user("thanks"),
|
||||
];
|
||||
// Boundary 2 would cut between the assistant(ToolUse) at [1] and user(ToolResult) at [2].
|
||||
let adjusted = safe_drain_boundary(&msgs, 2);
|
||||
assert_eq!(adjusted, 1, "Should pull boundary back to keep the ToolUse/ToolResult pair together");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_drain_boundary_no_change_for_text_messages() {
|
||||
let msgs = vec![
|
||||
Message::user("a"),
|
||||
Message::assistant("b"),
|
||||
Message::user("c"),
|
||||
Message::assistant("d"),
|
||||
];
|
||||
let adjusted = safe_drain_boundary(&msgs, 2);
|
||||
assert_eq!(adjusted, 2, "Should not change boundary for plain text messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_drain_boundary_edge_zero() {
|
||||
let msgs = vec![Message::user("a")];
|
||||
assert_eq!(safe_drain_boundary(&msgs, 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_drain_boundary_edge_end() {
|
||||
let msgs = vec![Message::user("a"), Message::assistant("b")];
|
||||
assert_eq!(safe_drain_boundary(&msgs, 2), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,6 @@ mod tests {
|
||||
fn test_constants() {
|
||||
assert!(GITHUB_DEVICE_CODE_URL.starts_with("https://"));
|
||||
assert!(GITHUB_TOKEN_URL.starts_with("https://"));
|
||||
assert!(!COPILOT_CLIENT_ID.is_empty());
|
||||
assert_ne!(COPILOT_CLIENT_ID, "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,9 +471,8 @@ impl LlmDriver for AnthropicDriver {
|
||||
input_json,
|
||||
}) = blocks.get(block_idx)
|
||||
{
|
||||
let input: serde_json::Value =
|
||||
serde_json::from_str(input_json)
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let input: serde_json::Value = serde_json::from_str(input_json)
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let _ = tx
|
||||
.send(StreamEvent::ToolUseEnd {
|
||||
id: id.clone(),
|
||||
@@ -521,8 +520,8 @@ impl LlmDriver for AnthropicDriver {
|
||||
name,
|
||||
input_json,
|
||||
} => {
|
||||
let input: serde_json::Value =
|
||||
serde_json::from_str(&input_json).unwrap_or_default();
|
||||
let input: serde_json::Value = serde_json::from_str(&input_json)
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
content.push(ContentBlock::ToolUse {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
@@ -553,6 +552,28 @@ impl LlmDriver for AnthropicDriver {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure a `serde_json::Value` is a JSON object (dictionary).
|
||||
///
|
||||
/// The Anthropic API requires `tool_use.input` to be a JSON object, never a
|
||||
/// string, null, or other scalar. This helper handles:
|
||||
/// - `Value::Object` → returned as-is
|
||||
/// - `Value::String` → attempt to parse as JSON; if the result is an object, use it
|
||||
/// - anything else (Null, Number, Bool, Array) → empty object `{}`
|
||||
fn ensure_object(v: &serde_json::Value) -> serde_json::Value {
|
||||
match v {
|
||||
serde_json::Value::Object(_) => v.clone(),
|
||||
serde_json::Value::String(s) => {
|
||||
// The input may have been double-serialized (stored as a JSON string).
|
||||
// Try to parse it back into a Value.
|
||||
match serde_json::from_str::<serde_json::Value>(s) {
|
||||
Ok(parsed) if parsed.is_object() => parsed,
|
||||
_ => serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
_ => serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an OpenFang Message to an Anthropic API message.
|
||||
fn convert_message(msg: &Message) -> ApiMessage {
|
||||
let role = match msg.role {
|
||||
@@ -582,7 +603,7 @@ fn convert_message(msg: &Message) -> ApiMessage {
|
||||
} => Some(ApiContentBlock::ToolUse {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
input: input.clone(),
|
||||
input: ensure_object(input),
|
||||
}),
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
@@ -692,4 +713,63 @@ mod tests {
|
||||
assert_eq!(response.tool_calls[0].name, "web_search");
|
||||
assert_eq!(response.usage.total(), 150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_object_from_object() {
|
||||
let obj = serde_json::json!({"key": "value"});
|
||||
assert_eq!(ensure_object(&obj), obj);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_object_from_string() {
|
||||
// Simulates double-serialized input (stored as JSON string)
|
||||
let stringified = serde_json::Value::String(r#"{"query": "rust"}"#.to_string());
|
||||
let result = ensure_object(&stringified);
|
||||
assert_eq!(result, serde_json::json!({"query": "rust"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_object_from_null() {
|
||||
let null = serde_json::Value::Null;
|
||||
assert_eq!(ensure_object(&null), serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_object_from_non_object_string() {
|
||||
// A string that parses to a non-object JSON value
|
||||
let s = serde_json::Value::String("42".to_string());
|
||||
assert_eq!(ensure_object(&s), serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_object_from_invalid_json_string() {
|
||||
let s = serde_json::Value::String("not json at all".to_string());
|
||||
assert_eq!(ensure_object(&s), serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_message_normalizes_tool_input() {
|
||||
// Simulate a ToolUse block with a stringified JSON input (legacy session data)
|
||||
let msg = Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
|
||||
id: "tu-1".to_string(),
|
||||
name: "web_search".to_string(),
|
||||
input: serde_json::Value::String(r#"{"query": "test"}"#.to_string()),
|
||||
provider_metadata: None,
|
||||
}]),
|
||||
};
|
||||
let api_msg = convert_message(&msg);
|
||||
if let ApiContent::Blocks(blocks) = api_msg.content {
|
||||
match &blocks[0] {
|
||||
ApiContentBlock::ToolUse { input, .. } => {
|
||||
assert!(input.is_object(), "input should be an object, got: {input}");
|
||||
assert_eq!(input["query"], "test");
|
||||
}
|
||||
_ => panic!("Expected ToolUse block"),
|
||||
}
|
||||
} else {
|
||||
panic!("Expected Blocks content");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,10 +133,6 @@ impl ClaudeCodeDriver {
|
||||
fn build_prompt(request: &CompletionRequest) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if let Some(ref sys) = request.system {
|
||||
parts.push(format!("[System]\n{sys}"));
|
||||
}
|
||||
|
||||
for msg in &request.messages {
|
||||
let role_label = match msg.role {
|
||||
Role::User => "User",
|
||||
@@ -192,8 +188,13 @@ impl ClaudeCodeDriver {
|
||||
///
|
||||
/// The CLI may return the response text in different fields depending on
|
||||
/// version: `result`, `content`, or `text`. We try all three.
|
||||
/// All fields use `#[serde(default)]` so deserialization never fails on
|
||||
/// missing keys — older and newer CLI versions differ in which fields are emitted.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ClaudeJsonOutput {
|
||||
// Fix: `result` now has #[serde(default)] so deserialization succeeds
|
||||
// even when the CLI emits the response in `content` or `text` instead.
|
||||
#[serde(default)]
|
||||
result: Option<String>,
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
@@ -215,15 +216,42 @@ struct ClaudeUsage {
|
||||
output_tokens: u64,
|
||||
}
|
||||
|
||||
/// Stream JSON event from `claude -p --output-format stream-json`.
|
||||
/// A single content block inside an `assistant` stream-json event.
|
||||
/// The CLI emits `{"type":"text","text":"..."}` blocks inside `message.content`.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct ClaudeMessageBlock {
|
||||
#[serde(default, rename = "type")]
|
||||
block_type: String,
|
||||
#[serde(default)]
|
||||
text: String,
|
||||
}
|
||||
|
||||
/// Nested `message` object carried by `type=assistant` stream-json events.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct ClaudeAssistantMessage {
|
||||
#[serde(default)]
|
||||
content: Vec<ClaudeMessageBlock>,
|
||||
}
|
||||
|
||||
/// Stream JSON event from `claude -p --output-format stream-json --verbose`.
|
||||
///
|
||||
/// Newer CLI versions (≥2.x) carry the response text inside the nested
|
||||
/// `message.content[].text` of `type=assistant` events rather than a
|
||||
/// flat `content` string. Both layouts are handled here so that real-time
|
||||
/// token streaming works across CLI versions.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ClaudeStreamEvent {
|
||||
#[serde(default)]
|
||||
r#type: String,
|
||||
/// Flat content string — used by older CLI versions and some event types.
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
/// Final result text carried by `type=result` events.
|
||||
#[serde(default)]
|
||||
result: Option<String>,
|
||||
/// Nested assistant message — used by newer CLI `type=assistant` events.
|
||||
#[serde(default)]
|
||||
message: Option<ClaudeAssistantMessage>,
|
||||
#[serde(default)]
|
||||
usage: Option<ClaudeUsage>,
|
||||
}
|
||||
@@ -240,6 +268,10 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
.arg("--output-format")
|
||||
.arg("json");
|
||||
|
||||
if let Some(ref sys) = request.system {
|
||||
cmd.arg("--system-prompt").arg(sys);
|
||||
}
|
||||
|
||||
if self.skip_permissions {
|
||||
cmd.arg("--dangerously-skip-permissions");
|
||||
}
|
||||
@@ -250,6 +282,13 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
|
||||
Self::apply_env_filter(&mut cmd);
|
||||
|
||||
// Inject HOME so the CLI can find its credentials (~/.claude/) when
|
||||
// OpenFang runs as a service without a login shell.
|
||||
if let Some(home) = home_dir() {
|
||||
cmd.env("HOME", &home);
|
||||
}
|
||||
// Detach stdin so the CLI does not block waiting for interactive input.
|
||||
cmd.stdin(std::process::Stdio::null());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
@@ -271,14 +310,36 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
debug!(pid = pid, model = %pid_label, "Claude Code CLI subprocess started");
|
||||
}
|
||||
|
||||
// Read stdout/stderr before waiting (take ownership of pipes)
|
||||
// Drain stdout and stderr concurrently while waiting for the process.
|
||||
// Sequential drain (wait → read) deadlocks when the subprocess writes
|
||||
// more than the OS pipe buffer (~64 KB): the child blocks on write,
|
||||
// child.wait() never returns, the timeout fires, and output is lost.
|
||||
let child_stdout = child.stdout.take();
|
||||
let child_stderr = child.stderr.take();
|
||||
|
||||
let stdout_task = tokio::spawn(async move {
|
||||
let mut buf = Vec::new();
|
||||
if let Some(mut out) = child_stdout {
|
||||
let _ = out.read_to_end(&mut buf).await;
|
||||
}
|
||||
buf
|
||||
});
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut buf = Vec::new();
|
||||
if let Some(mut err) = child_stderr {
|
||||
let _ = err.read_to_end(&mut buf).await;
|
||||
}
|
||||
buf
|
||||
});
|
||||
|
||||
// Wait with timeout
|
||||
let timeout_duration = std::time::Duration::from_secs(self.message_timeout_secs);
|
||||
let wait_result = tokio::time::timeout(timeout_duration, child.wait()).await;
|
||||
|
||||
// Collect pipe output — tasks complete once the process closes its end
|
||||
let stdout_bytes = stdout_task.await.unwrap_or_default();
|
||||
let stderr_bytes = stderr_task.await.unwrap_or_default();
|
||||
|
||||
// Clear PID tracking regardless of outcome
|
||||
self.active_pids.remove(&pid_label);
|
||||
|
||||
@@ -305,16 +366,6 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
}
|
||||
};
|
||||
|
||||
// Read captured output from pipes
|
||||
let mut stdout_bytes = Vec::new();
|
||||
let mut stderr_bytes = Vec::new();
|
||||
if let Some(mut out) = child_stdout {
|
||||
let _ = out.read_to_end(&mut stdout_bytes).await;
|
||||
}
|
||||
if let Some(mut err) = child_stderr {
|
||||
let _ = err.read_to_end(&mut stderr_bytes).await;
|
||||
};
|
||||
|
||||
if !status.success() {
|
||||
let stderr = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
|
||||
let stdout_str = String::from_utf8_lossy(&stdout_bytes).trim().to_string();
|
||||
@@ -413,6 +464,10 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
.arg("stream-json")
|
||||
.arg("--verbose");
|
||||
|
||||
if let Some(ref sys) = request.system {
|
||||
cmd.arg("--system-prompt").arg(sys);
|
||||
}
|
||||
|
||||
if self.skip_permissions {
|
||||
cmd.arg("--dangerously-skip-permissions");
|
||||
}
|
||||
@@ -423,6 +478,11 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
|
||||
Self::apply_env_filter(&mut cmd);
|
||||
|
||||
// Same HOME and stdin hygiene as the non-streaming path.
|
||||
if let Some(home) = home_dir() {
|
||||
cmd.env("HOME", &home);
|
||||
}
|
||||
cmd.stdin(std::process::Stdio::null());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
@@ -468,13 +528,28 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
Ok(event) => {
|
||||
match event.r#type.as_str() {
|
||||
"content" | "text" | "assistant" | "content_block_delta" => {
|
||||
if let Some(ref content) = event.content {
|
||||
full_text.push_str(content);
|
||||
let _ = tx
|
||||
.send(StreamEvent::TextDelta {
|
||||
text: content.clone(),
|
||||
})
|
||||
.await;
|
||||
// Older CLI: flat `content` string.
|
||||
// CLI ≥2.x (type=assistant): text is nested in
|
||||
// `message.content[].text`; the flat `content`
|
||||
// field is absent or null.
|
||||
let chunk = event.content.clone().unwrap_or_default();
|
||||
let nested: String = event
|
||||
.message
|
||||
.as_ref()
|
||||
.map(|msg| {
|
||||
msg.content
|
||||
.iter()
|
||||
.filter(|b| b.block_type == "text")
|
||||
.map(|b| b.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let text_chunk = if !chunk.is_empty() { chunk } else { nested };
|
||||
if !text_chunk.is_empty() {
|
||||
full_text.push_str(&text_chunk);
|
||||
let _ =
|
||||
tx.send(StreamEvent::TextDelta { text: text_chunk }).await;
|
||||
}
|
||||
}
|
||||
"result" | "done" | "complete" => {
|
||||
@@ -645,8 +720,8 @@ mod tests {
|
||||
};
|
||||
|
||||
let prompt = ClaudeCodeDriver::build_prompt(&request);
|
||||
assert!(prompt.contains("[System]"));
|
||||
assert!(prompt.contains("You are helpful."));
|
||||
assert!(!prompt.contains("[System]"));
|
||||
assert!(!prompt.contains("You are helpful."));
|
||||
assert!(prompt.contains("[User]"));
|
||||
assert!(prompt.contains("Hello"));
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ struct GeminiRequest {
|
||||
struct GeminiContent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
role: Option<String>,
|
||||
#[serde(default)]
|
||||
parts: Vec<GeminiPart>,
|
||||
}
|
||||
|
||||
@@ -336,9 +337,119 @@ fn convert_messages(
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize for Gemini's strict turn-ordering rules:
|
||||
// - A model turn with functionCall MUST be followed by a user turn with functionResponse
|
||||
// - No consecutive same-role turns
|
||||
let contents = sanitize_gemini_turns(contents);
|
||||
|
||||
(contents, system_instruction)
|
||||
}
|
||||
|
||||
/// Enforce Gemini's strict turn-ordering constraints.
|
||||
///
|
||||
/// Gemini requires: functionCall (model) → functionResponse (user) → model.
|
||||
/// After message trimming, this ordering can break. This function:
|
||||
/// 1. Merges consecutive same-role turns
|
||||
/// 2. Drops orphaned functionCall parts (no following functionResponse)
|
||||
/// 3. Drops orphaned functionResponse parts (no preceding functionCall)
|
||||
fn sanitize_gemini_turns(contents: Vec<GeminiContent>) -> Vec<GeminiContent> {
|
||||
if contents.is_empty() {
|
||||
return contents;
|
||||
}
|
||||
|
||||
// Step 1: Merge consecutive same-role turns
|
||||
let mut merged: Vec<GeminiContent> = Vec::with_capacity(contents.len());
|
||||
for entry in contents {
|
||||
if let Some(last) = merged.last_mut() {
|
||||
if last.role == entry.role {
|
||||
last.parts.extend(entry.parts);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
merged.push(entry);
|
||||
}
|
||||
|
||||
// Step 2: Drop orphaned functionCall parts from model turns.
|
||||
// A model turn with functionCall must be followed by a user turn with functionResponse.
|
||||
let len = merged.len();
|
||||
for i in 0..len {
|
||||
let is_model = merged[i].role.as_deref() == Some("model");
|
||||
if !is_model {
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_function_call = merged[i]
|
||||
.parts
|
||||
.iter()
|
||||
.any(|p| matches!(p, GeminiPart::FunctionCall { .. }));
|
||||
if !has_function_call {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if next turn is a user turn with functionResponse
|
||||
let next_has_response = i + 1 < len
|
||||
&& merged[i + 1].role.as_deref() == Some("user")
|
||||
&& merged[i + 1]
|
||||
.parts
|
||||
.iter()
|
||||
.any(|p| matches!(p, GeminiPart::FunctionResponse { .. }));
|
||||
|
||||
if !next_has_response {
|
||||
// Drop the functionCall parts from this model turn (keep text parts)
|
||||
merged[i]
|
||||
.parts
|
||||
.retain(|p| !matches!(p, GeminiPart::FunctionCall { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Drop orphaned functionResponse parts from user turns.
|
||||
// A user turn with functionResponse must be preceded by a model turn with functionCall.
|
||||
for i in 0..merged.len() {
|
||||
let is_user = merged[i].role.as_deref() == Some("user");
|
||||
if !is_user {
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_function_response = merged[i]
|
||||
.parts
|
||||
.iter()
|
||||
.any(|p| matches!(p, GeminiPart::FunctionResponse { .. }));
|
||||
if !has_function_response {
|
||||
continue;
|
||||
}
|
||||
|
||||
let prev_has_call = i > 0
|
||||
&& merged[i - 1].role.as_deref() == Some("model")
|
||||
&& merged[i - 1]
|
||||
.parts
|
||||
.iter()
|
||||
.any(|p| matches!(p, GeminiPart::FunctionCall { .. }));
|
||||
|
||||
if !prev_has_call {
|
||||
merged[i]
|
||||
.parts
|
||||
.retain(|p| !matches!(p, GeminiPart::FunctionResponse { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Remove turns that ended up empty after filtering
|
||||
merged.retain(|c| !c.parts.is_empty());
|
||||
|
||||
// Step 5: Final merge pass (removing parts may have created new consecutive same-role)
|
||||
let mut final_merged: Vec<GeminiContent> = Vec::with_capacity(merged.len());
|
||||
for entry in merged {
|
||||
if let Some(last) = final_merged.last_mut() {
|
||||
if last.role == entry.role {
|
||||
last.parts.extend(entry.parts);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
final_merged.push(entry);
|
||||
}
|
||||
|
||||
final_merged
|
||||
}
|
||||
|
||||
/// Extract system prompt from messages or the explicit system field.
|
||||
fn extract_system(messages: &[Message], system: &Option<String>) -> Option<GeminiContent> {
|
||||
let text = system.clone().or_else(|| {
|
||||
@@ -653,7 +764,20 @@ impl LlmDriver for GeminiDriver {
|
||||
return Err(LlmError::Api { status, message });
|
||||
}
|
||||
|
||||
// Parse SSE stream
|
||||
// Parse SSE stream — process line-by-line like the OpenAI driver.
|
||||
//
|
||||
// Gemini's `streamGenerateContent?alt=sse` endpoint sends
|
||||
// standard SSE: each event is a `data: {...}` line followed by
|
||||
// a blank line. The previous implementation looked for `\n\n`
|
||||
// as the event delimiter, but many HTTP responses use `\r\n`
|
||||
// line endings, so the actual delimiter is `\r\n\r\n` which
|
||||
// `find("\n\n")` never matches — causing the entire stream to
|
||||
// be silently buffered without extracting any events (zero
|
||||
// TextDelta emissions → empty response → infinite retry loop).
|
||||
//
|
||||
// Fix: process the buffer one line at a time (splitting on
|
||||
// `\n` and stripping trailing `\r`). A `data:` line is
|
||||
// parsed immediately. Empty/blank lines are simply skipped.
|
||||
let mut buffer = String::new();
|
||||
let mut text_content = String::new();
|
||||
// Thought signature for accumulated text content (last one wins)
|
||||
@@ -662,22 +786,32 @@ impl LlmDriver for GeminiDriver {
|
||||
let mut fn_calls: Vec<(String, serde_json::Value, Option<String>)> = Vec::new();
|
||||
let mut finish_reason: Option<String> = None;
|
||||
let mut usage = TokenUsage::default();
|
||||
let mut chunk_count: u32 = 0;
|
||||
let mut sse_line_count: u32 = 0;
|
||||
|
||||
let mut byte_stream = resp.bytes_stream();
|
||||
while let Some(chunk_result) = byte_stream.next().await {
|
||||
let chunk = chunk_result.map_err(|e| LlmError::Http(e.to_string()))?;
|
||||
chunk_count += 1;
|
||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
// Process complete SSE events (delimited by \n\n or \r\n\r\n)
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let event_text = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
// Process complete lines (handle both \r\n and \n endings)
|
||||
while let Some(pos) = buffer.find('\n') {
|
||||
let line = buffer[..pos].trim_end().to_string();
|
||||
buffer = buffer[pos + 1..].to_string();
|
||||
|
||||
// Extract the data line (handle both "data: " and "data:" formats)
|
||||
let data = event_text
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("data:").map(|d| d.trim_start()))
|
||||
.unwrap_or("");
|
||||
// Skip empty lines and SSE comments
|
||||
if line.is_empty() || line.starts_with(':') {
|
||||
continue;
|
||||
}
|
||||
|
||||
sse_line_count += 1;
|
||||
|
||||
// Extract the data payload (handle both "data: " and "data:" formats)
|
||||
let data = match line.strip_prefix("data:") {
|
||||
Some(d) => d.trim_start(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if data.is_empty() {
|
||||
continue;
|
||||
@@ -685,7 +819,14 @@ impl LlmDriver for GeminiDriver {
|
||||
|
||||
let json: GeminiResponse = match serde_json::from_str(data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
error = %e,
|
||||
data_preview = &data[..data.len().min(200)],
|
||||
"Failed to parse Gemini SSE data line"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract usage from each chunk (last one wins)
|
||||
@@ -768,6 +909,121 @@ impl LlmDriver for GeminiDriver {
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining data left in the buffer after the stream
|
||||
// ends (e.g. final chunk not terminated by a newline).
|
||||
let remaining = buffer.trim();
|
||||
if !remaining.is_empty() {
|
||||
if let Some(data) = remaining.strip_prefix("data:") {
|
||||
let data = data.trim();
|
||||
if !data.is_empty() {
|
||||
if let Ok(json) = serde_json::from_str::<GeminiResponse>(data) {
|
||||
if let Some(ref u) = json.usage_metadata {
|
||||
usage.input_tokens = u.prompt_token_count;
|
||||
usage.output_tokens = u.candidates_token_count;
|
||||
}
|
||||
for candidate in &json.candidates {
|
||||
if let Some(fr) = &candidate.finish_reason {
|
||||
finish_reason = Some(fr.clone());
|
||||
}
|
||||
if let Some(ref content) = candidate.content {
|
||||
for part in &content.parts {
|
||||
match part {
|
||||
GeminiPart::Text {
|
||||
text,
|
||||
thought_signature,
|
||||
} => {
|
||||
if !text.is_empty() {
|
||||
text_content.push_str(text);
|
||||
let _ = tx
|
||||
.send(StreamEvent::TextDelta {
|
||||
text: text.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
if thought_signature.is_some() {
|
||||
text_thought_sig = thought_signature.clone();
|
||||
}
|
||||
}
|
||||
GeminiPart::FunctionCall {
|
||||
function_call,
|
||||
thought_signature,
|
||||
} => {
|
||||
let id = format!(
|
||||
"call_{}",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
);
|
||||
let _ = tx
|
||||
.send(StreamEvent::ToolUseStart {
|
||||
id: id.clone(),
|
||||
name: function_call.name.clone(),
|
||||
})
|
||||
.await;
|
||||
let args_str =
|
||||
serde_json::to_string(&function_call.args)
|
||||
.unwrap_or_default();
|
||||
let _ = tx
|
||||
.send(StreamEvent::ToolInputDelta {
|
||||
text: args_str,
|
||||
})
|
||||
.await;
|
||||
let _ = tx
|
||||
.send(StreamEvent::ToolUseEnd {
|
||||
id,
|
||||
name: function_call.name.clone(),
|
||||
input: function_call.args.clone(),
|
||||
})
|
||||
.await;
|
||||
fn_calls.push((
|
||||
function_call.name.clone(),
|
||||
function_call.args.clone(),
|
||||
thought_signature.clone(),
|
||||
));
|
||||
}
|
||||
GeminiPart::Thought { ref text, .. } => {
|
||||
if !text.is_empty() {
|
||||
let _ = tx
|
||||
.send(StreamEvent::ThinkingDelta {
|
||||
text: text.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log stream summary for diagnostics (mirrors OpenAI driver)
|
||||
let is_empty_stream = text_content.is_empty()
|
||||
&& fn_calls.is_empty()
|
||||
&& usage.input_tokens == 0
|
||||
&& usage.output_tokens == 0;
|
||||
if is_empty_stream {
|
||||
warn!(
|
||||
chunks = chunk_count,
|
||||
sse_lines = sse_line_count,
|
||||
finish = ?finish_reason,
|
||||
buffer_remaining = buffer.len(),
|
||||
"Gemini SSE stream returned empty: 0 content, 0 tokens — likely a silently failed request"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
chunks = chunk_count,
|
||||
sse_lines = sse_line_count,
|
||||
text_len = text_content.len(),
|
||||
tool_count = fn_calls.len(),
|
||||
finish = ?finish_reason,
|
||||
input_tokens = usage.input_tokens,
|
||||
output_tokens = usage.output_tokens,
|
||||
"Gemini SSE stream completed"
|
||||
);
|
||||
}
|
||||
|
||||
// Build final response
|
||||
let mut content = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
@@ -1351,6 +1607,15 @@ mod tests {
|
||||
provider_metadata: None,
|
||||
}]),
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "call_456".to_string(),
|
||||
tool_name: "read_file".to_string(),
|
||||
content: "file contents".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
},
|
||||
];
|
||||
|
||||
let (contents, _) = convert_messages(&messages, &None);
|
||||
@@ -1581,6 +1846,12 @@ mod tests {
|
||||
_ => panic!("Expected ToolUse"),
|
||||
}
|
||||
|
||||
// Extract the generated tool_use_id for the ToolResult
|
||||
let tool_use_id = match &completion.content[1] {
|
||||
ContentBlock::ToolUse { id, .. } => id.clone(),
|
||||
_ => panic!("Expected ToolUse"),
|
||||
};
|
||||
|
||||
// Now convert back to Gemini format and verify signatures are echoed
|
||||
let messages = vec![
|
||||
Message::user("Search for rust"),
|
||||
@@ -1588,6 +1859,15 @@ mod tests {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(completion.content),
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name: "web_search".to_string(),
|
||||
content: "search results".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
},
|
||||
];
|
||||
let (contents, _) = convert_messages(&messages, &None);
|
||||
let model_turn = &contents[1];
|
||||
@@ -1720,4 +2000,141 @@ mod tests {
|
||||
_ => panic!("Expected Text block"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── sanitize_gemini_turns tests ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_drops_orphaned_function_call() {
|
||||
// A model turn with functionCall but no following functionResponse
|
||||
// should have the functionCall stripped.
|
||||
let contents = vec![
|
||||
GeminiContent {
|
||||
role: Some("user".to_string()),
|
||||
parts: vec![GeminiPart::Text {
|
||||
text: "Hello".to_string(),
|
||||
thought_signature: None,
|
||||
}],
|
||||
},
|
||||
GeminiContent {
|
||||
role: Some("model".to_string()),
|
||||
parts: vec![
|
||||
GeminiPart::Text {
|
||||
text: "I'll search.".to_string(),
|
||||
thought_signature: None,
|
||||
},
|
||||
GeminiPart::FunctionCall {
|
||||
function_call: GeminiFunctionCallData {
|
||||
name: "web_search".to_string(),
|
||||
args: serde_json::json!({"q": "rust"}),
|
||||
},
|
||||
thought_signature: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
let sanitized = sanitize_gemini_turns(contents);
|
||||
assert_eq!(sanitized.len(), 2);
|
||||
// functionCall should be stripped, text kept
|
||||
assert_eq!(sanitized[1].parts.len(), 1);
|
||||
assert!(matches!(&sanitized[1].parts[0], GeminiPart::Text { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_keeps_valid_function_call_response_pair() {
|
||||
let contents = vec![
|
||||
GeminiContent {
|
||||
role: Some("user".to_string()),
|
||||
parts: vec![GeminiPart::Text {
|
||||
text: "Search".to_string(),
|
||||
thought_signature: None,
|
||||
}],
|
||||
},
|
||||
GeminiContent {
|
||||
role: Some("model".to_string()),
|
||||
parts: vec![GeminiPart::FunctionCall {
|
||||
function_call: GeminiFunctionCallData {
|
||||
name: "web_search".to_string(),
|
||||
args: serde_json::json!({"q": "rust"}),
|
||||
},
|
||||
thought_signature: None,
|
||||
}],
|
||||
},
|
||||
GeminiContent {
|
||||
role: Some("user".to_string()),
|
||||
parts: vec![GeminiPart::FunctionResponse {
|
||||
function_response: GeminiFunctionResponseData {
|
||||
name: "web_search".to_string(),
|
||||
response: serde_json::json!({"result": "Rust is great"}),
|
||||
},
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
let sanitized = sanitize_gemini_turns(contents);
|
||||
assert_eq!(sanitized.len(), 3);
|
||||
// functionCall should be preserved
|
||||
assert!(matches!(
|
||||
&sanitized[1].parts[0],
|
||||
GeminiPart::FunctionCall { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_drops_orphaned_function_response() {
|
||||
// A user turn with functionResponse but no preceding functionCall
|
||||
let contents = vec![
|
||||
GeminiContent {
|
||||
role: Some("user".to_string()),
|
||||
parts: vec![GeminiPart::FunctionResponse {
|
||||
function_response: GeminiFunctionResponseData {
|
||||
name: "web_search".to_string(),
|
||||
response: serde_json::json!({"result": "data"}),
|
||||
},
|
||||
}],
|
||||
},
|
||||
GeminiContent {
|
||||
role: Some("model".to_string()),
|
||||
parts: vec![GeminiPart::Text {
|
||||
text: "Done.".to_string(),
|
||||
thought_signature: None,
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
let sanitized = sanitize_gemini_turns(contents);
|
||||
// Orphaned functionResponse removed, empty user turn removed
|
||||
assert_eq!(sanitized.len(), 1);
|
||||
assert_eq!(sanitized[0].role.as_deref(), Some("model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_merges_consecutive_same_role() {
|
||||
let contents = vec![
|
||||
GeminiContent {
|
||||
role: Some("model".to_string()),
|
||||
parts: vec![GeminiPart::Text {
|
||||
text: "First.".to_string(),
|
||||
thought_signature: None,
|
||||
}],
|
||||
},
|
||||
GeminiContent {
|
||||
role: Some("model".to_string()),
|
||||
parts: vec![GeminiPart::Text {
|
||||
text: "Second.".to_string(),
|
||||
thought_signature: None,
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
let sanitized = sanitize_gemini_turns(contents);
|
||||
assert_eq!(sanitized.len(), 1);
|
||||
assert_eq!(sanitized[0].parts.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_empty_input() {
|
||||
let sanitized = sanitize_gemini_turns(vec![]);
|
||||
assert!(sanitized.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod fallback;
|
||||
pub mod gemini;
|
||||
pub mod openai;
|
||||
pub mod qwen_code;
|
||||
pub mod vertex;
|
||||
|
||||
use crate::llm_driver::{DriverConfig, LlmDriver, LlmError};
|
||||
use openfang_types::model_catalog::{
|
||||
@@ -226,6 +227,12 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
api_key_env: "AZURE_OPENAI_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"vertex-ai" | "vertex" | "google-vertex" => Some(ProviderDefaults {
|
||||
// Vertex AI uses OAuth, not API keys - base_url is per-project
|
||||
base_url: "https://us-central1-aiplatform.googleapis.com",
|
||||
api_key_env: "GOOGLE_APPLICATION_CREDENTIALS",
|
||||
key_required: false, // Uses OAuth service account, not API key
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -370,6 +377,39 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
|
||||
return Ok(Arc::new(openai::OpenAIDriver::new_azure(api_key, base_url)));
|
||||
}
|
||||
|
||||
// Vertex AI — uses Google Cloud OAuth with service account credentials.
|
||||
// Requires GOOGLE_APPLICATION_CREDENTIALS env var pointing to service account JSON,
|
||||
// and the service account must be activated via gcloud CLI.
|
||||
if provider == "vertex-ai" || provider == "vertex" || provider == "google-vertex" {
|
||||
// Get project_id from environment or service account JSON
|
||||
let project_id = std::env::var("GOOGLE_CLOUD_PROJECT")
|
||||
.or_else(|_| std::env::var("GCLOUD_PROJECT"))
|
||||
.or_else(|_| std::env::var("GCP_PROJECT"))
|
||||
.or_else(|_| {
|
||||
// Try to read from service account JSON
|
||||
if let Ok(creds_path) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") {
|
||||
if let Ok(contents) = std::fs::read_to_string(&creds_path) {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) {
|
||||
if let Some(proj) = json.get("project_id").and_then(|v| v.as_str()) {
|
||||
return Ok(proj.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(std::env::VarError::NotPresent)
|
||||
})
|
||||
.map_err(|_| {
|
||||
LlmError::MissingApiKey(
|
||||
"Set GOOGLE_APPLICATION_CREDENTIALS or GOOGLE_CLOUD_PROJECT for Vertex AI"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let region = std::env::var("GOOGLE_CLOUD_REGION")
|
||||
.or_else(|_| std::env::var("VERTEX_AI_REGION"))
|
||||
.unwrap_or_else(|_| "us-central1".to_string());
|
||||
return Ok(Arc::new(vertex::VertexAIDriver::new(project_id, region)));
|
||||
}
|
||||
|
||||
// Kimi for Code — Anthropic-compatible endpoint
|
||||
if provider == "kimi_coding" {
|
||||
let api_key = config
|
||||
@@ -791,9 +831,7 @@ mod tests {
|
||||
let config = DriverConfig {
|
||||
provider: "azure".to_string(),
|
||||
api_key: Some("test-azure-key".to_string()),
|
||||
base_url: Some(
|
||||
"https://myresource.openai.azure.com/openai/deployments".to_string(),
|
||||
),
|
||||
base_url: Some("https://myresource.openai.azure.com/openai/deployments".to_string()),
|
||||
skip_permissions: true,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
@@ -805,9 +843,7 @@ mod tests {
|
||||
let config = DriverConfig {
|
||||
provider: "azure".to_string(),
|
||||
api_key: None,
|
||||
base_url: Some(
|
||||
"https://myresource.openai.azure.com/openai/deployments".to_string(),
|
||||
),
|
||||
base_url: Some("https://myresource.openai.azure.com/openai/deployments".to_string()),
|
||||
skip_permissions: true,
|
||||
};
|
||||
let result = create_driver(&config);
|
||||
@@ -843,9 +879,7 @@ mod tests {
|
||||
let config = DriverConfig {
|
||||
provider: "azure-openai".to_string(),
|
||||
api_key: Some("test-azure-key".to_string()),
|
||||
base_url: Some(
|
||||
"https://myresource.openai.azure.com/openai/deployments".to_string(),
|
||||
),
|
||||
base_url: Some("https://myresource.openai.azure.com/openai/deployments".to_string()),
|
||||
skip_permissions: true,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
|
||||
@@ -99,8 +99,7 @@ impl OpenAIDriver {
|
||||
if self.azure_mode {
|
||||
builder = builder.header("api-key", self.api_key.as_str());
|
||||
} else {
|
||||
builder =
|
||||
builder.header("authorization", format!("Bearer {}", self.api_key.as_str()));
|
||||
builder = builder.header("authorization", format!("Bearer {}", self.api_key.as_str()));
|
||||
}
|
||||
builder
|
||||
}
|
||||
@@ -682,8 +681,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
|
||||
if let Some(calls) = choice.message.tool_calls {
|
||||
for call in calls {
|
||||
let input: serde_json::Value =
|
||||
serde_json::from_str(&call.function.arguments).unwrap_or_default();
|
||||
let input: serde_json::Value = serde_json::from_str(&call.function.arguments)
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
content.push(ContentBlock::ToolUse {
|
||||
id: call.id.clone(),
|
||||
name: call.function.name.clone(),
|
||||
@@ -1150,7 +1149,10 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
|
||||
// Reasoning/thinking content delta (DeepSeek-R1, Qwen3 via LM Studio/Ollama)
|
||||
if let Some(reasoning) = delta["reasoning_content"].as_str() {
|
||||
if let Some(reasoning) = delta["reasoning_content"]
|
||||
.as_str()
|
||||
.or_else(|| delta["reasoning"].as_str())
|
||||
{
|
||||
if !reasoning.is_empty() {
|
||||
reasoning_content.push_str(reasoning);
|
||||
let _ = tx
|
||||
@@ -1173,7 +1175,10 @@ impl LlmDriver for OpenAIDriver {
|
||||
|
||||
// ID (sent in first chunk for this tool)
|
||||
if let Some(id) = call["id"].as_str() {
|
||||
tool_accum[idx].0 = id.to_string();
|
||||
// Fix: Empty string IDs are overwritten, leading to inconsistencies in certain models.
|
||||
if !id.is_empty() {
|
||||
tool_accum[idx].0 = id.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(func) = call.get("function") {
|
||||
@@ -1312,7 +1317,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
|
||||
for (id, name, arguments) in &tool_accum {
|
||||
let input: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
|
||||
let input: serde_json::Value =
|
||||
serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
|
||||
content.push(ContentBlock::ToolUse {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
@@ -1322,14 +1328,14 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls.push(ToolCall {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
input,
|
||||
input: input.clone(),
|
||||
});
|
||||
|
||||
let _ = tx
|
||||
.send(StreamEvent::ToolUseEnd {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
input: serde_json::from_str(arguments).unwrap_or_default(),
|
||||
input,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
//! Google Vertex AI driver with OAuth authentication.
|
||||
//!
|
||||
//! Uses service account credentials (`GOOGLE_APPLICATION_CREDENTIALS`) to
|
||||
//! authenticate with Vertex AI's Gemini models via OAuth 2.0 bearer tokens.
|
||||
//! This enables enterprise deployments without requiring consumer API keys.
|
||||
//!
|
||||
//! # Endpoint Format
|
||||
//!
|
||||
//! ```text
|
||||
//! https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/publishers/google/models/{model}:generateContent
|
||||
//! ```
|
||||
//!
|
||||
//! # Authentication
|
||||
//!
|
||||
//! Uses OAuth 2.0 bearer tokens obtained via `gcloud auth print-access-token`.
|
||||
//! Tokens are cached for 50 minutes and automatically refreshed.
|
||||
//!
|
||||
//! # Environment Variables
|
||||
//!
|
||||
//! - `GOOGLE_APPLICATION_CREDENTIALS` — Path to service account JSON
|
||||
//! - `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT` / `GCP_PROJECT` — Project ID (optional if in credentials)
|
||||
//! - `GOOGLE_CLOUD_REGION` / `VERTEX_AI_REGION` — Region (default: `us-central1`)
|
||||
//! - `VERTEX_AI_ACCESS_TOKEN` — Pre-generated token (optional, for testing)
|
||||
|
||||
use crate::llm_driver::{CompletionRequest, CompletionResponse, LlmDriver, LlmError, StreamEvent};
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use openfang_types::message::{
|
||||
ContentBlock, Message, MessageContent, Role, StopReason, TokenUsage,
|
||||
};
|
||||
use openfang_types::tool::ToolCall;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Vertex AI driver with OAuth authentication.
|
||||
///
|
||||
/// Authenticates using GCP service account credentials and OAuth 2.0 bearer tokens.
|
||||
/// Tokens are cached with automatic refresh before expiry.
|
||||
pub struct VertexAIDriver {
|
||||
project_id: String,
|
||||
region: String,
|
||||
/// Cached OAuth access token (zeroized on drop for security).
|
||||
token_cache: Arc<RwLock<TokenCache>>,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
/// Cached OAuth token with expiry tracking.
|
||||
///
|
||||
/// SECURITY: Token is wrapped in `Zeroizing` to clear memory on drop.
|
||||
struct TokenCache {
|
||||
token: Option<Zeroizing<String>>,
|
||||
expires_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl TokenCache {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
token: None,
|
||||
expires_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid(&self) -> bool {
|
||||
match (&self.token, &self.expires_at) {
|
||||
(Some(_), Some(expires)) => Instant::now() < *expires,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self) -> Option<String> {
|
||||
if self.is_valid() {
|
||||
self.token.as_ref().map(|t| t.as_str().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VertexAIDriver {
|
||||
/// Create a new Vertex AI driver.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `project_id` - GCP project ID
|
||||
/// * `region` - GCP region (e.g., `us-central1`)
|
||||
pub fn new(project_id: String, region: String) -> Self {
|
||||
Self {
|
||||
project_id,
|
||||
region,
|
||||
token_cache: Arc::new(RwLock::new(TokenCache::new())),
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a valid OAuth access token, refreshing if needed.
|
||||
async fn get_access_token(&self) -> Result<String, LlmError> {
|
||||
// Check cache first
|
||||
{
|
||||
let cache = self.token_cache.read().await;
|
||||
if let Some(token) = cache.get() {
|
||||
debug!("Using cached Vertex AI access token");
|
||||
return Ok(token);
|
||||
}
|
||||
}
|
||||
|
||||
// Need to refresh token
|
||||
info!("Refreshing Vertex AI OAuth access token");
|
||||
let token = self.fetch_access_token().await?;
|
||||
|
||||
// Cache the token (expires in ~1 hour, we refresh at 50 min)
|
||||
{
|
||||
let mut cache = self.token_cache.write().await;
|
||||
cache.token = Some(Zeroizing::new(token.clone()));
|
||||
cache.expires_at = Some(Instant::now() + Duration::from_secs(50 * 60));
|
||||
}
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Fetch a new access token using gcloud CLI.
|
||||
///
|
||||
/// This uses the service account specified in GOOGLE_APPLICATION_CREDENTIALS
|
||||
/// via the gcloud CLI. For production, this should use the google-auth library.
|
||||
async fn fetch_access_token(&self) -> Result<String, LlmError> {
|
||||
// First, check if a pre-generated token is available in env
|
||||
if let Ok(token) = std::env::var("VERTEX_AI_ACCESS_TOKEN") {
|
||||
if !token.is_empty() {
|
||||
debug!("Using pre-set VERTEX_AI_ACCESS_TOKEN");
|
||||
return Ok(token);
|
||||
}
|
||||
}
|
||||
|
||||
// Try application-default credentials first (uses GOOGLE_APPLICATION_CREDENTIALS)
|
||||
let output = tokio::process::Command::new("gcloud")
|
||||
.args(["auth", "application-default", "print-access-token"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
if let Ok(output) = output {
|
||||
if output.status.success() {
|
||||
let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !token.is_empty() {
|
||||
debug!("Successfully obtained Vertex AI access token via application-default");
|
||||
return Ok(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to regular gcloud auth (requires activated service account)
|
||||
let output = tokio::process::Command::new("gcloud")
|
||||
.args(["auth", "print-access-token"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| LlmError::MissingApiKey(format!("Failed to run gcloud: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(LlmError::MissingApiKey(format!(
|
||||
"gcloud auth failed: {}. Ensure GOOGLE_APPLICATION_CREDENTIALS is set and \
|
||||
run: gcloud auth activate-service-account --key-file=$GOOGLE_APPLICATION_CREDENTIALS",
|
||||
stderr.trim()
|
||||
)));
|
||||
}
|
||||
|
||||
let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if token.is_empty() {
|
||||
return Err(LlmError::MissingApiKey(
|
||||
"Empty access token from gcloud".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
debug!("Successfully obtained Vertex AI access token");
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Build the Vertex AI endpoint URL for a model.
|
||||
fn build_endpoint(&self, model: &str, streaming: bool) -> String {
|
||||
// Strip any "gemini-" prefix duplications
|
||||
let model_name = model.strip_prefix("models/").unwrap_or(model);
|
||||
|
||||
let method = if streaming {
|
||||
"streamGenerateContent"
|
||||
} else {
|
||||
"generateContent"
|
||||
};
|
||||
|
||||
format!(
|
||||
"https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/publishers/google/models/{model}:{method}",
|
||||
region = self.region,
|
||||
project = self.project_id,
|
||||
model = model_name,
|
||||
method = method
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request types (reusing Gemini format) ──────────────────────────────
|
||||
|
||||
/// Top-level Gemini/Vertex API request body.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VertexRequest {
|
||||
contents: Vec<VertexContent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
system_instruction: Option<VertexContent>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
tools: Vec<VertexToolConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
generation_config: Option<GenerationConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct VertexContent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
role: Option<String>,
|
||||
parts: Vec<VertexPart>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
enum VertexPart {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
InlineData {
|
||||
#[serde(rename = "inlineData")]
|
||||
inline_data: VertexInlineData,
|
||||
},
|
||||
FunctionCall {
|
||||
#[serde(rename = "functionCall")]
|
||||
function_call: VertexFunctionCallData,
|
||||
},
|
||||
FunctionResponse {
|
||||
#[serde(rename = "functionResponse")]
|
||||
function_response: VertexFunctionResponseData,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct VertexInlineData {
|
||||
#[serde(rename = "mimeType")]
|
||||
mime_type: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct VertexFunctionCallData {
|
||||
name: String,
|
||||
args: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct VertexFunctionResponseData {
|
||||
name: String,
|
||||
response: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VertexToolConfig {
|
||||
function_declarations: Vec<VertexFunctionDeclaration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct VertexFunctionDeclaration {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GenerationConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_output_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
// ── Response types ─────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VertexResponse {
|
||||
#[serde(default)]
|
||||
candidates: Vec<VertexCandidate>,
|
||||
#[serde(default)]
|
||||
usage_metadata: Option<VertexUsageMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VertexCandidate {
|
||||
content: Option<VertexContent>,
|
||||
#[serde(default)]
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VertexUsageMetadata {
|
||||
#[serde(default)]
|
||||
prompt_token_count: u64,
|
||||
#[serde(default)]
|
||||
candidates_token_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VertexErrorResponse {
|
||||
error: VertexErrorDetail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VertexErrorDetail {
|
||||
message: String,
|
||||
}
|
||||
|
||||
// ── Message conversion ─────────────────────────────────────────────────
|
||||
|
||||
fn convert_messages(
|
||||
messages: &[Message],
|
||||
system: &Option<String>,
|
||||
) -> (Vec<VertexContent>, Option<VertexContent>) {
|
||||
let mut contents = Vec::new();
|
||||
|
||||
let system_instruction = extract_system(messages, system);
|
||||
|
||||
for msg in messages {
|
||||
if msg.role == Role::System {
|
||||
continue;
|
||||
}
|
||||
|
||||
let role = match msg.role {
|
||||
Role::User => "user",
|
||||
Role::Assistant => "model",
|
||||
Role::System => continue,
|
||||
};
|
||||
|
||||
let parts = match &msg.content {
|
||||
MessageContent::Text(text) => vec![VertexPart::Text { text: text.clone() }],
|
||||
MessageContent::Blocks(blocks) => {
|
||||
let mut parts = Vec::new();
|
||||
for block in blocks {
|
||||
match block {
|
||||
ContentBlock::Text { text, .. } => {
|
||||
parts.push(VertexPart::Text { text: text.clone() });
|
||||
}
|
||||
ContentBlock::ToolUse { name, input, .. } => {
|
||||
parts.push(VertexPart::FunctionCall {
|
||||
function_call: VertexFunctionCallData {
|
||||
name: name.clone(),
|
||||
args: input.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
ContentBlock::Image { media_type, data } => {
|
||||
parts.push(VertexPart::InlineData {
|
||||
inline_data: VertexInlineData {
|
||||
mime_type: media_type.clone(),
|
||||
data: data.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
ContentBlock::ToolResult { content, .. } => {
|
||||
parts.push(VertexPart::FunctionResponse {
|
||||
function_response: VertexFunctionResponseData {
|
||||
name: String::new(),
|
||||
response: serde_json::json!({ "result": content }),
|
||||
},
|
||||
});
|
||||
}
|
||||
ContentBlock::Thinking { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
};
|
||||
|
||||
if !parts.is_empty() {
|
||||
contents.push(VertexContent {
|
||||
role: Some(role.to_string()),
|
||||
parts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
(contents, system_instruction)
|
||||
}
|
||||
|
||||
fn extract_system(messages: &[Message], system: &Option<String>) -> Option<VertexContent> {
|
||||
let text = system.clone().or_else(|| {
|
||||
messages.iter().find_map(|m| {
|
||||
if m.role == Role::System {
|
||||
match &m.content {
|
||||
MessageContent::Text(t) => Some(t.clone()),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})?;
|
||||
|
||||
Some(VertexContent {
|
||||
role: None,
|
||||
parts: vec![VertexPart::Text { text }],
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_tools(request: &CompletionRequest) -> Vec<VertexToolConfig> {
|
||||
if request.tools.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let declarations: Vec<VertexFunctionDeclaration> = request
|
||||
.tools
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let normalized =
|
||||
openfang_types::tool::normalize_schema_for_provider(&t.input_schema, "gemini");
|
||||
VertexFunctionDeclaration {
|
||||
name: t.name.clone(),
|
||||
description: t.description.clone(),
|
||||
parameters: normalized,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
vec![VertexToolConfig {
|
||||
function_declarations: declarations,
|
||||
}]
|
||||
}
|
||||
|
||||
fn convert_response(resp: VertexResponse) -> Result<CompletionResponse, LlmError> {
|
||||
let candidate = resp
|
||||
.candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| LlmError::Parse("No candidates in Vertex AI response".to_string()))?;
|
||||
|
||||
let mut content = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
|
||||
if let Some(vertex_content) = candidate.content {
|
||||
for part in vertex_content.parts {
|
||||
match part {
|
||||
VertexPart::Text { text } => {
|
||||
content.push(ContentBlock::Text {
|
||||
text,
|
||||
provider_metadata: None,
|
||||
});
|
||||
}
|
||||
VertexPart::FunctionCall { function_call } => {
|
||||
tool_calls.push(ToolCall {
|
||||
id: format!("call_{}", &uuid::Uuid::new_v4().to_string()[..8]),
|
||||
name: function_call.name,
|
||||
input: function_call.args,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stop_reason = match candidate.finish_reason.as_deref() {
|
||||
Some("STOP") => StopReason::EndTurn,
|
||||
Some("MAX_TOKENS") => StopReason::MaxTokens,
|
||||
Some("SAFETY") | Some("RECITATION") | Some("BLOCKLIST") => StopReason::EndTurn,
|
||||
_ if !tool_calls.is_empty() => StopReason::ToolUse,
|
||||
_ => StopReason::EndTurn,
|
||||
};
|
||||
|
||||
let usage = resp
|
||||
.usage_metadata
|
||||
.map(|u| TokenUsage {
|
||||
input_tokens: u.prompt_token_count,
|
||||
output_tokens: u.candidates_token_count,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(CompletionResponse {
|
||||
content,
|
||||
stop_reason,
|
||||
tool_calls,
|
||||
usage,
|
||||
})
|
||||
}
|
||||
|
||||
// ── LlmDriver implementation ──────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for VertexAIDriver {
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
let (contents, system_instruction) = convert_messages(&request.messages, &request.system);
|
||||
let tools = convert_tools(&request);
|
||||
|
||||
let vertex_request = VertexRequest {
|
||||
contents,
|
||||
system_instruction,
|
||||
tools,
|
||||
generation_config: Some(GenerationConfig {
|
||||
temperature: Some(request.temperature),
|
||||
max_output_tokens: Some(request.max_tokens),
|
||||
}),
|
||||
};
|
||||
|
||||
let access_token = self.get_access_token().await?;
|
||||
|
||||
let max_retries = 3;
|
||||
for attempt in 0..=max_retries {
|
||||
let url = self.build_endpoint(&request.model, false);
|
||||
debug!(url = %url, attempt, "Sending Vertex AI request");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", access_token))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&vertex_request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::Http(e.to_string()))?;
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
|
||||
if status == 429 || status == 503 {
|
||||
if attempt < max_retries {
|
||||
let retry_ms = (attempt + 1) as u64 * 2000;
|
||||
warn!(status, retry_ms, "Rate limited/overloaded, retrying");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(retry_ms)).await;
|
||||
continue;
|
||||
}
|
||||
return Err(if status == 429 {
|
||||
LlmError::RateLimited {
|
||||
retry_after_ms: 5000,
|
||||
}
|
||||
} else {
|
||||
LlmError::Overloaded {
|
||||
retry_after_ms: 5000,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let message = serde_json::from_str::<VertexErrorResponse>(&body)
|
||||
.map(|e| e.error.message)
|
||||
.unwrap_or(body);
|
||||
return Err(LlmError::Api { status, message });
|
||||
}
|
||||
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| LlmError::Http(e.to_string()))?;
|
||||
let vertex_response: VertexResponse =
|
||||
serde_json::from_str(&body).map_err(|e| LlmError::Parse(e.to_string()))?;
|
||||
|
||||
return convert_response(vertex_response);
|
||||
}
|
||||
|
||||
Err(LlmError::Api {
|
||||
status: 0,
|
||||
message: "Max retries exceeded".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
request: CompletionRequest,
|
||||
tx: tokio::sync::mpsc::Sender<StreamEvent>,
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
let (contents, system_instruction) = convert_messages(&request.messages, &request.system);
|
||||
let tools = convert_tools(&request);
|
||||
|
||||
let vertex_request = VertexRequest {
|
||||
contents,
|
||||
system_instruction,
|
||||
tools,
|
||||
generation_config: Some(GenerationConfig {
|
||||
temperature: Some(request.temperature),
|
||||
max_output_tokens: Some(request.max_tokens),
|
||||
}),
|
||||
};
|
||||
|
||||
let access_token = self.get_access_token().await?;
|
||||
|
||||
let max_retries = 3;
|
||||
for attempt in 0..=max_retries {
|
||||
let url = format!("{}?alt=sse", self.build_endpoint(&request.model, true));
|
||||
debug!(url = %url, attempt, "Sending Vertex AI streaming request");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", access_token))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&vertex_request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::Http(e.to_string()))?;
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
|
||||
if status == 429 || status == 503 {
|
||||
if attempt < max_retries {
|
||||
let retry_ms = (attempt + 1) as u64 * 2000;
|
||||
warn!(
|
||||
status,
|
||||
retry_ms, "Rate limited/overloaded (stream), retrying"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(retry_ms)).await;
|
||||
continue;
|
||||
}
|
||||
return Err(if status == 429 {
|
||||
LlmError::RateLimited {
|
||||
retry_after_ms: 5000,
|
||||
}
|
||||
} else {
|
||||
LlmError::Overloaded {
|
||||
retry_after_ms: 5000,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let message = serde_json::from_str::<VertexErrorResponse>(&body)
|
||||
.map(|e| e.error.message)
|
||||
.unwrap_or(body);
|
||||
return Err(LlmError::Api { status, message });
|
||||
}
|
||||
|
||||
// Process SSE stream
|
||||
let mut byte_stream = resp.bytes_stream();
|
||||
let mut buffer = String::new();
|
||||
let mut accumulated_text = String::new();
|
||||
let mut final_tool_calls = Vec::new();
|
||||
let mut final_usage = None;
|
||||
|
||||
while let Some(chunk_result) = byte_stream.next().await {
|
||||
let chunk = chunk_result.map_err(|e| LlmError::Http(e.to_string()))?;
|
||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
// Process complete lines
|
||||
while let Some(line_end) = buffer.find('\n') {
|
||||
let line = buffer[..line_end].trim().to_string();
|
||||
buffer = buffer[line_end + 1..].to_string();
|
||||
|
||||
if line.is_empty() || !line.starts_with("data: ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let json_str = &line[6..];
|
||||
if json_str == "[DONE]" {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Ok(resp) = serde_json::from_str::<VertexResponse>(json_str) {
|
||||
if let Some(candidate) = resp.candidates.into_iter().next() {
|
||||
if let Some(content) = candidate.content {
|
||||
for part in content.parts {
|
||||
match part {
|
||||
VertexPart::Text { text } => {
|
||||
accumulated_text.push_str(&text);
|
||||
let _ = tx.send(StreamEvent::TextDelta { text }).await;
|
||||
}
|
||||
VertexPart::FunctionCall { function_call } => {
|
||||
final_tool_calls.push(ToolCall {
|
||||
id: format!(
|
||||
"call_{}",
|
||||
&uuid::Uuid::new_v4().to_string()[..8]
|
||||
),
|
||||
name: function_call.name,
|
||||
input: function_call.args,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(usage) = resp.usage_metadata {
|
||||
final_usage = Some(TokenUsage {
|
||||
input_tokens: usage.prompt_token_count,
|
||||
output_tokens: usage.candidates_token_count,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stop_reason = if !final_tool_calls.is_empty() {
|
||||
StopReason::ToolUse
|
||||
} else {
|
||||
StopReason::EndTurn
|
||||
};
|
||||
|
||||
let usage = final_usage.unwrap_or_default();
|
||||
|
||||
let _ = tx
|
||||
.send(StreamEvent::ContentComplete { stop_reason, usage })
|
||||
.await;
|
||||
|
||||
let content = if accumulated_text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![ContentBlock::Text {
|
||||
text: accumulated_text,
|
||||
provider_metadata: None,
|
||||
}]
|
||||
};
|
||||
|
||||
return Ok(CompletionResponse {
|
||||
content,
|
||||
stop_reason,
|
||||
tool_calls: final_tool_calls,
|
||||
usage,
|
||||
});
|
||||
}
|
||||
|
||||
Err(LlmError::Api {
|
||||
status: 0,
|
||||
message: "Max retries exceeded".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_vertex_driver_creation() {
|
||||
let driver = VertexAIDriver::new("test-project".to_string(), "us-central1".to_string());
|
||||
assert_eq!(driver.project_id, "test-project");
|
||||
assert_eq!(driver.region, "us-central1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_endpoint_non_streaming() {
|
||||
let driver = VertexAIDriver::new("my-project".to_string(), "us-central1".to_string());
|
||||
let endpoint = driver.build_endpoint("gemini-2.0-flash", false);
|
||||
assert_eq!(
|
||||
endpoint,
|
||||
"https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-2.0-flash:generateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_endpoint_streaming() {
|
||||
let driver = VertexAIDriver::new("my-project".to_string(), "europe-west4".to_string());
|
||||
let endpoint = driver.build_endpoint("gemini-1.5-pro", true);
|
||||
assert_eq!(
|
||||
endpoint,
|
||||
"https://europe-west4-aiplatform.googleapis.com/v1/projects/my-project/locations/europe-west4/publishers/google/models/gemini-1.5-pro:streamGenerateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_endpoint_strips_model_prefix() {
|
||||
let driver = VertexAIDriver::new("my-project".to_string(), "us-central1".to_string());
|
||||
let endpoint = driver.build_endpoint("models/gemini-2.0-flash", false);
|
||||
assert_eq!(
|
||||
endpoint,
|
||||
"https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-2.0-flash:generateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_cache_initially_invalid() {
|
||||
let cache = TokenCache::new();
|
||||
assert!(!cache.is_valid());
|
||||
assert!(cache.token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vertex_content_serialization() {
|
||||
let content = VertexContent {
|
||||
role: Some("user".to_string()),
|
||||
parts: vec![VertexPart::Text {
|
||||
text: "Hello".to_string(),
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&content).unwrap();
|
||||
assert!(json.contains("\"role\":\"user\""));
|
||||
assert!(json.contains("\"text\":\"Hello\""));
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,12 @@ pub trait KernelHandle: Send + Sync {
|
||||
Err("Channel file data send not available".to_string())
|
||||
}
|
||||
|
||||
/// Refresh an agent's last_active timestamp without changing any other state.
|
||||
/// Called by the agent loop before long LLM calls to prevent heartbeat false-positives.
|
||||
fn touch_agent(&self, agent_id: &str) {
|
||||
let _ = agent_id;
|
||||
}
|
||||
|
||||
/// Spawn an agent with capability inheritance enforcement.
|
||||
/// `parent_caps` are the parent's granted capabilities. The kernel MUST verify
|
||||
/// that every capability in the child manifest is covered by `parent_caps`.
|
||||
|
||||
@@ -135,6 +135,7 @@ pub enum StreamEvent {
|
||||
},
|
||||
/// Tool execution completed with result (emitted by agent loop, not LLM driver).
|
||||
ToolExecutionResult {
|
||||
id: String,
|
||||
name: String,
|
||||
result_preview: String,
|
||||
is_error: bool,
|
||||
|
||||
@@ -498,7 +498,7 @@ impl LoopGuard {
|
||||
}
|
||||
|
||||
/// Compute a SHA-256 hash of the tool name and parameters.
|
||||
fn compute_hash(tool_name: &str, params: &serde_json::Value) -> String {
|
||||
pub fn compute_hash(tool_name: &str, params: &serde_json::Value) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(tool_name.as_bytes());
|
||||
hasher.update(b"|");
|
||||
|
||||
+202
-450
@@ -1,16 +1,20 @@
|
||||
//! MCP (Model Context Protocol) client — connect to external MCP servers.
|
||||
//!
|
||||
//! MCP uses JSON-RPC 2.0 over stdio or HTTP+SSE. This module lets OpenFang
|
||||
//! agents use tools from any MCP server (100+ available: GitHub, filesystem,
|
||||
//! databases, APIs, etc.).
|
||||
//! Uses the official `rmcp` SDK for protocol handling. Supports:
|
||||
//! - **stdio**: subprocess with JSON-RPC over stdin/stdout
|
||||
//! - **sse**: deprecated HTTP+SSE transport (protocol version 2024-11-05)
|
||||
//! - **http**: Streamable HTTP transport (protocol version 2025-03-26+)
|
||||
//!
|
||||
//! All MCP tools are namespaced with `mcp_{server}_{tool}` to prevent collisions.
|
||||
|
||||
use http::{HeaderName, HeaderValue};
|
||||
use openfang_types::tool::ToolDefinition;
|
||||
use rmcp::model::{CallToolRequestParams, ClientCapabilities, ClientInfo, Implementation};
|
||||
use rmcp::service::RunningService;
|
||||
use rmcp::{RoleClient, ServiceExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -30,6 +34,12 @@ pub struct McpServerConfig {
|
||||
/// Environment variables to pass through to the subprocess (sandboxed).
|
||||
#[serde(default)]
|
||||
pub env: Vec<String>,
|
||||
/// Extra HTTP headers to send with every SSE / Streamable-HTTP request.
|
||||
/// Each entry is `"Header-Name: value"`. Useful for authentication
|
||||
/// (`Authorization: Bearer <token>`), API keys (`X-Api-Key: ...`),
|
||||
/// or any custom headers required by a remote MCP server.
|
||||
#[serde(default)]
|
||||
pub headers: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_timeout() -> u64 {
|
||||
@@ -46,8 +56,14 @@ pub enum McpTransport {
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
},
|
||||
/// HTTP Server-Sent Events.
|
||||
/// Deprecated HTTP+SSE transport (protocol version 2024-11-05).
|
||||
/// Uses POST for sending and SSE for receiving.
|
||||
Sse { url: String },
|
||||
/// Streamable HTTP transport (MCP 2025-03-26+).
|
||||
/// Single endpoint, client MUST send Accept: application/json, text/event-stream.
|
||||
/// Server responds with either JSON or SSE stream.
|
||||
/// Supports Mcp-Session-Id for session management.
|
||||
Http { url: String },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -64,59 +80,9 @@ pub struct McpConnection {
|
||||
/// Needed because `normalize_name` replaces hyphens with underscores,
|
||||
/// but the server expects the original name (e.g. "list-connections").
|
||||
original_names: HashMap<String, String>,
|
||||
/// Transport handle for sending requests.
|
||||
transport: McpTransportHandle,
|
||||
/// Next JSON-RPC request ID.
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
/// Transport handle — abstraction over stdio subprocess or HTTP.
|
||||
enum McpTransportHandle {
|
||||
Stdio {
|
||||
child: Box<tokio::process::Child>,
|
||||
stdin: tokio::process::ChildStdin,
|
||||
stdout: BufReader<tokio::process::ChildStdout>,
|
||||
},
|
||||
Sse {
|
||||
client: reqwest::Client,
|
||||
url: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 request.
|
||||
#[derive(Serialize)]
|
||||
struct JsonRpcRequest {
|
||||
jsonrpc: &'static str,
|
||||
id: u64,
|
||||
method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 response.
|
||||
#[derive(Deserialize)]
|
||||
struct JsonRpcResponse {
|
||||
#[allow(dead_code)]
|
||||
jsonrpc: String,
|
||||
#[allow(dead_code)]
|
||||
id: Option<u64>,
|
||||
result: Option<serde_json::Value>,
|
||||
error: Option<JsonRpcError>,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 error object.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct JsonRpcError {
|
||||
pub code: i64,
|
||||
pub message: String,
|
||||
#[allow(dead_code)]
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for JsonRpcError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "JSON-RPC error {}: {}", self.code, self.message)
|
||||
}
|
||||
/// The rmcp client handle — type-erased because the concrete type
|
||||
/// depends on which transport was used (stdio vs HTTP).
|
||||
client: RunningService<RoleClient, ClientInfo>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -126,13 +92,17 @@ impl std::fmt::Display for JsonRpcError {
|
||||
impl McpConnection {
|
||||
/// Connect to an MCP server, perform handshake, and discover tools.
|
||||
pub async fn connect(config: McpServerConfig) -> Result<Self, String> {
|
||||
let transport = match &config.transport {
|
||||
let client_info = ClientInfo::new(
|
||||
ClientCapabilities::default(),
|
||||
Implementation::new("openfang", env!("CARGO_PKG_VERSION")),
|
||||
);
|
||||
|
||||
let client = match &config.transport {
|
||||
McpTransport::Stdio { command, args } => {
|
||||
Self::connect_stdio(command, args, &config.env).await?
|
||||
Self::connect_stdio(command, args, &config.env, client_info).await?
|
||||
}
|
||||
McpTransport::Sse { url } => {
|
||||
// SSRF check: reject private/localhost URLs unless explicitly configured
|
||||
Self::connect_sse(url).await?
|
||||
McpTransport::Sse { url } | McpTransport::Http { url } => {
|
||||
Self::connect_http(url, &config.headers, client_info).await?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -140,13 +110,9 @@ impl McpConnection {
|
||||
config,
|
||||
tools: Vec::new(),
|
||||
original_names: HashMap::new(),
|
||||
transport,
|
||||
next_id: 1,
|
||||
client,
|
||||
};
|
||||
|
||||
// Initialize handshake
|
||||
conn.initialize().await?;
|
||||
|
||||
// Discover tools
|
||||
conn.discover_tools().await?;
|
||||
|
||||
@@ -159,76 +125,34 @@ impl McpConnection {
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Send the MCP `initialize` handshake.
|
||||
async fn initialize(&mut self) -> Result<(), String> {
|
||||
let params = serde_json::json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "openfang",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
});
|
||||
|
||||
let response = self.send_request("initialize", Some(params)).await?;
|
||||
|
||||
if let Some(result) = response {
|
||||
debug!(
|
||||
server = %self.config.name,
|
||||
server_info = %result,
|
||||
"MCP initialize response"
|
||||
);
|
||||
}
|
||||
|
||||
// Send initialized notification (no response expected)
|
||||
self.send_notification("notifications/initialized", None)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover available tools via `tools/list`.
|
||||
async fn discover_tools(&mut self) -> Result<(), String> {
|
||||
let response = self.send_request("tools/list", None).await?;
|
||||
let tools = self
|
||||
.client
|
||||
.list_all_tools()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to list MCP tools: {e}"))?;
|
||||
|
||||
if let Some(result) = response {
|
||||
if let Some(tools_array) = result.get("tools").and_then(|t| t.as_array()) {
|
||||
let server_name = &self.config.name;
|
||||
for tool in tools_array {
|
||||
let raw_name = tool["name"].as_str().unwrap_or("unnamed");
|
||||
let description = tool["description"].as_str().unwrap_or("");
|
||||
let input_schema = tool
|
||||
.get("inputSchema")
|
||||
.cloned()
|
||||
.and_then(|v| {
|
||||
// Ensure input_schema is a JSON object. MCP servers may
|
||||
// return it as a string, null, or omit it entirely.
|
||||
match &v {
|
||||
serde_json::Value::Object(_) => Some(v),
|
||||
serde_json::Value::String(s) => {
|
||||
serde_json::from_str::<serde_json::Value>(s)
|
||||
.ok()
|
||||
.filter(|p| p.is_object())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.unwrap_or(serde_json::json!({"type": "object"}));
|
||||
let server_name = &self.config.name;
|
||||
for tool in &tools {
|
||||
let raw_name = &tool.name;
|
||||
let description = tool.description.as_deref().unwrap_or("");
|
||||
|
||||
// Namespace: mcp_{server}_{tool}
|
||||
let namespaced = format_mcp_tool_name(server_name, raw_name);
|
||||
let input_schema = serde_json::to_value(&tool.input_schema)
|
||||
.unwrap_or(serde_json::json!({"type": "object"}));
|
||||
|
||||
// Store original name so we can send it back to the server
|
||||
self.original_names
|
||||
.insert(namespaced.clone(), raw_name.to_string());
|
||||
// Namespace: mcp_{server}_{tool}
|
||||
let namespaced = format_mcp_tool_name(server_name, raw_name);
|
||||
|
||||
self.tools.push(ToolDefinition {
|
||||
name: namespaced,
|
||||
description: format!("[MCP:{server_name}] {description}"),
|
||||
input_schema,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Store original name so we can send it back to the server
|
||||
self.original_names
|
||||
.insert(namespaced.clone(), raw_name.to_string());
|
||||
|
||||
self.tools.push(ToolDefinition {
|
||||
name: namespaced,
|
||||
description: format!("[MCP:{server_name}] {description}"),
|
||||
input_schema,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -243,40 +167,38 @@ impl McpConnection {
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<String, String> {
|
||||
// Look up the original tool name from the server (preserves hyphens etc.)
|
||||
let raw_name = self
|
||||
let raw_name: String = self
|
||||
.original_names
|
||||
.get(name)
|
||||
.map(|s| s.as_str())
|
||||
.or_else(|| strip_mcp_prefix(&self.config.name, name))
|
||||
.unwrap_or(name);
|
||||
.cloned()
|
||||
.or_else(|| strip_mcp_prefix(&self.config.name, name).map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| name.to_string());
|
||||
|
||||
let params = serde_json::json!({
|
||||
"name": raw_name,
|
||||
"arguments": arguments,
|
||||
});
|
||||
let args = arguments.as_object().cloned().unwrap_or_default();
|
||||
|
||||
let response = self.send_request("tools/call", Some(params)).await?;
|
||||
debug!(tool = %raw_name, server = %self.config.name, "MCP tool call");
|
||||
|
||||
match response {
|
||||
Some(result) => {
|
||||
// Extract text content from the response
|
||||
if let Some(content) = result.get("content").and_then(|c| c.as_array()) {
|
||||
let texts: Vec<&str> = content
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
if item["type"].as_str() == Some("text") {
|
||||
item["text"].as_str()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(texts.join("\n"))
|
||||
} else {
|
||||
Ok(result.to_string())
|
||||
}
|
||||
}
|
||||
None => Err("No result from MCP tools/call".to_string()),
|
||||
let params = CallToolRequestParams::new(raw_name).with_arguments(args);
|
||||
|
||||
let result = self
|
||||
.client
|
||||
.call_tool(params)
|
||||
.await
|
||||
.map_err(|e| format!("MCP tool call failed: {e}"))?;
|
||||
|
||||
// Extract text content from the response.
|
||||
// `Content` is `Annotated<RawContent>` which Derefs to `RawContent`.
|
||||
let texts: Vec<&str> = result
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|item| item.as_text().map(|tc| tc.text.as_str()))
|
||||
.collect();
|
||||
|
||||
if texts.is_empty() {
|
||||
// Fallback: serialize the entire result
|
||||
Ok(serde_json::to_string(&result).unwrap_or_default())
|
||||
} else {
|
||||
Ok(texts.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,253 +212,124 @@ impl McpConnection {
|
||||
&self.config.name
|
||||
}
|
||||
|
||||
// --- Transport helpers ---
|
||||
|
||||
async fn send_request(
|
||||
&mut self,
|
||||
method: &str,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
|
||||
let request = JsonRpcRequest {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method: method.to_string(),
|
||||
params,
|
||||
};
|
||||
|
||||
let request_json = serde_json::to_string(&request)
|
||||
.map_err(|e| format!("Failed to serialize request: {e}"))?;
|
||||
|
||||
debug!(method, id, "MCP request");
|
||||
|
||||
match &mut self.transport {
|
||||
McpTransportHandle::Stdio { stdin, stdout, .. } => {
|
||||
// Write request + newline
|
||||
stdin
|
||||
.write_all(request_json.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write to MCP stdin: {e}"))?;
|
||||
stdin
|
||||
.write_all(b"\n")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write newline: {e}"))?;
|
||||
stdin
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to flush stdin: {e}"))?;
|
||||
|
||||
// Read response line
|
||||
let mut line = String::new();
|
||||
let timeout = tokio::time::Duration::from_secs(self.config.timeout_secs);
|
||||
match tokio::time::timeout(timeout, stdout.read_line(&mut line)).await {
|
||||
Ok(Ok(0)) => return Err("MCP server closed connection".to_string()),
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => return Err(format!("Failed to read MCP response: {e}")),
|
||||
Err(_) => return Err("MCP request timed out".to_string()),
|
||||
}
|
||||
|
||||
let response: JsonRpcResponse = serde_json::from_str(line.trim())
|
||||
.map_err(|e| format!("Invalid MCP JSON-RPC response: {e}"))?;
|
||||
|
||||
if let Some(err) = response.error {
|
||||
return Err(format!("{err}"));
|
||||
}
|
||||
|
||||
Ok(response.result)
|
||||
}
|
||||
McpTransportHandle::Sse { client, url } => {
|
||||
let response = client
|
||||
.post(url.as_str())
|
||||
.json(&request)
|
||||
.timeout(std::time::Duration::from_secs(self.config.timeout_secs))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("MCP SSE request failed: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("MCP SSE returned {}", response.status()));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read SSE response: {e}"))?;
|
||||
|
||||
let rpc_response: JsonRpcResponse = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("Invalid MCP SSE JSON-RPC response: {e}"))?;
|
||||
|
||||
if let Some(err) = rpc_response.error {
|
||||
return Err(format!("{err}"));
|
||||
}
|
||||
|
||||
Ok(rpc_response.result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_notification(
|
||||
&mut self,
|
||||
method: &str,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<(), String> {
|
||||
let notification = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params.unwrap_or(serde_json::json!({})),
|
||||
});
|
||||
|
||||
let json = serde_json::to_string(¬ification)
|
||||
.map_err(|e| format!("Failed to serialize notification: {e}"))?;
|
||||
|
||||
match &mut self.transport {
|
||||
McpTransportHandle::Stdio { stdin, .. } => {
|
||||
stdin
|
||||
.write_all(json.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("Write notification: {e}"))?;
|
||||
stdin
|
||||
.write_all(b"\n")
|
||||
.await
|
||||
.map_err(|e| format!("Write newline: {e}"))?;
|
||||
stdin.flush().await.map_err(|e| format!("Flush: {e}"))?;
|
||||
}
|
||||
McpTransportHandle::Sse { client, url } => {
|
||||
let _ = client.post(url.as_str()).json(¬ification).send().await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
// -- Transport constructors -----------------------------------------------
|
||||
|
||||
/// Connect using stdio transport (subprocess).
|
||||
async fn connect_stdio(
|
||||
command: &str,
|
||||
args: &[String],
|
||||
env_whitelist: &[String],
|
||||
) -> Result<McpTransportHandle, String> {
|
||||
client_info: ClientInfo,
|
||||
) -> Result<RunningService<RoleClient, ClientInfo>, String> {
|
||||
use rmcp::transport::{ConfigureCommandExt, TokioChildProcess};
|
||||
use tokio::process::Command;
|
||||
|
||||
// Validate command path (no path traversal)
|
||||
if command.contains("..") {
|
||||
return Err("MCP command path contains '..': rejected".to_string());
|
||||
}
|
||||
|
||||
// On Windows, npm/npx install as .cmd batch wrappers. Detect and adapt.
|
||||
let resolved_command: String = if cfg!(windows) {
|
||||
// If the user already specified .cmd/.bat, use as-is
|
||||
if command.ends_with(".cmd") || command.ends_with(".bat") {
|
||||
command.to_string()
|
||||
} else {
|
||||
// Check if the .cmd variant exists on PATH
|
||||
let cmd_variant = format!("{command}.cmd");
|
||||
let has_cmd = std::env::var("PATH")
|
||||
.unwrap_or_default()
|
||||
.split(';')
|
||||
.any(|dir| std::path::Path::new(dir).join(&cmd_variant).exists());
|
||||
if has_cmd {
|
||||
cmd_variant
|
||||
} else {
|
||||
command.to_string()
|
||||
let cmd_str = command.to_string();
|
||||
let args_vec: Vec<String> = args.to_vec();
|
||||
let env_list: Vec<String> = env_whitelist.to_vec();
|
||||
|
||||
let transport = TokioChildProcess::new(Command::new(&cmd_str).configure(move |cmd| {
|
||||
for arg in &args_vec {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
// Sandbox: clear environment, only pass whitelisted vars
|
||||
cmd.env_clear();
|
||||
for var_name in &env_list {
|
||||
if let Ok(val) = std::env::var(var_name) {
|
||||
cmd.env(var_name, val);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
command.to_string()
|
||||
// Always pass PATH for binary resolution
|
||||
if let Ok(path) = std::env::var("PATH") {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
// On Windows, npm/node need extra vars
|
||||
if cfg!(windows) {
|
||||
for var in &[
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"USERPROFILE",
|
||||
"SystemRoot",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"HOME",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
] {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
cmd.env(var, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.map_err(|e| format!("Failed to spawn MCP server '{cmd_str}': {e}"))?;
|
||||
|
||||
let client = client_info
|
||||
.serve(transport)
|
||||
.await
|
||||
.map_err(|e| format!("MCP stdio handshake failed: {e}"))?;
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Connect using Streamable HTTP transport (or SSE fallback via the same endpoint).
|
||||
///
|
||||
/// The `rmcp` SDK's `StreamableHttpClientTransport` handles the full
|
||||
/// Streamable HTTP protocol: Accept headers, Mcp-Session-Id tracking,
|
||||
/// SSE stream parsing, and content-type negotiation.
|
||||
async fn connect_http(
|
||||
url: &str,
|
||||
headers: &[String],
|
||||
client_info: ClientInfo,
|
||||
) -> Result<RunningService<RoleClient, ClientInfo>, String> {
|
||||
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
|
||||
use rmcp::transport::StreamableHttpClientTransport;
|
||||
|
||||
Self::check_ssrf(url)?;
|
||||
|
||||
// Parse custom headers (e.g., "Authorization: Bearer <token>").
|
||||
let mut custom_headers: HashMap<HeaderName, HeaderValue> = HashMap::new();
|
||||
for header_str in headers {
|
||||
if let Some((name, value)) = header_str.split_once(':') {
|
||||
let name = name.trim();
|
||||
let value = value.trim();
|
||||
if let (Ok(hn), Ok(hv)) = (
|
||||
HeaderName::from_bytes(name.as_bytes()),
|
||||
HeaderValue::from_str(value),
|
||||
) {
|
||||
custom_headers.insert(hn, hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let config = StreamableHttpClientTransportConfig {
|
||||
uri: Arc::from(url),
|
||||
custom_headers,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&resolved_command);
|
||||
cmd.args(args);
|
||||
cmd.stdin(Stdio::piped());
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
let transport = StreamableHttpClientTransport::from_config(config);
|
||||
|
||||
// Sandbox: clear environment, only pass whitelisted vars
|
||||
cmd.env_clear();
|
||||
for var_name in env_whitelist {
|
||||
if let Ok(val) = std::env::var(var_name) {
|
||||
cmd.env(var_name, val);
|
||||
}
|
||||
}
|
||||
// Always pass PATH for binary resolution
|
||||
if let Ok(path) = std::env::var("PATH") {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
// On Windows, npm/node need APPDATA, USERPROFILE, LOCALAPPDATA, and SystemRoot
|
||||
if cfg!(windows) {
|
||||
for var in &[
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"USERPROFILE",
|
||||
"SystemRoot",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"HOME",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
] {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
cmd.env(var, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
let client = client_info
|
||||
.serve(transport)
|
||||
.await
|
||||
.map_err(|e| format!("MCP HTTP connection failed: {e}"))?;
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn MCP server '{resolved_command}': {e}"))?;
|
||||
|
||||
// Log stderr in background for debugging MCP server issues
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let cmd_name = resolved_command.clone();
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let reader = tokio::io::BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::debug!(mcp_server = %cmd_name, "stderr: {line}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or("Failed to capture MCP server stdin")?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or("Failed to capture MCP server stdout")?;
|
||||
|
||||
Ok(McpTransportHandle::Stdio {
|
||||
child: Box::new(child),
|
||||
stdin,
|
||||
stdout: BufReader::new(stdout),
|
||||
})
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
async fn connect_sse(url: &str) -> Result<McpTransportHandle, String> {
|
||||
// Basic SSRF check: reject obviously private URLs
|
||||
/// Basic SSRF check: reject obviously private/metadata URLs.
|
||||
fn check_ssrf(url: &str) -> Result<(), String> {
|
||||
let lower = url.to_lowercase();
|
||||
if lower.contains("169.254.169.254") || lower.contains("metadata.google") {
|
||||
return Err("SSRF: MCP SSE URL targets metadata endpoint".to_string());
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
|
||||
|
||||
Ok(McpTransportHandle::Sse {
|
||||
client,
|
||||
url: url.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpConnection {
|
||||
fn drop(&mut self) {
|
||||
if let McpTransportHandle::Stdio { ref mut child, .. } = self.transport {
|
||||
// Best-effort kill of the subprocess
|
||||
let _ = child.start_kill();
|
||||
return Err("SSRF: MCP URL targets metadata endpoint".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -627,16 +420,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_hyphenated_tool_name_preserved() {
|
||||
// Tool names with hyphens get normalized to underscores for namespacing,
|
||||
// but original_names map preserves the original for call_tool dispatch.
|
||||
let namespaced = format_mcp_tool_name("sqlcl", "list-connections");
|
||||
assert_eq!(namespaced, "mcp_sqlcl_list_connections");
|
||||
|
||||
// Simulate what discover_tools does
|
||||
let mut original_names = HashMap::new();
|
||||
original_names.insert(namespaced.clone(), "list-connections".to_string());
|
||||
|
||||
// call_tool should resolve to original hyphenated name
|
||||
let raw = original_names
|
||||
.get(&namespaced)
|
||||
.map(|s| s.as_str())
|
||||
@@ -655,25 +444,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_extract_mcp_server_from_known_with_hyphens() {
|
||||
// Server "bocha-search" normalized to "bocha_search" in tool prefix
|
||||
let servers = vec!["bocha-search", "github"];
|
||||
let tool = "mcp_bocha_search_bocha_web_search";
|
||||
assert_eq!(
|
||||
extract_mcp_server_from_known(tool, &servers),
|
||||
Some("bocha-search")
|
||||
);
|
||||
// Simple server name still works
|
||||
assert_eq!(
|
||||
extract_mcp_server_from_known("mcp_github_create_issue", &servers),
|
||||
Some("github")
|
||||
);
|
||||
// Non-MCP tool returns None
|
||||
assert_eq!(extract_mcp_server_from_known("file_read", &servers), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_mcp_server_from_known_longest_match() {
|
||||
// "my-api" and "my-api-v2" — should match the longer one
|
||||
let servers = vec!["my-api", "my-api-v2"];
|
||||
assert_eq!(
|
||||
extract_mcp_server_from_known("mcp_my_api_v2_get_users", &servers),
|
||||
@@ -685,60 +470,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_jsonrpc_initialize() {
|
||||
// Verify the initialize request structure
|
||||
let request = JsonRpcRequest {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize".to_string(),
|
||||
params: Some(serde_json::json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "openfang",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
})),
|
||||
};
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
assert!(json.contains("initialize"));
|
||||
assert!(json.contains("protocolVersion"));
|
||||
assert!(json.contains("openfang"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_jsonrpc_tools_list() {
|
||||
// Simulate a tools/list response
|
||||
let response_json = r#"{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"result": {
|
||||
"tools": [
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"body": {"type": "string"}
|
||||
},
|
||||
"required": ["title"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: JsonRpcResponse = serde_json::from_str(response_json).unwrap();
|
||||
assert!(response.error.is_none());
|
||||
let result = response.result.unwrap();
|
||||
let tools = result["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 1);
|
||||
assert_eq!(tools[0]["name"].as_str().unwrap(), "create_issue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_transport_config_serde() {
|
||||
let config = McpServerConfig {
|
||||
@@ -752,6 +483,7 @@ mod tests {
|
||||
},
|
||||
timeout_secs: 30,
|
||||
env: vec!["GITHUB_PERSONAL_ACCESS_TOKEN".to_string()],
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
@@ -776,6 +508,7 @@ mod tests {
|
||||
},
|
||||
timeout_secs: 60,
|
||||
env: vec![],
|
||||
headers: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&sse_config).unwrap();
|
||||
let back: McpServerConfig = serde_json::from_str(&json).unwrap();
|
||||
@@ -783,5 +516,24 @@ mod tests {
|
||||
McpTransport::Sse { url } => assert_eq!(url, "https://example.com/mcp"),
|
||||
_ => panic!("Expected SSE transport"),
|
||||
}
|
||||
|
||||
// HTTP (Streamable HTTP) variant
|
||||
let http_config = McpServerConfig {
|
||||
name: "atlassian".to_string(),
|
||||
transport: McpTransport::Http {
|
||||
url: "https://mcp.atlassian.com/v1/mcp".to_string(),
|
||||
},
|
||||
timeout_secs: 120,
|
||||
env: vec![],
|
||||
headers: vec!["Authorization: Bearer test-token-456".to_string()],
|
||||
};
|
||||
let json = serde_json::to_string(&http_config).unwrap();
|
||||
let back: McpServerConfig = serde_json::from_str(&json).unwrap();
|
||||
match back.transport {
|
||||
McpTransport::Http { url } => {
|
||||
assert_eq!(url, "https://mcp.atlassian.com/v1/mcp")
|
||||
}
|
||||
_ => panic!("Expected Http transport"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@
|
||||
|
||||
use openfang_types::model_catalog::{
|
||||
AuthStatus, ModelCatalogEntry, ModelTier, ProviderInfo, AI21_BASE_URL, ANTHROPIC_BASE_URL,
|
||||
AZURE_OPENAI_BASE_URL, BEDROCK_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL,
|
||||
COHERE_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL,
|
||||
GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL,
|
||||
LEMONADE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL,
|
||||
MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL,
|
||||
OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
|
||||
AZURE_OPENAI_BASE_URL, BEDROCK_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL, COHERE_BASE_URL,
|
||||
DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL,
|
||||
HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL,
|
||||
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL,
|
||||
OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
|
||||
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
|
||||
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
@@ -107,20 +106,138 @@ impl ModelCatalog {
|
||||
&self.models
|
||||
}
|
||||
|
||||
/// Find a model by its canonical ID or by alias.
|
||||
/// Find a model by its canonical ID, display name, or alias.
|
||||
///
|
||||
/// When multiple models match case-insensitively (e.g. a builtin `qwen3-30b-a3b`
|
||||
/// and a custom `Qwen3-30B-A3B`), user-defined entries (Custom or Local tier)
|
||||
/// take priority. This ensures models from `custom_models.json` or dynamically
|
||||
/// discovered local models are not shadowed by builtins that happen to share the
|
||||
/// same lowercased name (#856).
|
||||
pub fn find_model(&self, id_or_alias: &str) -> Option<&ModelCatalogEntry> {
|
||||
let lower = id_or_alias.to_lowercase();
|
||||
// Direct ID match first
|
||||
if let Some(entry) = self.models.iter().find(|m| m.id.to_lowercase() == lower) {
|
||||
|
||||
// Single scan: prefer user-defined models (Custom/Local tier) over builtins.
|
||||
//
|
||||
// Priority order:
|
||||
// 1. User-defined entry with exact-case ID match
|
||||
// 2. User-defined entry with case-insensitive ID match
|
||||
// 3. Builtin entry with exact-case ID match
|
||||
// 4. Builtin entry with case-insensitive ID match
|
||||
//
|
||||
// This ensures that custom models from custom_models.json and dynamically
|
||||
// discovered local models are never shadowed by builtins that share the same
|
||||
// lowercased name, regardless of how the caller cased the search term.
|
||||
let mut user_ci: Option<&ModelCatalogEntry> = None;
|
||||
let mut builtin_exact: Option<&ModelCatalogEntry> = None;
|
||||
let mut builtin_ci: Option<&ModelCatalogEntry> = None;
|
||||
|
||||
for m in &self.models {
|
||||
if m.id.to_lowercase() != lower {
|
||||
continue;
|
||||
}
|
||||
let is_user_defined = m.tier == ModelTier::Custom || m.tier == ModelTier::Local;
|
||||
let is_exact = m.id == id_or_alias;
|
||||
|
||||
match (is_user_defined, is_exact) {
|
||||
(true, true) => return Some(m), // Best possible: user-defined + exact
|
||||
(true, false) if user_ci.is_none() => user_ci = Some(m),
|
||||
(false, true) if builtin_exact.is_none() => builtin_exact = Some(m),
|
||||
(false, false) if builtin_ci.is_none() => builtin_ci = Some(m),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(entry) = user_ci {
|
||||
return Some(entry);
|
||||
}
|
||||
// Alias resolution
|
||||
if let Some(entry) = builtin_exact {
|
||||
return Some(entry);
|
||||
}
|
||||
if let Some(entry) = builtin_ci {
|
||||
return Some(entry);
|
||||
}
|
||||
|
||||
// 3. Display-name match for dashboard/UI payloads that send labels.
|
||||
if let Some(entry) = self
|
||||
.models
|
||||
.iter()
|
||||
.find(|m| m.display_name.to_lowercase() == lower)
|
||||
{
|
||||
return Some(entry);
|
||||
}
|
||||
// 4. Alias resolution
|
||||
if let Some(canonical) = self.aliases.get(&lower) {
|
||||
return self.models.iter().find(|m| m.id == *canonical);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find a model by ID/alias, preferring entries from the given provider.
|
||||
///
|
||||
/// When `provider` is specified, this method first looks for a matching model
|
||||
/// that belongs to that provider. If no provider-scoped match is found, it
|
||||
/// falls back to the normal `find_model` resolution.
|
||||
///
|
||||
/// This prevents issue #833 where switching to model "kimi-2.5" with provider
|
||||
/// "model_studio" would incorrectly resolve to moonshot's builtin kimi-2.5
|
||||
/// because `find_model` does not consider provider affinity.
|
||||
pub fn find_model_for_provider(
|
||||
&self,
|
||||
id_or_alias: &str,
|
||||
provider: &str,
|
||||
) -> Option<&ModelCatalogEntry> {
|
||||
let lower = id_or_alias.to_lowercase();
|
||||
|
||||
// First pass: look for a match scoped to the requested provider.
|
||||
// Priority: exact-case ID > case-insensitive ID > display-name.
|
||||
let mut provider_ci: Option<&ModelCatalogEntry> = None;
|
||||
|
||||
for m in &self.models {
|
||||
if m.provider != provider {
|
||||
continue;
|
||||
}
|
||||
if m.id.to_lowercase() != lower {
|
||||
continue;
|
||||
}
|
||||
if m.id == id_or_alias {
|
||||
return Some(m); // Exact-case match on the right provider — best result
|
||||
}
|
||||
if provider_ci.is_none() {
|
||||
provider_ci = Some(m);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(entry) = provider_ci {
|
||||
return Some(entry);
|
||||
}
|
||||
|
||||
// Display-name match scoped to provider
|
||||
if let Some(entry) = self
|
||||
.models
|
||||
.iter()
|
||||
.find(|m| m.provider == provider && m.display_name.to_lowercase() == lower)
|
||||
{
|
||||
return Some(entry);
|
||||
}
|
||||
|
||||
// Alias resolution scoped to provider: resolve the alias, then check if
|
||||
// the canonical model belongs to the requested provider.
|
||||
if let Some(canonical) = self.aliases.get(&lower) {
|
||||
if let Some(entry) = self
|
||||
.models
|
||||
.iter()
|
||||
.find(|m| m.id == *canonical && m.provider == provider)
|
||||
{
|
||||
return Some(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// No provider-scoped match — fall back to normal resolution so callers
|
||||
// still get a result when the model genuinely doesn't exist on this provider
|
||||
// (e.g. user typo, or a model name that only exists elsewhere).
|
||||
self.find_model(id_or_alias)
|
||||
}
|
||||
|
||||
/// Resolve an alias to a canonical model ID, or None if not an alias.
|
||||
pub fn resolve_alias(&self, alias: &str) -> Option<&str> {
|
||||
self.aliases.get(&alias.to_lowercase()).map(|s| s.as_str())
|
||||
@@ -255,8 +372,8 @@ impl ModelCatalog {
|
||||
display_name: display,
|
||||
provider: provider.to_string(),
|
||||
tier: ModelTier::Local,
|
||||
context_window: 32_768,
|
||||
max_output_tokens: 4_096,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 16_384,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: true,
|
||||
@@ -283,6 +400,10 @@ impl ModelCatalog {
|
||||
///
|
||||
/// Returns `true` if the model was added, `false` if a model with the same
|
||||
/// ID **and** provider already exists (case-insensitive).
|
||||
///
|
||||
/// The entry's tier is forced to [`ModelTier::Custom`] so that user-defined
|
||||
/// models are always preferred over builtins with the same lowercased name
|
||||
/// (see `find_model` priority logic, #856).
|
||||
pub fn add_custom_model(&mut self, entry: ModelCatalogEntry) -> bool {
|
||||
let lower_id = entry.id.to_lowercase();
|
||||
let lower_provider = entry.provider.to_lowercase();
|
||||
@@ -294,6 +415,8 @@ impl ModelCatalog {
|
||||
return false;
|
||||
}
|
||||
let provider = entry.provider.clone();
|
||||
let mut entry = entry;
|
||||
entry.tier = ModelTier::Custom;
|
||||
self.models.push(entry);
|
||||
|
||||
// Update provider model count
|
||||
@@ -849,7 +972,8 @@ fn builtin_aliases() -> HashMap<String, String> {
|
||||
("ernie", "ernie-4.5-8k"),
|
||||
("kimi", "kimi-k2"),
|
||||
("moonshot", "moonshot-v1-128k"),
|
||||
("minimax", "MiniMax-M2.5"),
|
||||
("minimax", "MiniMax-M2.7"),
|
||||
("minimax-m2.7", "MiniMax-M2.7"),
|
||||
("minimax-m2.5", "MiniMax-M2.5"),
|
||||
("minimax-m2.5-highspeed", "MiniMax-M2.5-highspeed"),
|
||||
("minimax-highspeed", "MiniMax-M2.5-highspeed"),
|
||||
@@ -1540,34 +1664,6 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "llama-3.2-3b-preview".into(),
|
||||
display_name: "Llama 3.2 3B".into(),
|
||||
provider: "groq".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.06,
|
||||
output_cost_per_m: 0.06,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "llama-3.2-1b-preview".into(),
|
||||
display_name: "Llama 3.2 1B".into(),
|
||||
provider: "groq".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.04,
|
||||
output_cost_per_m: 0.04,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "mixtral-8x7b-32768".into(),
|
||||
display_name: "Mixtral 8x7B".into(),
|
||||
@@ -1582,20 +1678,6 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec!["mixtral".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "gemma2-9b-it".into(),
|
||||
display_name: "Gemma 2 9B".into(),
|
||||
provider: "groq".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 8_192,
|
||||
max_output_tokens: 4_096,
|
||||
input_cost_per_m: 0.02,
|
||||
output_cost_per_m: 0.02,
|
||||
supports_tools: false,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "qwen-qwq-32b".into(),
|
||||
display_name: "Qwen QWQ 32B".into(),
|
||||
@@ -3010,8 +3092,22 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// MiniMax (6)
|
||||
// MiniMax (7)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "MiniMax-M2.7".into(),
|
||||
display_name: "MiniMax M2.7".into(),
|
||||
provider: "minimax".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 204_800,
|
||||
max_output_tokens: 131_072,
|
||||
input_cost_per_m: 0.30,
|
||||
output_cost_per_m: 1.20,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["minimax-m2.7".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "minimax-text-01".into(),
|
||||
display_name: "MiniMax Text 01".into(),
|
||||
@@ -3024,7 +3120,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["minimax".into()],
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "MiniMax-M2.5".into(),
|
||||
@@ -3916,7 +4012,7 @@ mod tests {
|
||||
let anthropic = catalog.get_provider("anthropic").unwrap();
|
||||
assert_eq!(anthropic.model_count, 7);
|
||||
let groq = catalog.get_provider("groq").unwrap();
|
||||
assert_eq!(groq.model_count, 10);
|
||||
assert_eq!(groq.model_count, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3938,6 +4034,14 @@ mod tests {
|
||||
assert_eq!(entry.provider, "xai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_model_by_display_name() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let entry = catalog.find_model("Grok 4").unwrap();
|
||||
assert_eq!(entry.id, "grok-4-0709");
|
||||
assert_eq!(entry.provider, "xai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_providers_in_catalog() {
|
||||
let catalog = ModelCatalog::new();
|
||||
@@ -4043,14 +4147,21 @@ mod tests {
|
||||
assert!(catalog.find_model("codegeex").is_some());
|
||||
assert!(catalog.find_model("ernie").is_some());
|
||||
assert!(catalog.find_model("minimax").is_some());
|
||||
// MiniMax M2.7 — new flagship model
|
||||
let m27 = catalog.find_model("MiniMax-M2.7").unwrap();
|
||||
assert_eq!(m27.provider, "minimax");
|
||||
assert_eq!(m27.tier, ModelTier::Frontier);
|
||||
assert!(!m27.supports_vision);
|
||||
assert!(m27.supports_tools);
|
||||
assert!(catalog.find_model("minimax-m2.7").is_some());
|
||||
// Default "minimax" alias now points to M2.7
|
||||
let default = catalog.find_model("minimax").unwrap();
|
||||
assert_eq!(default.id, "MiniMax-M2.7");
|
||||
// MiniMax M2.5 — by exact ID, alias, and case-insensitive
|
||||
let m25 = catalog.find_model("MiniMax-M2.5").unwrap();
|
||||
assert_eq!(m25.provider, "minimax");
|
||||
assert_eq!(m25.tier, ModelTier::Frontier);
|
||||
assert!(catalog.find_model("minimax-m2.5").is_some());
|
||||
// Default "minimax" alias now points to M2.5
|
||||
let default = catalog.find_model("minimax").unwrap();
|
||||
assert_eq!(default.id, "MiniMax-M2.5");
|
||||
// MiniMax M2.5 Highspeed — by exact ID and aliases
|
||||
let hs = catalog.find_model("MiniMax-M2.5-highspeed").unwrap();
|
||||
assert_eq!(hs.provider, "minimax");
|
||||
@@ -4232,4 +4343,184 @@ mod tests {
|
||||
assert!(entry.supports_tools);
|
||||
assert!(entry.supports_vision);
|
||||
}
|
||||
|
||||
/// Regression test for #856: custom models with case-sensitive names must not be
|
||||
/// shadowed by builtin models that share the same lowercased ID.
|
||||
///
|
||||
/// When a user deploys `Qwen3-30B-A3B` via vLLM and adds it to custom_models.json,
|
||||
/// find_model should return the custom entry (provider "vllm"), not the builtin
|
||||
/// entry (provider "qwen") whose id is `qwen3-30b-a3b`.
|
||||
#[test]
|
||||
fn test_custom_model_not_shadowed_by_builtin_856() {
|
||||
let mut catalog = ModelCatalog::new();
|
||||
|
||||
// Verify the builtin exists first
|
||||
let builtin = catalog.find_model("qwen3-30b-a3b").unwrap();
|
||||
assert_eq!(builtin.provider, "qwen");
|
||||
|
||||
// Add a custom model with a case-sensitive name on a different provider
|
||||
let added = catalog.add_custom_model(ModelCatalogEntry {
|
||||
id: "Qwen3-30B-A3B".into(),
|
||||
display_name: "Qwen3 30B (Local vLLM)".into(),
|
||||
provider: "vllm".into(),
|
||||
tier: ModelTier::Balanced, // user might not set tier explicitly
|
||||
context_window: 32_768,
|
||||
max_output_tokens: 4_096,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
});
|
||||
assert!(added, "custom model should be added (different provider)");
|
||||
|
||||
// add_custom_model should force Custom tier
|
||||
let custom = catalog
|
||||
.list_models()
|
||||
.iter()
|
||||
.find(|m| m.id == "Qwen3-30B-A3B")
|
||||
.unwrap();
|
||||
assert_eq!(custom.tier, ModelTier::Custom);
|
||||
|
||||
// Exact-case lookup must find the custom entry, not the builtin
|
||||
let found = catalog.find_model("Qwen3-30B-A3B").unwrap();
|
||||
assert_eq!(found.id, "Qwen3-30B-A3B");
|
||||
assert_eq!(found.provider, "vllm");
|
||||
assert_eq!(found.tier, ModelTier::Custom);
|
||||
|
||||
// Lowercase lookup: builtin "qwen3-30b-a3b" (tier Fast) gets an exact-case match,
|
||||
// but the custom "Qwen3-30B-A3B" (tier Custom) gets a case-insensitive match.
|
||||
// With our fix, the user-defined entry (Custom tier) wins even when there's
|
||||
// an exact-case builtin match, because user-defined entries always take priority.
|
||||
let lower_found = catalog.find_model("qwen3-30b-a3b").unwrap();
|
||||
assert_eq!(lower_found.provider, "vllm");
|
||||
assert_eq!(lower_found.tier, ModelTier::Custom);
|
||||
}
|
||||
|
||||
/// Verify that find_model's exact-case match takes priority over case-insensitive.
|
||||
#[test]
|
||||
fn test_find_model_exact_case_priority() {
|
||||
let catalog = ModelCatalog::new();
|
||||
// MiniMax-M2.5 exists as a builtin with exact case "MiniMax-M2.5"
|
||||
let entry = catalog.find_model("MiniMax-M2.5").unwrap();
|
||||
assert_eq!(entry.id, "MiniMax-M2.5");
|
||||
assert_eq!(entry.provider, "minimax");
|
||||
|
||||
// Case-insensitive still works
|
||||
let lower = catalog.find_model("minimax-m2.5").unwrap();
|
||||
assert_eq!(lower.id, "MiniMax-M2.5");
|
||||
}
|
||||
|
||||
/// Verify that dynamically discovered local models (Local tier) are preferred
|
||||
/// over builtins in case-insensitive lookups.
|
||||
#[test]
|
||||
fn test_discovered_local_model_preferred() {
|
||||
let mut catalog = ModelCatalog::new();
|
||||
|
||||
// merge_discovered_models adds models with Local tier
|
||||
catalog.merge_discovered_models("ollama", &["Custom-Model-7B".to_string()]);
|
||||
|
||||
// Verify it was added
|
||||
let found = catalog.find_model("Custom-Model-7B").unwrap();
|
||||
assert_eq!(found.tier, ModelTier::Local);
|
||||
assert_eq!(found.provider, "ollama");
|
||||
|
||||
// Case-insensitive lookup should prefer the Local-tier entry
|
||||
let lower = catalog.find_model("custom-model-7b").unwrap();
|
||||
assert_eq!(lower.tier, ModelTier::Local);
|
||||
assert_eq!(lower.provider, "ollama");
|
||||
}
|
||||
|
||||
/// Regression test for #833: find_model_for_provider should prefer the entry
|
||||
/// from the specified provider when multiple providers share the same model name.
|
||||
///
|
||||
/// Scenario: a custom provider "model_studio" has a model "kimi-k2.5", and the
|
||||
/// builtin "moonshot" provider also has "kimi-k2.5". When the user switches to
|
||||
/// "kimi-k2.5" with provider "model_studio", we must resolve to model_studio's
|
||||
/// entry, not moonshot's builtin.
|
||||
#[test]
|
||||
fn test_find_model_for_provider_prefers_specified_provider_833() {
|
||||
let mut catalog = ModelCatalog::new();
|
||||
|
||||
// Verify the builtin moonshot entry exists
|
||||
let builtin = catalog.find_model("kimi-k2.5").unwrap();
|
||||
assert_eq!(builtin.provider, "moonshot");
|
||||
|
||||
// Add a custom model with the same name on a different provider
|
||||
let added = catalog.add_custom_model(ModelCatalogEntry {
|
||||
id: "kimi-k2.5".into(),
|
||||
display_name: "Kimi K2.5 (Model Studio)".into(),
|
||||
provider: "model_studio".into(),
|
||||
tier: ModelTier::Balanced,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
});
|
||||
assert!(added, "custom model should be added (different provider)");
|
||||
|
||||
// Plain find_model returns the custom entry (Custom tier wins over builtin)
|
||||
let plain = catalog.find_model("kimi-k2.5").unwrap();
|
||||
assert_eq!(plain.tier, ModelTier::Custom);
|
||||
|
||||
// find_model_for_provider with "model_studio" must return model_studio's entry
|
||||
let ms = catalog
|
||||
.find_model_for_provider("kimi-k2.5", "model_studio")
|
||||
.unwrap();
|
||||
assert_eq!(ms.provider, "model_studio");
|
||||
assert_eq!(ms.display_name, "Kimi K2.5 (Model Studio)");
|
||||
|
||||
// find_model_for_provider with "moonshot" must return moonshot's builtin
|
||||
let moonshot = catalog
|
||||
.find_model_for_provider("kimi-k2.5", "moonshot")
|
||||
.unwrap();
|
||||
assert_eq!(moonshot.provider, "moonshot");
|
||||
assert_eq!(moonshot.display_name, "Kimi K2.5");
|
||||
}
|
||||
|
||||
/// Verify find_model_for_provider falls back to normal resolution when the
|
||||
/// model doesn't exist on the requested provider.
|
||||
#[test]
|
||||
fn test_find_model_for_provider_fallback() {
|
||||
let catalog = ModelCatalog::new();
|
||||
|
||||
// "claude-sonnet-4-20250514" only exists on "anthropic"
|
||||
let entry = catalog
|
||||
.find_model_for_provider("claude-sonnet-4-20250514", "nonexistent_provider")
|
||||
.unwrap();
|
||||
assert_eq!(entry.provider, "anthropic");
|
||||
}
|
||||
|
||||
/// Verify find_model_for_provider is case-insensitive for the model name.
|
||||
#[test]
|
||||
fn test_find_model_for_provider_case_insensitive() {
|
||||
let mut catalog = ModelCatalog::new();
|
||||
|
||||
catalog.add_custom_model(ModelCatalogEntry {
|
||||
id: "My-Custom-LLM".into(),
|
||||
display_name: "My Custom LLM".into(),
|
||||
provider: "custom_provider".into(),
|
||||
tier: ModelTier::Balanced,
|
||||
context_window: 32_768,
|
||||
max_output_tokens: 4_096,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
});
|
||||
|
||||
// Case-insensitive lookup with the correct provider
|
||||
let found = catalog
|
||||
.find_model_for_provider("my-custom-llm", "custom_provider")
|
||||
.unwrap();
|
||||
assert_eq!(found.provider, "custom_provider");
|
||||
assert_eq!(found.id, "My-Custom-LLM");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ use tracing::{debug, warn};
|
||||
/// Maximum inter-agent call depth to prevent infinite recursion (A->B->C->...).
|
||||
const MAX_AGENT_CALL_DEPTH: u32 = 5;
|
||||
|
||||
/// Check if a tool name refers to a shell execution tool.
|
||||
///
|
||||
/// Used to determine whether exec_policy settings should bypass the approval gate.
|
||||
fn is_shell_tool(name: &str) -> bool {
|
||||
name == "shell_exec"
|
||||
}
|
||||
|
||||
/// Check if a shell command should be blocked by taint tracking.
|
||||
///
|
||||
/// Layer 1: Shell metacharacter injection (backticks, `$(`, `${`, etc.)
|
||||
@@ -133,9 +140,28 @@ pub async fn execute_tool(
|
||||
}
|
||||
}
|
||||
|
||||
// Approval gate: check if this tool requires human approval before execution
|
||||
// Approval gate: check if this tool requires human approval before execution.
|
||||
//
|
||||
// When exec_policy.mode = "full" (or allowlist with allowed_commands = ["*"]),
|
||||
// the user has explicitly opted into unrestricted shell access. In that case,
|
||||
// shell_exec should bypass the approval gate — requiring approval for commands
|
||||
// the user already whitelisted is contradictory (GitHub issue #772).
|
||||
let exec_policy_bypasses_approval = is_shell_tool(tool_name)
|
||||
&& exec_policy.is_some_and(|p| {
|
||||
p.mode == openfang_types::config::ExecSecurityMode::Full
|
||||
|| (p.mode == openfang_types::config::ExecSecurityMode::Allowlist
|
||||
&& p.allowed_commands.iter().any(|c| c == "*"))
|
||||
});
|
||||
|
||||
if exec_policy_bypasses_approval {
|
||||
debug!(
|
||||
tool_name,
|
||||
"Approval bypassed: exec_policy grants unrestricted shell access"
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(kh) = kernel {
|
||||
if kh.requires_approval(tool_name) {
|
||||
if !exec_policy_bypasses_approval && kh.requires_approval(tool_name) {
|
||||
let agent_id_str = caller_agent_id.unwrap_or("unknown");
|
||||
let input_str = input.to_string();
|
||||
let summary = format!(
|
||||
@@ -1549,7 +1575,7 @@ async fn tool_shell_exec(
|
||||
|
||||
// Truncate very long outputs to prevent memory issues
|
||||
let max_output = 100_000;
|
||||
let stdout_str = if stdout.len() > max_output {
|
||||
let mut stdout_str = if stdout.len() > max_output {
|
||||
format!(
|
||||
"{}...\n[truncated, {} total bytes]",
|
||||
crate::str_utils::safe_truncate_str(&stdout, max_output),
|
||||
@@ -1568,6 +1594,10 @@ async fn tool_shell_exec(
|
||||
stderr.to_string()
|
||||
};
|
||||
|
||||
if exit_code == 0 && stdout_str.is_empty() {
|
||||
stdout_str = "Command executed successfully".to_string();
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Exit code: {exit_code}\n\nSTDOUT:\n{stdout_str}\nSTDERR:\n{stderr_str}"
|
||||
))
|
||||
@@ -2447,7 +2477,7 @@ async fn tool_a2a_discover(input: &serde_json::Value) -> Result<String, String>
|
||||
let url = input["url"].as_str().ok_or("Missing 'url' parameter")?;
|
||||
|
||||
// SSRF protection: block private/metadata IPs
|
||||
if crate::web_fetch::check_ssrf(url).is_err() {
|
||||
if crate::web_fetch::check_ssrf(url, &[]).is_err() {
|
||||
return Err("SSRF blocked: URL resolves to a private or metadata address".to_string());
|
||||
}
|
||||
|
||||
@@ -2470,7 +2500,7 @@ async fn tool_a2a_send(
|
||||
// Resolve agent URL: either directly provided or looked up by name
|
||||
let url = if let Some(url) = input["agent_url"].as_str() {
|
||||
// SSRF protection
|
||||
if crate::web_fetch::check_ssrf(url).is_err() {
|
||||
if crate::web_fetch::check_ssrf(url, &[]).is_err() {
|
||||
return Err("SSRF blocked: URL resolves to a private or metadata address".to_string());
|
||||
}
|
||||
url.to_string()
|
||||
@@ -3647,10 +3677,13 @@ mod tests {
|
||||
None, // process_manager
|
||||
)
|
||||
.await;
|
||||
// Should NOT be "Permission denied" — it should normalize to file_write
|
||||
// and pass the capability check. It will fail for other reasons (path validation).
|
||||
// Should NOT be the capability-check denial — it should normalize to file_write
|
||||
// and pass the capability check. It may fail for other reasons (path validation,
|
||||
// OS-level errors), but not the agent capability gate.
|
||||
assert!(
|
||||
!result.content.contains("Permission denied"),
|
||||
!result
|
||||
.content
|
||||
.contains("does not have capability to use tool"),
|
||||
"fs-write should normalize to file_write and pass capability check, got: {}",
|
||||
result.content
|
||||
);
|
||||
|
||||
@@ -53,7 +53,7 @@ impl WebFetchEngine {
|
||||
let method_upper = method.to_uppercase();
|
||||
|
||||
// Step 1: SSRF protection — BEFORE any network I/O
|
||||
check_ssrf(url)?;
|
||||
check_ssrf(url, &self.config.ssrf_allowed_hosts)?;
|
||||
|
||||
// Step 2: Cache lookup (only for GET)
|
||||
let cache_key = format!("fetch:{}:{}", method_upper, url);
|
||||
@@ -185,7 +185,14 @@ fn is_html(content_type: &str, body: &str) -> bool {
|
||||
/// Check if a URL targets a private/internal network resource.
|
||||
/// Blocks localhost, metadata endpoints, and private IPs.
|
||||
/// Must run BEFORE any network I/O.
|
||||
pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
|
||||
///
|
||||
/// The `allowed_hosts` slice lets self-hosted deployments bypass the
|
||||
/// private-IP check for specific hosts. Entries can be exact hostnames
|
||||
/// (`"n8n.local"`), wildcard domains (`"*.olares.com"`), or CIDR ranges
|
||||
/// (`"10.0.0.0/8"`).
|
||||
///
|
||||
/// **Cloud metadata endpoints are NEVER allowed regardless of the allowlist.**
|
||||
pub(crate) fn check_ssrf(url: &str, allowed_hosts: &[String]) -> Result<(), String> {
|
||||
// Only allow http:// and https:// schemes
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
return Err("Only http:// and https:// URLs are allowed".to_string());
|
||||
@@ -200,6 +207,7 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
|
||||
};
|
||||
|
||||
// Hostname-based blocklist (catches metadata endpoints)
|
||||
// These are UNCONDITIONALLY blocked — no allowlist can override them.
|
||||
let blocked = [
|
||||
"localhost",
|
||||
"ip6-localhost",
|
||||
@@ -217,13 +225,28 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
|
||||
return Err(format!("SSRF blocked: {hostname} is a restricted hostname"));
|
||||
}
|
||||
|
||||
// Check if the hostname is explicitly allowed before doing DNS resolution.
|
||||
if is_host_allowed(hostname, allowed_hosts) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Resolve DNS and check every returned IP
|
||||
let port = if url.starts_with("https") { 443 } else { 80 };
|
||||
let socket_addr = format!("{hostname}:{port}");
|
||||
if let Ok(addrs) = socket_addr.to_socket_addrs() {
|
||||
for addr in addrs {
|
||||
let ip = addr.ip();
|
||||
if is_metadata_ip(&ip) {
|
||||
// Metadata IPs are NEVER allowed, even via allowlist.
|
||||
return Err(format!(
|
||||
"SSRF blocked: {hostname} resolves to metadata IP {ip}"
|
||||
));
|
||||
}
|
||||
if ip.is_loopback() || ip.is_unspecified() || is_private_ip(&ip) {
|
||||
// Check if the resolved IP matches a CIDR in the allowlist.
|
||||
if is_ip_allowed(&ip, allowed_hosts) {
|
||||
continue;
|
||||
}
|
||||
return Err(format!(
|
||||
"SSRF blocked: {hostname} resolves to private IP {ip}"
|
||||
));
|
||||
@@ -234,6 +257,97 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns true if an IP is a cloud metadata endpoint address.
|
||||
fn is_metadata_ip(ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
let octets = v4.octets();
|
||||
// 169.254.169.254 (AWS/GCP/Azure IMDS)
|
||||
octets == [169, 254, 169, 254]
|
||||
// 100.100.100.200 (Alibaba Cloud IMDS)
|
||||
|| octets == [100, 100, 100, 200]
|
||||
// 192.0.0.192 (Azure IMDS alternative)
|
||||
|| octets == [192, 0, 0, 192]
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a hostname matches any entry in the allowlist.
|
||||
/// Supports exact match and wildcard domains (`*.example.com`).
|
||||
fn is_host_allowed(hostname: &str, allowed_hosts: &[String]) -> bool {
|
||||
let lower = hostname.to_lowercase();
|
||||
for entry in allowed_hosts {
|
||||
let entry_lower = entry.to_lowercase();
|
||||
// Exact match
|
||||
if entry_lower == lower {
|
||||
return true;
|
||||
}
|
||||
// Wildcard domain: *.example.com matches sub.example.com
|
||||
if let Some(suffix) = entry_lower.strip_prefix("*.") {
|
||||
if lower.ends_with(&format!(".{suffix}")) || lower == suffix {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if an IP address matches any CIDR entry in the allowlist.
|
||||
fn is_ip_allowed(ip: &IpAddr, allowed_hosts: &[String]) -> bool {
|
||||
for entry in allowed_hosts {
|
||||
if let Some(pos) = entry.find('/') {
|
||||
// Parse as CIDR: base_ip/prefix_len
|
||||
let base_str = &entry[..pos];
|
||||
let prefix_str = &entry[pos + 1..];
|
||||
if let (Ok(base_ip), Ok(prefix_len)) =
|
||||
(base_str.parse::<IpAddr>(), prefix_str.parse::<u32>())
|
||||
{
|
||||
if ip_in_cidr(ip, &base_ip, prefix_len) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if let Ok(entry_ip) = entry.parse::<IpAddr>() {
|
||||
// Exact IP match
|
||||
if *ip == entry_ip {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if `ip` falls within the CIDR block `base/prefix_len`.
|
||||
fn ip_in_cidr(ip: &IpAddr, base: &IpAddr, prefix_len: u32) -> bool {
|
||||
match (ip, base) {
|
||||
(IpAddr::V4(ip4), IpAddr::V4(base4)) => {
|
||||
if prefix_len > 32 {
|
||||
return false;
|
||||
}
|
||||
if prefix_len == 0 {
|
||||
return true;
|
||||
}
|
||||
let ip_bits = u32::from_be_bytes(ip4.octets());
|
||||
let base_bits = u32::from_be_bytes(base4.octets());
|
||||
let mask = !0u32 << (32 - prefix_len);
|
||||
(ip_bits & mask) == (base_bits & mask)
|
||||
}
|
||||
(IpAddr::V6(ip6), IpAddr::V6(base6)) => {
|
||||
if prefix_len > 128 {
|
||||
return false;
|
||||
}
|
||||
if prefix_len == 0 {
|
||||
return true;
|
||||
}
|
||||
let ip_bits = u128::from_be_bytes(ip6.octets());
|
||||
let base_bits = u128::from_be_bytes(base6.octets());
|
||||
let mask = !0u128 << (128 - prefix_len);
|
||||
(ip_bits & mask) == (base_bits & mask)
|
||||
}
|
||||
_ => false, // mismatched families
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an IP address is in a private range.
|
||||
fn is_private_ip(ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
@@ -308,8 +422,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_blocks_localhost() {
|
||||
assert!(check_ssrf("http://localhost/admin").is_err());
|
||||
assert!(check_ssrf("http://localhost:8080/api").is_err());
|
||||
assert!(check_ssrf("http://localhost/admin", &[]).is_err());
|
||||
assert!(check_ssrf("http://localhost:8080/api", &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -323,8 +437,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_blocks_metadata() {
|
||||
assert!(check_ssrf("http://169.254.169.254/latest/meta-data/").is_err());
|
||||
assert!(check_ssrf("http://metadata.google.internal/computeMetadata/v1/").is_err());
|
||||
assert!(check_ssrf("http://169.254.169.254/latest/meta-data/", &[]).is_err());
|
||||
assert!(check_ssrf("http://metadata.google.internal/computeMetadata/v1/", &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -339,28 +453,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_blocks_non_http() {
|
||||
assert!(check_ssrf("file:///etc/passwd").is_err());
|
||||
assert!(check_ssrf("ftp://internal.corp/data").is_err());
|
||||
assert!(check_ssrf("gopher://evil.com").is_err());
|
||||
assert!(check_ssrf("file:///etc/passwd", &[]).is_err());
|
||||
assert!(check_ssrf("ftp://internal.corp/data", &[]).is_err());
|
||||
assert!(check_ssrf("gopher://evil.com", &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_blocks_cloud_metadata() {
|
||||
// Alibaba Cloud IMDS
|
||||
assert!(check_ssrf("http://100.100.100.200/latest/meta-data/").is_err());
|
||||
assert!(check_ssrf("http://100.100.100.200/latest/meta-data/", &[]).is_err());
|
||||
// Azure IMDS alternative
|
||||
assert!(check_ssrf("http://192.0.0.192/metadata/instance").is_err());
|
||||
assert!(check_ssrf("http://192.0.0.192/metadata/instance", &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_blocks_zero_ip() {
|
||||
assert!(check_ssrf("http://0.0.0.0/").is_err());
|
||||
assert!(check_ssrf("http://0.0.0.0/", &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_blocks_ipv6_localhost() {
|
||||
assert!(check_ssrf("http://[::1]/admin").is_err());
|
||||
assert!(check_ssrf("http://[::1]:8080/api").is_err());
|
||||
assert!(check_ssrf("http://[::1]/admin", &[]).is_err());
|
||||
assert!(check_ssrf("http://[::1]:8080/api", &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -374,4 +488,47 @@ mod tests {
|
||||
let h3 = extract_host("http://[::1]/path");
|
||||
assert_eq!(h3, "[::1]:80");
|
||||
}
|
||||
|
||||
// ── SSRF allowlist tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_allowlist_permits_private_ip() {
|
||||
// A CIDR allowlist entry should permit an otherwise-blocked private IP.
|
||||
let allow = vec!["10.0.0.0/8".to_string()];
|
||||
assert!(check_ssrf("http://10.1.2.3", &allow).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_allowlist_still_blocks_metadata() {
|
||||
// Even if the allowlist covers the entire link-local range,
|
||||
// cloud metadata endpoints must NEVER be permitted.
|
||||
let allow = vec!["169.254.0.0/16".to_string()];
|
||||
assert!(check_ssrf("http://169.254.169.254/latest/meta-data/", &allow).is_err());
|
||||
// Also verify hostname-based metadata blocks
|
||||
let allow2 = vec!["metadata.google.internal".to_string()];
|
||||
assert!(check_ssrf("http://metadata.google.internal/computeMetadata/v1/", &allow2).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_allowlist_wildcard_domain() {
|
||||
let allow = vec!["*.example.com".to_string()];
|
||||
assert!(check_ssrf("http://api.example.com", &allow).is_ok());
|
||||
// Non-matching domain should still go through normal checks
|
||||
assert!(is_host_allowed("other.net", &allow) == false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_allowlist_exact_hostname() {
|
||||
let allow = vec!["n8n.local".to_string()];
|
||||
assert!(check_ssrf("http://n8n.local/webhook", &allow).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cidr_matching() {
|
||||
let ip_in: IpAddr = "10.1.2.3".parse().unwrap();
|
||||
let ip_out: IpAddr = "11.0.0.1".parse().unwrap();
|
||||
let base: IpAddr = "10.0.0.0".parse().unwrap();
|
||||
assert!(ip_in_cidr(&ip_in, &base, 8));
|
||||
assert!(!ip_in_cidr(&ip_out, &base, 8));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user