diff --git a/.gitignore b/.gitignore index 77dd181..51d57b2 100644 --- a/.gitignore +++ b/.gitignore @@ -28,9 +28,12 @@ build/ .env !.env.example -# MCP files +# MCP config files openspace/config/config_mcp.json +# Communication config files +openspace/config/config_communication.json + # Logs logs/ diff --git a/README.md b/README.md index da61f1c..83eecee 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,31 @@ [![WeChat](https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat&logo=wechat&logoColor=white)](./COMMUNICATION.md) [![中文文档](https://img.shields.io/badge/文档-中文版-F5C6C6?style=flat)](./README_CN.md) +**One Command to Evolve All Your AI Agents**: OpenClaw, nanobot, Claude Code, Codex, Cursor and etc. + +openspace --query your task + --- +## 📢 News + +- **2026-04-09** 💬 Multi-channel **communication gateway**. OpenSpace can now receive and respond to messages from external platforms. Ships with **WhatsApp** (Baileys bridge + QR auth) and **Feishu** (HTTP webhook) adapters, session management, attachment caching, and allowlist-based access control. See [`openspace/config/README.md`](openspace/config/README.md) for setup. +- **2026-04-07** 🌐 OpenSpace MCP now supports standalone **SSE** and **streamable HTTP** startup, making it easier for remote hosts to connect over HTTP instead of stdio and bypass stdio-bound MCP server timeout bottlenecks. See the [host integration guide](openspace/host_skills/README.md) for setup details. +- **2026-04-06** 🛠️ Fixed multiple runtime issues across grounding, MCP serving, skill evolution, and persistence, improving execution stability and recovery in long-running workflows. +- **2026-04-05** 🧭 Cleaned up LLM credential resolution: centralized `.env` loading, improved host config auto-detection, and made provider-native env handling more consistent. +- **2026-04-03** 🚀 Released **v0.1.0** — Skill quality monitoring: structural patterns extracted from high-quality skills now evaluate every new submission daily. Faster, more relevant cloud search. Production-grade vertical skill clusters emerging organically from the community. Frontend now supports Chinese (zh) i18n. +- **2026-04-02** ⚡ Cloud search upgraded for higher relevance and lower latency. +- **2026-03-31** 🛡️ Security hardening: hardened zip extraction and `import_skill` against path traversal. CLI now respects `OPENSPACE_MODEL` and `OPENSPACE_LLM_*` env vars; MiniMax compatibility; workflow ID collision fixes. +- **2026-03-29** 🔒 Pinned litellm to <1.82.7 to avoid PYSEC-2026-2 supply-chain attack. +- **2026-03-28** 🔧 Idempotent skill registration — `register_skill_dir` now returns existing `SkillMeta` for already-registered skills. Updated OpenClaw setup docs. +- **2026-03-27** 🪟 Fixed stdio deadlock on Windows; improved evolver confirmation parsing with stem-style keyword matching. +- **2026-03-26** 🌱 Dynamic skill directory re-scanning on each call, lightweight local skill search, and streamlined documentation. +- **2026-03-25** 🎉 OpenSpace is now open source! + +--- + ## The Problem with Today's AI Agents Today's AI agents — [OpenClaw](https://github.com/openclaw/openclaw), [nanobot](https://github.com/HKUDS/nanobot), [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [Cursor](https://cursor.com), etc. — are powerful, but they have a critical weakness: they never **Learn**, **Adapt**, and **Evolve** from real-world experience — let alone **Share** with each other. @@ -140,6 +161,15 @@ pip install -e . openspace-mcp --help # verify installation ``` +> [!TIP] +> **Slow clone?** The `assets/` folder (~50 MB of images) makes the default clone large. Use this lightweight alternative to skip it: +> ```bash +> git clone --filter=blob:none --sparse https://github.com/HKUDS/OpenSpace.git +> cd OpenSpace +> git sparse-checkout set '/*' '!assets/' +> pip install -e . +> ``` + **Choose your path:** - **[Path A](#-path-a-for-your-agent)** — Plug OpenSpace into your agent - **[Path B](#-path-b-as-your-co-worker)** — Use OpenSpace directly as your AI co-worker @@ -169,6 +199,18 @@ Works with any agent that supports skills (`SKILL.md`) — [Claude Code](https:/ > [!TIP] > Credentials (API key, model) are **auto-detected** from your agent's config; you usually don't need to set them manually. +> [!NOTE] +> OpenSpace supports 3 launch modes: +> - **stdio**: keep `command: "openspace-mcp"` in the host config. +> - **SSE**: start `openspace-mcp --transport sse --host 127.0.0.1 --port 8080`. +> - **streamable HTTP**: start `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081`. +> +> Common remote endpoints: +> - SSE endpoint: `http://127.0.0.1:8080/sse` +> - streamable HTTP endpoint: `http://127.0.0.1:8081/mcp` +> +> `stdio` is the simplest option. HTTP modes keep OpenSpace as a standalone server, but **host-specific registration syntax** and **host-side timeouts** still apply. + **② Copy skills** into your agent's skills directory: ```bash @@ -480,6 +522,14 @@ OpenSpace/ │ │ ├── auth.py # API key management │ │ └── cli/ # CLI tools (download_skill, upload_skill) │ │ +│ ├── 💬 communication/ # Multi-Channel Communication Gateway +│ │ ├── gateway.py # Message routing, session management, reply dispatch +│ │ ├── adapters/ # Platform adapters (WhatsApp, Feishu) +│ │ ├── bridges/ # Non-Python runtimes (WhatsApp Baileys bridge) +│ │ ├── config.py # Communication config loader +│ │ ├── session_store.py # Per-channel session persistence +│ │ └── types.py # ChannelMessage, ChannelSource, SendResult +│ │ │ ├── 🔧 platform/ # Platform abstraction (system info, screenshots) │ ├── 🔧 host_detection/ # Auto-detect nanobot / openclaw credentials │ ├── 🔧 host_skills/ # SKILL.md definitions for agent integration @@ -531,7 +581,19 @@ OpenSpace builds upon the following open-source projects. We sincerely thank the
-**🌟 Star us if OpenSpace helps your agent!** +## ⭐ Star History + +If you find OpenSpace helpful, please consider giving us a star! ⭐ + +
+ + + + + Star History Chart + + +
**🧬 Make You Agent Self-Evolve · 🌐 A Community That Grows Together · 💰 Fewer Tokens, Smarter Agents** diff --git a/README_CN.md b/README_CN.md index a47342e..1014b1b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -15,10 +15,31 @@ [![Feishu](https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat&logo=larksuite&logoColor=white)](./COMMUNICATION.md) [![WeChat](https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat&logo=wechat&logoColor=white)](./COMMUNICATION.md) +**一条命令,进化你所有的 AI Agent**:OpenClaw、nanobot、Claude Code、Codex、Cursor 等 + +openspace --query your task +
--- +## 📢 最新动态 + +- **2026-04-09** 💬 多渠道**通信网关**上线。OpenSpace 现可接收并回复外部平台消息。内置 **WhatsApp**(Baileys bridge + 扫码认证)与**飞书**(HTTP webhook)适配器,支持会话管理、附件缓存和白名单访问控制。配置方式见 [`openspace/config/README.md`](openspace/config/README.md)。 +- **2026-04-07** 🌐 OpenSpace MCP 新增独立 **SSE** 与 **streamable HTTP** 启动方式,便于远端 host 通过 HTTP 接入,绕过基于 stdio 的 MCP server timeout 瓶颈。具体接入方式见 [host integration 文档](openspace/host_skills/README.md)。 +- **2026-04-06** 🛠️ 修复多项运行时问题,覆盖 grounding、MCP 服务、skill 进化与持久化链路,长流程执行的稳定性与恢复能力进一步提升。 +- **2026-04-05** 🧭 LLM 凭证解析清理完成:统一 `.env` 加载逻辑,改进宿主配置自动识别,并让 provider 原生环境变量处理更一致。 +- **2026-04-03** 🚀 发布 **v0.1.0** — Skill 质量监控上线:从优质 Skill 中提取结构模式,每日自动评估所有新提交;云端搜索全面升级,匹配更准、响应更快;社区自发形成生产级垂直 Skill 集群。前端新增中文(zh)国际化支持。 +- **2026-04-02** ⚡ 云端搜索升级,提升匹配质量、降低响应延迟。 +- **2026-03-31** 🛡️ 安全加固:zip 解压与 `import_skill` 新增路径穿越防护;CLI 启动时读取 `OPENSPACE_MODEL` 及 `OPENSPACE_LLM_*` 环境变量;修复 MiniMax 兼容性问题与 workflow ID 冲突。 +- **2026-03-29** 🔒 锁定 litellm 版本至 <1.82.7,规避 PYSEC-2026-2 供应链投毒。 +- **2026-03-28** 🔧 Skill 注册幂等化——`register_skill_dir` 对已注册目录直接返回已有 `SkillMeta`,不再重复创建。同步更新 OpenClaw 部署文档。 +- **2026-03-27** 🪟 修复 Windows 下 stdio 死锁;evolver 确认解析改用词干匹配,消除误判。 +- **2026-03-26** 🌱 Skill 目录支持每次调用时动态重扫描,本地搜索更轻量,文档同步精简。 +- **2026-03-25** 🎉 OpenSpace 正式开源! + +--- + ## 当前 AI Agent 面临的问题 如今的 AI Agent——[OpenClaw](https://github.com/openclaw/openclaw)、[nanobot](https://github.com/HKUDS/nanobot)、[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Codex](https://github.com/openai/codex)、[Cursor](https://cursor.com) 等——能力强大,但有一个致命弱点:它们从不从真实世界的经验中**学习**、**适应**和**进化**——更不用说相互之间的**共享**了。 @@ -140,6 +161,15 @@ pip install -e . openspace-mcp --help # 验证安装 ``` +> [!TIP] +> **Clone 太慢?** `assets/` 目录包含约 50 MB 的图片文件,导致仓库较大。使用以下轻量方式跳过它: +> ```bash +> git clone --filter=blob:none --sparse https://github.com/HKUDS/OpenSpace.git +> cd OpenSpace +> git sparse-checkout set '/*' '!assets/' +> pip install -e . +> ``` + **选择你的路径:** - **[路径 A](#-路径-a为你的-agent-接入)** — 将 OpenSpace 接入你的 Agent - **[路径 B](#-路径-b作为你的-ai-协作者)** — 直接使用 OpenSpace 作为你的 AI 协作者 @@ -169,6 +199,18 @@ openspace-mcp --help # 验证安装 > [!TIP] > 凭证(API 密钥、模型)会从你的 Agent 配置中**自动检测**,通常无需手动设置。 +> [!NOTE] +> OpenSpace 支持 3 种启动方式: +> - **stdio**:在宿主配置里保留 `command: "openspace-mcp"`。 +> - **SSE**:先启动 `openspace-mcp --transport sse --host 127.0.0.1 --port 8080`。 +> - **streamable HTTP**:先启动 `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081`。 +> +> 通用远端 endpoint: +> - SSE: `http://127.0.0.1:8080/sse` +> - streamable HTTP: `http://127.0.0.1:8081/mcp` +> +> `stdio` 最简单。HTTP 模式会把 OpenSpace 作为独立服务常驻,但 **不同宿主的注册写法不同**,而且 **调用方自己的 timeout 仍然生效**。 + **② 将 Skill 复制**到你的 Agent Skill 目录: ```bash @@ -480,6 +522,14 @@ OpenSpace/ │ │ ├── auth.py # API 密钥管理 │ │ └── cli/ # CLI 工具(download_skill、upload_skill) │ │ +│ ├── 💬 communication/ # 多渠道通信网关 +│ │ ├── gateway.py # 消息路由、会话管理、回复分发 +│ │ ├── adapters/ # 平台适配器(WhatsApp、飞书) +│ │ ├── bridges/ # 非 Python 运行时(WhatsApp Baileys bridge) +│ │ ├── config.py # 通信配置加载 +│ │ ├── session_store.py # 按频道的会话持久化 +│ │ └── types.py # ChannelMessage, ChannelSource, SendResult +│ │ │ ├── 🔧 platform/ # 平台抽象(系统信息、截图) │ ├── 🔧 host_detection/ # 自动检测 nanobot / openclaw 凭证 │ ├── 🔧 host_skills/ # 面向 Agent 集成的 SKILL.md 定义 diff --git a/assets/cli-typing.gif b/assets/cli-typing.gif new file mode 100644 index 0000000..ec67739 Binary files /dev/null and b/assets/cli-typing.gif differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6de88c8..b4630ac 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,9 +9,11 @@ "version": "0.1.0", "dependencies": { "axios": "^1.7.9", + "i18next": "^26.0.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-force-graph-2d": "^1.25.4", + "react-i18next": "^17.0.2", "react-router-dom": "^7.1.1" }, "devDependencies": { @@ -272,6 +274,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2297,6 +2308,46 @@ "node": ">= 0.4" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.0.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.3.tgz", + "integrity": "sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/index-array-by": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", @@ -2916,6 +2967,33 @@ "react": "*" } }, + "node_modules/react-i18next": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.2.tgz", + "integrity": "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -3319,7 +3397,7 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -3360,6 +3438,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3473,6 +3560,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index ef1907d..2005cf7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,9 +10,11 @@ }, "dependencies": { "axios": "^1.7.9", + "i18next": "^26.0.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-force-graph-2d": "^1.25.4", + "react-i18next": "^17.0.2", "react-router-dom": "^7.1.1" }, "devDependencies": { diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index c50f737..855d55c 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,4 +1,5 @@ import { Component, ErrorInfo, ReactNode } from 'react'; +import i18n from '../i18n'; interface Props { children: ReactNode; @@ -21,7 +22,6 @@ export class ErrorBoundary extends Component { } componentDidCatch(error: Error, errorInfo: ErrorInfo) { - // Log error to reporting service if (import.meta.env.DEV) { console.error('Error caught by boundary:', error, errorInfo); } @@ -29,25 +29,26 @@ export class ErrorBoundary extends Component { render() { if (this.state.hasError) { + const t = i18n.t.bind(i18n); return ( this.props.fallback || (

- Something went wrong + {t('errorBoundary.title')}

- An unexpected error occurred + {t('errorBoundary.message')}

{import.meta.env.DEV && this.state.error && (
- Error details + {t('errorBoundary.details')}
                     {this.state.error.stack}
                   
diff --git a/frontend/src/components/skill-detail/DiffViewer.tsx b/frontend/src/components/skill-detail/DiffViewer.tsx index 3a37b65..49b37b9 100644 --- a/frontend/src/components/skill-detail/DiffViewer.tsx +++ b/frontend/src/components/skill-detail/DiffViewer.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState, type KeyboardEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import type { DiffFile, DiffLine } from '../../utils/diffParser'; interface DiffViewerProps { @@ -124,6 +125,7 @@ function buildSplitRows(lines: DiffLine[], header: string): SplitDiffRow[] { } export default function DiffViewer({ files }: DiffViewerProps) { + const { t } = useTranslation(); const [selectedIndex, setSelectedIndex] = useState(0); const renderableFiles = useMemo( () => files.filter((file) => file.hunks.some((hunk) => hunk.lines.length > 0)), @@ -135,7 +137,7 @@ export default function DiffViewer({ files }: DiffViewerProps) { }, [renderableFiles]); if (renderableFiles.length === 0) { - return

No files in diff

; + return

{t('diffViewer.noFiles')}

; } const activeIndex = selectedIndex < renderableFiles.length ? selectedIndex : 0; @@ -216,8 +218,8 @@ export default function DiffViewer({ files }: DiffViewerProps) { return (
-
Old
-
New
+
{t('diffViewer.old')}
+
{t('diffViewer.new')}
{hunk.header} diff --git a/frontend/src/components/skill-detail/SkillEvolutionGraph.tsx b/frontend/src/components/skill-detail/SkillEvolutionGraph.tsx index 689749c..7675e7f 100644 --- a/frontend/src/components/skill-detail/SkillEvolutionGraph.tsx +++ b/frontend/src/components/skill-detail/SkillEvolutionGraph.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import ForceGraph2D from 'react-force-graph-2d'; import type { SkillGraphNode } from '../../hooks/useSkillEvolutionGraphData'; @@ -25,6 +26,7 @@ export default function SkillEvolutionGraph({ onNodeClick, onBackgroundClick, }: SkillEvolutionGraphProps) { + const { t } = useTranslation(); const graphContainerRef = useRef(null); const fgRef = useRef(null); const [graphDim, setGraphDim] = useState({ width: 0, height: 0 }); @@ -180,7 +182,7 @@ export default function SkillEvolutionGraph({ }, []); if (graphData.nodes.length === 0) { - return
No lineage graph data.
; + return
{t('graph.noGraphData')}
; } return ( @@ -197,9 +199,9 @@ export default function SkillEvolutionGraph({ const graphNode = node as SkillGraphNode; return [ graphNode.name, - `score: ${graphNode.score.toFixed(1)}`, - `generation: ${graphNode.generation}`, - `origin: ${graphNode.origin}`, + t('graph.tooltipScore', { value: graphNode.score.toFixed(1) }), + t('graph.tooltipGeneration', { value: graphNode.generation }), + t('graph.tooltipOrigin', { value: graphNode.origin }), ].join('\n'); }} onNodeClick={(node) => { diff --git a/frontend/src/components/skill-detail/SkillVersionDrawer.tsx b/frontend/src/components/skill-detail/SkillVersionDrawer.tsx index b842b92..363f560 100644 --- a/frontend/src/components/skill-detail/SkillVersionDrawer.tsx +++ b/frontend/src/components/skill-detail/SkillVersionDrawer.tsx @@ -1,6 +1,7 @@ import { useLayoutEffect, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import type { SkillDetail } from '../../api'; import { parseDiff } from '../../utils/diffParser'; import EmptyState from '../EmptyState'; @@ -72,6 +73,7 @@ function lockScroll() { } export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVersionDrawerProps) { + const { t } = useTranslation(); const closeButtonRef = useRef(null); const rawDiff = skill?.lineage.content_diff ?? ''; @@ -135,16 +137,16 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
-

Skill Version

+

{t('drawer.skillVersion')}

{skill.name}

{skill.skill_id}

- Open as main + {t('drawer.openAsMain')}
@@ -153,13 +155,13 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
-
Version Summary
-
{skill.description || 'No description available for this version.'}
+
{t('drawer.versionSummary')}
+
{skill.description || t('drawer.noDescriptionAvailable')}
{skill.category} {skill.origin} - gen {skill.generation} - {skill.is_active ? 'active' : 'inactive'} + {t('drawer.gen', { generation: skill.generation })} + {skill.is_active ? t('common.active') : t('common.inactive')} {skill.tags.map((tag) => ( {tag} ))} @@ -167,67 +169,67 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
{skill.score.toFixed(1)}
-
version score
+
{t('drawer.versionScore')}
-
Metrics
-

Execution quality

+
{t('drawer.metrics')}
+

{t('drawer.executionQuality')}

- - - - + + + +
-
Selections
{skill.total_selections}
-
Applied
{skill.total_applied}
-
Completions
{skill.total_completions}
-
Fallbacks
{skill.total_fallbacks}
+
{t('drawer.selectionsLabel')}
{skill.total_selections}
+
{t('drawer.appliedLabel')}
{skill.total_applied}
+
{t('drawer.completionsLabel')}
{skill.total_completions}
+
{t('drawer.fallbacksLabel')}
{skill.total_fallbacks}
-

Version Metadata

-

Origin: {skill.origin}

-

Generation: {skill.generation}

-

Visibility: {skill.visibility}

-

Created: {formatDate(skill.lineage.created_at)}

-

First seen: {formatDate(skill.first_seen)}

-

Last updated: {formatDate(skill.last_updated)}

-

Skill path: {skill.path || 'Unavailable'}

-

Skill dir: {skill.skill_dir || 'Unavailable'}

-

Parent IDs: {skill.parent_skill_ids.length ? skill.parent_skill_ids.join(', ') : 'None'}

-

Change summary: {skill.lineage.change_summary || 'None'}

-

Effective score: {formatPercent(skill.effective_rate)}

+

{t('drawer.versionMetadata')}

+

{t('drawer.origin')} {skill.origin}

+

{t('drawer.generation')} {skill.generation}

+

{t('drawer.visibility')} {skill.visibility}

+

{t('drawer.created')} {formatDate(skill.lineage.created_at)}

+

{t('drawer.firstSeen')} {formatDate(skill.first_seen)}

+

{t('drawer.lastUpdated')} {formatDate(skill.last_updated)}

+

{t('drawer.skillPath')} {skill.path || t('common.unavailable')}

+

{t('drawer.skillDir')} {skill.skill_dir || t('common.unavailable')}

+

{t('drawer.parentIds')} {skill.parent_skill_ids.length ? skill.parent_skill_ids.join(', ') : t('common.none')}

+

{t('drawer.changeSummary')} {skill.lineage.change_summary || t('common.none')}

+

{t('drawer.effectiveScore')} {formatPercent(skill.effective_rate)}

-
Diff
-

Content diff

+
{t('drawer.diff')}
+

{t('drawer.contentDiff')}

{isOversizedDiff ? ( - + ) : canShowDiff ? ( diffFiles.length > 0 ? ( ) : ( - + ) ) : ( - + )}
-
Source
-

SKILL.md preview

+
{t('drawer.source')}
+

{t('drawer.skillMdPreview')}

{sourcePreview ? (
@@ -235,14 +237,14 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
{sourcePreview.content}
) : ( - + )}
-
Analyses
-

Recent execution analyses

+
{t('drawer.analyses')}
+

{t('drawer.recentAnalyses')}

{skill.recent_analyses.length > 0 ? (
@@ -252,15 +254,19 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
{analysis.task_id}
{formatDate(analysis.timestamp)}
-
{truncate(analysis.execution_note || 'No execution note', 220)}
+
{truncate(analysis.execution_note || t('drawer.noExecutionNote'), 220)}
- completed: {analysis.task_completed ? 'yes' : 'no'} · tool issues: {analysis.tool_issues.length} · suggestions: {analysis.evolution_suggestions.length} + {t('drawer.analysisCompleted', { + value: analysis.task_completed ? t('common.yes') : t('common.no'), + toolIssues: analysis.tool_issues.length, + suggestions: analysis.evolution_suggestions.length, + })}
))}
) : ( - + )} diff --git a/frontend/src/components/skill-detail/SkillVersionFilterBar.tsx b/frontend/src/components/skill-detail/SkillVersionFilterBar.tsx index 381b3a2..02aaddd 100644 --- a/frontend/src/components/skill-detail/SkillVersionFilterBar.tsx +++ b/frontend/src/components/skill-detail/SkillVersionFilterBar.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + interface SkillVersionFilterBarProps { originFilter: string; onOriginFilterChange: (value: string) => void; @@ -15,16 +17,18 @@ export default function SkillVersionFilterBar({ allOrigins, allTags, }: SkillVersionFilterBarProps) { + const { t } = useTranslation(); + return (
- + onTagFilterChange(event.target.value)} className="border border-[color:var(--color-ink)] bg-transparent px-2 py-1 text-sm" > - + {allTags.map((tag) => ( ))} diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json new file mode 100644 index 0000000..5d476a3 --- /dev/null +++ b/frontend/src/i18n/en.json @@ -0,0 +1,253 @@ +{ + "nav": { + "dashboard": "Dashboard", + "skills": "Skills", + "workflows": "Workflows" + }, + "common": { + "loading": "Loading…", + "yes": "yes", + "no": "no", + "none": "None", + "unavailable": "Unavailable", + "unknown": "Unknown", + "close": "Close", + "score": "score", + "success": "success", + "active": "active", + "inactive": "inactive", + "noDescription": "No description", + "steps_one": "{{count}} step", + "steps_other": "{{count}} steps", + "agentActions_one": "{{count}} agent action", + "agentActions_other": "{{count}} agent actions", + "tags": "+{{count}} tags" + }, + "langSwitch": { + "label": "Language" + }, + "errorBoundary": { + "title": "Something went wrong", + "message": "An unexpected error occurred", + "goToDashboard": "Go to Dashboard", + "details": "Error details" + }, + "dashboard": { + "title": "Dashboard", + "loadingDashboard": "Loading dashboard…", + "failedToLoad": "Failed to load overview", + "dashboardUnavailable": "Dashboard unavailable", + "totalSkills": "Total Skills", + "activeHint": "Active: {{count}}", + "avgSkillScore": "Average Skill Score", + "avgScoreHint": "Primary metric = effective rate × 100", + "workflowSessions": "Workflow Sessions", + "localRepo": "local repo", + "workspace": "workspace", + "recordedUnder": "Recorded under {{location}}", + "workflowSuccess": "Workflow Success", + "avgSuccessHint": "Average session success rate", + "health": "Health", + "runtimeSnapshot": "Runtime snapshot", + "status": "Status", + "dbPath": "DB Path", + "workflowCount": "Workflow Count", + "builtFrontend": "Built Frontend", + "skillsSection": "Skills", + "topScoredSkills": "Top scored skills", + "noSkillsYet": "No skills yet", + "noSkillsDesc": "Run OpenSpace tasks or sync skills into the local registry first.", + "effective": "effective {{value}}", + "applied": "applied {{value}}", + "selections": "selections {{count}}", + "workflowsSection": "Workflows", + "recentSessions": "Recent sessions", + "noWorkflowSessions": "No workflow sessions", + "noWorkflowDesc": "Recordings will appear after a task is executed with recording enabled." + }, + "skills": { + "title": "Skill classes", + "searchPlaceholder": "Search by name, id, description, tag, or origin", + "sortByScore": "Sort by best score", + "sortByUpdated": "Sort by updated time", + "sortByName": "Sort by name", + "skillClasses": "Skill Classes", + "versionsHint": "Versions: {{count}}", + "activeVersions": "Active Versions", + "withActivity": "With activity: {{count}}", + "avgBestScore": "Average Best Score", + "bestNodeScoreHint": "Best node score per class", + "selections": "Selections", + "completionsHint": "Completions: {{count}}", + "loadingSkills": "Loading skills…", + "failedToLoad": "Failed to load skills", + "noSkillsMatch": "No skills match", + "noSkillsMatchDesc": "Try another keyword, or execute tasks so new skill telemetry lands in SQLite.", + "bestScore": "best score", + "noClassDescription": "No class description", + "versions": "{{count}} versions", + "active": "{{count}} active", + "selectionsCount": "{{count}} selections" + }, + "skillDetail": { + "loadingDetail": "Loading skill detail…", + "failedToLoad": "Failed to load skill class", + "skillNotFound": "Skill not found", + "backToSkills": "← Back to Skills", + "anchoredOn": "Skill class anchored on {{id}}", + "skillClass": "Skill Class", + "evolutionOverview": "Evolution overview", + "activeTip": "active tip", + "inactiveAnchor": "inactive anchor", + "bestVersionScore": "best version score", + "skillDirectory": "Skill directory", + "latestVersionCreated": "Latest version created", + "representativeVersion": "Representative version", + "representativeUpdate": "Representative update", + "versions": "Versions", + "maxGeneration": "Max generation {{count}}", + "activeVersions": "Active Versions", + "originsCount": "Origins: {{count}}", + "averageScore": "Average Score", + "acrossAllVersions": "Across all versions in this lineage", + "selections": "Selections", + "representativeScore": "Representative score {{score}}", + "evolutionGraph": "Evolution Graph", + "versionLineage": "Version lineage", + "loadingDrawer": "Loading version drawer…", + "noLineageGraph": "No lineage graph", + "noLineageGraphDesc": "This skill does not yet have lineage data to visualize." + }, + "workflows": { + "title": "Recorded sessions", + "searchPlaceholder": "Search by task name or instruction", + "workflowSessions": "Workflow Sessions", + "scannedFrom": "Scanned from logs/recordings and logs/trajectories", + "averageSuccess": "Average Success", + "meanSuccessRate": "Mean success rate across sessions", + "loadingWorkflows": "Loading workflows…", + "failedToLoad": "Failed to load workflows", + "noSessions": "No workflow sessions", + "noSessionsDesc": "Run `openspace` with recording enabled, then refresh this page.", + "more": "+{{count}} more" + }, + "workflowDetail": { + "loadingDetail": "Loading workflow detail…", + "failedToLoad": "Failed to load workflow", + "workflowNotFound": "Workflow not found", + "backToWorkflows": "← Back to Workflows", + "workflowDetail": "Workflow detail", + "skillsSelected_one": "{{count}} skill selected", + "skillsSelected_other": "{{count}} skills selected", + "mergedEvent_one": "{{count}} merged event", + "mergedEvent_other": "{{count}} merged events", + "runDescription": "{{status}} run with {{iterations}} and {{actions}}.", + "started": "Started", + "duration": "Duration", + "stepsLabel": "Steps", + "latestEvent": "Latest event", + "successRate": "Success rate", + "successRateHint_one": "{{count}} successful iteration out of {{iterations}}", + "successRateHint_other": "{{count}} successful iterations out of {{iterations}}", + "iterations": "Iterations", + "totalRuntime": "{{duration}} total runtime", + "activeBackends": "Active backends", + "mostActive": "Most active {{backend}} · {{count}} events", + "noRecordedActivity": "No recorded tool activity", + "timelineEvents": "Timeline events", + "agentToolHint": "{{agentCount}} agent actions · {{toolCount}} tool events", + "noTimelineData": "No timeline data", + "noTimelineDesc": "This session does not yet contain trajectory or agent action records.", + "rawEventJson": "Raw event JSON", + "selection": "Selection", + "selectedSkills": "Selected skills", + "noSelectedSkills": "No selected skills", + "noSelectedSkillsDesc": "No skills were selected or recorded for this run.", + "session": "Session", + "overview": "Overview", + "taskId": "Task ID", + "runtime": "Runtime", + "window": "Window", + "ended": "Ended {{date}}", + "selectionMethod": "Selection method", + "notRecorded": "Not recorded", + "enabledBackends": "Enabled backends", + "backendActivity": "Backend activity", + "iteration_one": "{{count}} iteration", + "iteration_other": "{{count}} iterations", + "step_one": "{{count}} step", + "step_other": "{{count}} steps", + "agentAction_one": "{{count}} agent action", + "agentAction_other": "{{count}} agent actions", + "successfulIteration_one": "{{count}} successful iteration", + "successfulIteration_other": "{{count}} successful iterations" + }, + "drawer": { + "skillVersion": "Skill Version", + "openAsMain": "Open as main", + "versionSummary": "Version Summary", + "noDescriptionAvailable": "No description available for this version.", + "gen": "gen {{generation}}", + "versionScore": "version score", + "metrics": "Metrics", + "executionQuality": "Execution quality", + "effectiveRate": "Effective rate", + "completionRate": "Completion rate", + "appliedRate": "Applied rate", + "fallbackRate": "Fallback rate", + "selectionsLabel": "Selections", + "appliedLabel": "Applied", + "completionsLabel": "Completions", + "fallbacksLabel": "Fallbacks", + "versionMetadata": "Version Metadata", + "origin": "Origin:", + "generation": "Generation:", + "visibility": "Visibility:", + "created": "Created:", + "firstSeen": "First seen:", + "lastUpdated": "Last updated:", + "skillPath": "Skill path:", + "skillDir": "Skill dir:", + "parentIds": "Parent IDs:", + "changeSummary": "Change summary:", + "effectiveScore": "Effective score:", + "diff": "Diff", + "contentDiff": "Content diff", + "diffTooLarge": "Diff too large", + "diffTooLargeDesc": "This version has a very large content diff, so the inline viewer is disabled.", + "diffUnavailable": "Diff unavailable", + "diffUnavailableDesc": "This version has a diff payload, but it could not be parsed as a unified diff.", + "noContentDiff": "No content diff", + "noContentDiffDesc": "This version does not have a stored content diff.", + "source": "Source", + "skillMdPreview": "SKILL.md preview", + "sourceUnavailable": "Source unavailable", + "sourceUnavailableDesc": "This version points to a missing or unreadable SKILL.md path.", + "analyses": "Analyses", + "recentAnalyses": "Recent execution analyses", + "noExecutionNote": "No execution note", + "analysisCompleted": "completed: {{value}} · tool issues: {{toolIssues}} · suggestions: {{suggestions}}", + "noAnalysesYet": "No analyses yet", + "noAnalysesDesc": "Execution analyses will appear after recorded task runs are persisted into SQLite." + }, + "filter": { + "origin": "Origin:", + "allOrigins": "All Origins", + "tags": "Tags:", + "allTags": "All Tags" + }, + "graph": { + "noGraphData": "No lineage graph data.", + "tooltipScore": "score: {{value}}", + "tooltipGeneration": "generation: {{value}}", + "tooltipOrigin": "origin: {{value}}" + }, + "diffViewer": { + "noFiles": "No files in diff", + "old": "Old", + "new": "New" + }, + "format": { + "noInstruction": "No instruction captured" + } +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..d27eb6b --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,34 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import en from './en.json'; +import zh from './zh.json'; + +const STORAGE_KEY = 'openspace-lang'; + +function getSavedLanguage(): string { + try { + return localStorage.getItem(STORAGE_KEY) || 'en'; + } catch { + return 'en'; + } +} + +i18n.use(initReactI18next).init({ + resources: { + en: { translation: en }, + zh: { translation: zh }, + }, + lng: getSavedLanguage(), + fallbackLng: 'en', + interpolation: { escapeValue: false }, +}); + +i18n.on('languageChanged', (lng) => { + try { + localStorage.setItem(STORAGE_KEY, lng); + } catch { + // ignore + } +}); + +export default i18n; diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json new file mode 100644 index 0000000..7f023c4 --- /dev/null +++ b/frontend/src/i18n/zh.json @@ -0,0 +1,253 @@ +{ + "nav": { + "dashboard": "仪表盘", + "skills": "Skills", + "workflows": "工作流" + }, + "common": { + "loading": "加载中…", + "yes": "是", + "no": "否", + "none": "无", + "unavailable": "不可用", + "unknown": "未知", + "close": "关闭", + "score": "评分", + "success": "成功率", + "active": "活跃", + "inactive": "未激活", + "noDescription": "暂无描述", + "steps_one": "{{count}} 步", + "steps_other": "{{count}} 步", + "agentActions_one": "{{count}} 个 Agent 操作", + "agentActions_other": "{{count}} 个 Agent 操作", + "tags": "+{{count}} 个标签" + }, + "langSwitch": { + "label": "语言" + }, + "errorBoundary": { + "title": "出现了错误", + "message": "发生了意外错误", + "goToDashboard": "返回仪表盘", + "details": "错误详情" + }, + "dashboard": { + "title": "仪表盘", + "loadingDashboard": "正在加载仪表盘…", + "failedToLoad": "加载概览失败", + "dashboardUnavailable": "仪表盘不可用", + "totalSkills": "Skills 总数", + "activeHint": "活跃:{{count}}", + "avgSkillScore": "Skill 平均评分", + "avgScoreHint": "主要指标 = 有效率 × 100", + "workflowSessions": "工作流会话", + "localRepo": "本地仓库", + "workspace": "工作区", + "recordedUnder": "记录于{{location}}", + "workflowSuccess": "工作流成功率", + "avgSuccessHint": "平均会话成功率", + "health": "健康状态", + "runtimeSnapshot": "运行时快照", + "status": "状态", + "dbPath": "数据库路径", + "workflowCount": "工作流数量", + "builtFrontend": "前端构建", + "skillsSection": "Skills", + "topScoredSkills": "评分最高的 Skills", + "noSkillsYet": "暂无 Skills", + "noSkillsDesc": "先运行 OpenSpace 任务或将 Skills 同步到本地注册表中。", + "effective": "有效率 {{value}}", + "applied": "应用率 {{value}}", + "selections": "选择次数 {{count}}", + "workflowsSection": "工作流", + "recentSessions": "近期会话", + "noWorkflowSessions": "暂无工作流会话", + "noWorkflowDesc": "启用录制后执行任务,录制数据将在此显示。" + }, + "skills": { + "title": "Skill 类别", + "searchPlaceholder": "按名称、ID、描述、标签或来源搜索", + "sortByScore": "按最佳评分排序", + "sortByUpdated": "按更新时间排序", + "sortByName": "按名称排序", + "skillClasses": "Skill 类别", + "versionsHint": "版本数:{{count}}", + "activeVersions": "活跃版本", + "withActivity": "有活动记录:{{count}}", + "avgBestScore": "平均最佳评分", + "bestNodeScoreHint": "每个类别的最佳节点评分", + "selections": "选择次数", + "completionsHint": "完成次数:{{count}}", + "loadingSkills": "正在加载 Skills…", + "failedToLoad": "加载 Skills 失败", + "noSkillsMatch": "没有匹配的 Skills", + "noSkillsMatchDesc": "尝试其他关键词,或执行任务以将新的 Skill 遥测数据写入 SQLite。", + "bestScore": "最佳评分", + "noClassDescription": "暂无类别描述", + "versions": "{{count}} 个版本", + "active": "{{count}} 个活跃", + "selectionsCount": "{{count}} 次选择" + }, + "skillDetail": { + "loadingDetail": "正在加载 Skill 详情…", + "failedToLoad": "加载 Skill 类别失败", + "skillNotFound": "未找到 Skill", + "backToSkills": "← 返回 Skills", + "anchoredOn": "Skill 类别锚定于 {{id}}", + "skillClass": "Skill 类别", + "evolutionOverview": "演化概览", + "activeTip": "活跃端点", + "inactiveAnchor": "未激活锚点", + "bestVersionScore": "最佳版本评分", + "skillDirectory": "Skill 目录", + "latestVersionCreated": "最新版本创建时间", + "representativeVersion": "代表版本", + "representativeUpdate": "代表版本更新时间", + "versions": "版本数", + "maxGeneration": "最大世代 {{count}}", + "activeVersions": "活跃版本", + "originsCount": "来源数:{{count}}", + "averageScore": "平均评分", + "acrossAllVersions": "该谱系中所有版本的平均值", + "selections": "选择次数", + "representativeScore": "代表评分 {{score}}", + "evolutionGraph": "演化图", + "versionLineage": "版本谱系", + "loadingDrawer": "正在加载版本抽屉…", + "noLineageGraph": "暂无谱系图", + "noLineageGraphDesc": "该 Skill 尚无可供可视化的谱系数据。" + }, + "workflows": { + "title": "录制会话", + "searchPlaceholder": "按任务名称或指令搜索", + "workflowSessions": "工作流会话", + "scannedFrom": "从 logs/recordings 和 logs/trajectories 扫描", + "averageSuccess": "平均成功率", + "meanSuccessRate": "各会话的平均成功率", + "loadingWorkflows": "正在加载工作流…", + "failedToLoad": "加载工作流失败", + "noSessions": "暂无工作流会话", + "noSessionsDesc": "启用录制运行 `openspace` 后刷新此页面。", + "more": "+{{count}} 更多" + }, + "workflowDetail": { + "loadingDetail": "正在加载工作流详情…", + "failedToLoad": "加载工作流失败", + "workflowNotFound": "未找到工作流", + "backToWorkflows": "← 返回工作流列表", + "workflowDetail": "工作流详情", + "skillsSelected_one": "已选择 {{count}} 个 Skill", + "skillsSelected_other": "已选择 {{count}} 个 Skills", + "mergedEvent_one": "{{count}} 个合并事件", + "mergedEvent_other": "{{count}} 个合并事件", + "runDescription": "{{status}}运行,包含 {{iterations}} 和 {{actions}}。", + "started": "开始时间", + "duration": "持续时间", + "stepsLabel": "步骤", + "latestEvent": "最新事件", + "successRate": "成功率", + "successRateHint_one": "{{iterations}}中 {{count}} 次成功迭代", + "successRateHint_other": "{{iterations}}中 {{count}} 次成功迭代", + "iterations": "迭代次数", + "totalRuntime": "总运行时间 {{duration}}", + "activeBackends": "活跃后端", + "mostActive": "最活跃 {{backend}} · {{count}} 个事件", + "noRecordedActivity": "无已记录的工具活动", + "timelineEvents": "时间线事件", + "agentToolHint": "{{agentCount}} 个 Agent 操作 · {{toolCount}} 个工具事件", + "noTimelineData": "暂无时间线数据", + "noTimelineDesc": "此会话尚未包含轨迹或 Agent 操作记录。", + "rawEventJson": "原始事件 JSON", + "selection": "Skill 选择", + "selectedSkills": "已选 Skills", + "noSelectedSkills": "未选择 Skills", + "noSelectedSkillsDesc": "此次运行未选择或记录任何 Skills。", + "session": "会话", + "overview": "概览", + "taskId": "任务 ID", + "runtime": "运行时", + "window": "时间窗口", + "ended": "结束于 {{date}}", + "selectionMethod": "选择方法", + "notRecorded": "未记录", + "enabledBackends": "已启用后端", + "backendActivity": "后端活动", + "iteration_one": "{{count}} 次迭代", + "iteration_other": "{{count}} 次迭代", + "step_one": "{{count}} 步", + "step_other": "{{count}} 步", + "agentAction_one": "{{count}} 个 Agent 操作", + "agentAction_other": "{{count}} 个 Agent 操作", + "successfulIteration_one": "{{count}} 次成功迭代", + "successfulIteration_other": "{{count}} 次成功迭代" + }, + "drawer": { + "skillVersion": "Skill 版本", + "openAsMain": "作为主视图打开", + "versionSummary": "版本摘要", + "noDescriptionAvailable": "此版本暂无描述信息。", + "gen": "第 {{generation}} 代", + "versionScore": "版本评分", + "metrics": "指标", + "executionQuality": "执行质量", + "effectiveRate": "有效率", + "completionRate": "完成率", + "appliedRate": "应用率", + "fallbackRate": "回退率", + "selectionsLabel": "选择次数", + "appliedLabel": "应用次数", + "completionsLabel": "完成次数", + "fallbacksLabel": "回退次数", + "versionMetadata": "版本元数据", + "origin": "来源:", + "generation": "世代:", + "visibility": "可见性:", + "created": "创建时间:", + "firstSeen": "首次出现:", + "lastUpdated": "最后更新:", + "skillPath": "Skill 路径:", + "skillDir": "Skill 目录:", + "parentIds": "父级 ID:", + "changeSummary": "变更摘要:", + "effectiveScore": "有效评分:", + "diff": "Diff", + "contentDiff": "内容 Diff", + "diffTooLarge": "Diff 过大", + "diffTooLargeDesc": "此版本的内容 Diff 过大,内联查看器已禁用。", + "diffUnavailable": "Diff 不可用", + "diffUnavailableDesc": "此版本有 Diff 数据,但无法解析为统一 Diff 格式。", + "noContentDiff": "无内容 Diff", + "noContentDiffDesc": "此版本没有存储的内容 Diff。", + "source": "源代码", + "skillMdPreview": "SKILL.md 预览", + "sourceUnavailable": "源代码不可用", + "sourceUnavailableDesc": "此版本指向的 SKILL.md 路径缺失或不可读。", + "analyses": "分析", + "recentAnalyses": "近期执行分析", + "noExecutionNote": "无执行备注", + "analysisCompleted": "已完成:{{value}} · 工具问题:{{toolIssues}} · 建议:{{suggestions}}", + "noAnalysesYet": "暂无分析", + "noAnalysesDesc": "执行分析将在已记录的任务运行持久化到 SQLite 后显示。" + }, + "filter": { + "origin": "来源:", + "allOrigins": "所有来源", + "tags": "标签:", + "allTags": "所有标签" + }, + "graph": { + "noGraphData": "暂无谱系图数据。", + "tooltipScore": "评分:{{value}}", + "tooltipGeneration": "Generation:{{value}}", + "tooltipOrigin": "Origin:{{value}}" + }, + "diffViewer": { + "noFiles": "Diff 中无文件", + "old": "旧版", + "new": "新版" + }, + "format": { + "noInstruction": "未捕获到指令" + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 5a34772..153d185 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -32,7 +32,7 @@ --radius-chip: 999px; --radius-card: 32px; --radius-card-sm: 18px; - --font-serif: 'Neuton', Georgia, serif; + --font-serif: 'Neuton', 'Noto Serif CJK SC', 'Songti SC', 'SimSun', Georgia, serif; --font-sans: 'Cabin', system-ui, -apple-system, sans-serif; --font-mono: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', 'Courier New', monospace; --shadow-hard: 4px 4px 0px 0px rgba(74, 59, 42, 0.1); diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index c72e8f9..523b231 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -1,4 +1,5 @@ import { NavLink, Outlet } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; const linkClass = ({ isActive }: { isActive: boolean }) => isActive @@ -6,6 +7,12 @@ const linkClass = ({ isActive }: { isActive: boolean }) => : 'hover:text-primary'; export default function MainLayout() { + const { t, i18n } = useTranslation(); + + const toggleLang = () => { + i18n.changeLanguage(i18n.language === 'zh' ? 'en' : 'zh'); + }; + return (
-
API: `localhost:7788` · Vite: `localhost:3888`
+
+ +
API: `localhost:7788` · Vite: `localhost:3888`
+
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e0becf6..fb77ecd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import './i18n'; import './index.css'; import App from './App'; import { ErrorBoundary } from './components/ErrorBoundary'; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 165e47c..6e98cd4 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,11 +1,13 @@ import { Link } from 'react-router-dom'; import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { overviewApi, type OverviewResponse } from '../api'; import MetricCard from '../components/MetricCard'; import EmptyState from '../components/EmptyState'; import { formatDate, formatInstruction, formatPercent, truncate } from '../utils/format'; export default function DashboardPage() { + const { t } = useTranslation(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -22,7 +24,7 @@ export default function DashboardPage() { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load overview'); + setError(err instanceof Error ? err.message : t('dashboard.failedToLoad')); } } finally { if (!cancelled) { @@ -34,37 +36,37 @@ export default function DashboardPage() { return () => { cancelled = true; }; - }, []); + }, [t]); if (loading) { - return
Loading dashboard…
; + return
{t('dashboard.loadingDashboard')}
; } if (error || !data) { - return
{error ?? 'Dashboard unavailable'}
; + return
{error ?? t('dashboard.dashboardUnavailable')}
; } return (
-

Dashboard

+

{t('dashboard.title')}

- - - - + + + +
-
Health
-

Runtime snapshot

+
{t('dashboard.health')}
+

{t('dashboard.runtimeSnapshot')}

-
Status{data.health.status}
-
DB Path{data.health.db_path}
-
Workflow Count{data.health.workflow_count}
-
Built Frontend{data.health.frontend_dist_exists ? 'yes' : 'no'}
+
{t('dashboard.status')}{data.health.status}
+
{t('dashboard.dbPath')}{data.health.db_path}
+
{t('dashboard.workflowCount')}{data.health.workflow_count}
+
{t('dashboard.builtFrontend')}{data.health.frontend_dist_exists ? t('common.yes') : t('common.no')}
@@ -72,11 +74,11 @@ export default function DashboardPage() {
-
Skills
-

Top scored skills

+
{t('dashboard.skillsSection')}
+

{t('dashboard.topScoredSkills')}

{data.skills.top.length === 0 ? ( - + ) : (
{data.skills.top.map((skill) => ( @@ -84,17 +86,17 @@ export default function DashboardPage() {
{skill.name}
-
{truncate(skill.description || 'No description', 110)}
+
{truncate(skill.description || t('common.noDescription'), 110)}
{skill.score.toFixed(1)}
-
score
+
{t('common.score')}
- effective {formatPercent(skill.effective_rate)} - applied {formatPercent(skill.applied_rate)} - selections {skill.total_selections} + {t('dashboard.effective', { value: formatPercent(skill.effective_rate) })} + {t('dashboard.applied', { value: formatPercent(skill.applied_rate) })} + {t('dashboard.selections', { count: skill.total_selections })}
))} @@ -104,11 +106,11 @@ export default function DashboardPage() {
-
Workflows
-

Recent sessions

+
{t('dashboard.workflowsSection')}
+

{t('dashboard.recentSessions')}

{data.workflows.recent.length === 0 ? ( - + ) : (
{data.workflows.recent.map((workflow) => ( @@ -116,16 +118,16 @@ export default function DashboardPage() {
{workflow.task_name}
-
{formatInstruction(workflow.instruction, 160)}
+
{formatInstruction(workflow.instruction, 160, t('format.noInstruction'))}
{(workflow.success_rate * 100).toFixed(1)}%
-
success
+
{t('common.success')}
- {workflow.total_steps} steps - {workflow.agent_action_count} agent actions + {t('common.steps', { count: workflow.total_steps })} + {t('common.agentActions', { count: workflow.agent_action_count })} {formatDate(workflow.start_time)}
diff --git a/frontend/src/pages/SkillDetailPage.tsx b/frontend/src/pages/SkillDetailPage.tsx index 8b20ea5..c2de04e 100644 --- a/frontend/src/pages/SkillDetailPage.tsx +++ b/frontend/src/pages/SkillDetailPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useSearchParams, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { skillsApi, type SkillDetail, type SkillLineage } from '../api'; import EmptyState from '../components/EmptyState'; import MetricCard from '../components/MetricCard'; @@ -26,6 +27,7 @@ function resolveLineageGraph(skill: SkillDetail | null): SkillLineage | null { const DRAWER_ANIMATION_DURATION_MS = 300; export default function SkillDetailPage() { + const { t } = useTranslation(); const { skillId = '' } = useParams(); const [searchParams, setSearchParams] = useSearchParams(); const [skillClass, setSkillClass] = useState(null); @@ -52,7 +54,7 @@ export default function SkillDetailPage() { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load skill class'); + setError(err instanceof Error ? err.message : t('skillDetail.failedToLoad')); } } finally { if (!cancelled) { @@ -68,7 +70,7 @@ export default function SkillDetailPage() { return () => { cancelled = true; }; - }, [skillId]); + }, [skillId, t]); const lineageGraph = useMemo(() => resolveLineageGraph(skillClass), [skillClass]); @@ -97,7 +99,7 @@ export default function SkillDetailPage() { } catch (err) { if (!cancelled) { setSelectedVersion(null); - setDrawerError(err instanceof Error ? err.message : 'Failed to load selected version'); + setDrawerError(err instanceof Error ? err.message : t('skillDetail.failedToLoad')); } } finally { if (!cancelled) { @@ -110,7 +112,7 @@ export default function SkillDetailPage() { return () => { cancelled = true; }; - }, [selectedVersionId, skillClass]); + }, [selectedVersionId, skillClass, t]); useEffect(() => { if (selectedVersion) { @@ -215,20 +217,20 @@ export default function SkillDetailPage() { }; if (loading) { - return
Loading skill detail…
; + return
{t('skillDetail.loadingDetail')}
; } if (error || !skillClass) { - return
{error ?? 'Skill not found'}
; + return
{error ?? t('skillDetail.skillNotFound')}
; } return (
- ← Back to Skills + {t('skillDetail.backToSkills')}

{skillClass.name}

-
Skill class anchored on {skillClass.skill_id}
+
{t('skillDetail.anchoredOn', { id: skillClass.skill_id })}
@@ -236,13 +238,13 @@ export default function SkillDetailPage() {
-
Skill Class
-

Evolution overview

+
{t('skillDetail.skillClass')}
+

{t('skillDetail.evolutionOverview')}

{skillClass.category} {skillClass.visibility} - {skillClass.is_active ? 'active tip' : 'inactive anchor'} + {skillClass.is_active ? t('skillDetail.activeTip') : t('skillDetail.inactiveAnchor')} {classSummary.origins.map((origin) => ( {origin} ))} @@ -250,48 +252,48 @@ export default function SkillDetailPage() { {tag} ))} {classSummary.tags.length > 8 ? ( - +{classSummary.tags.length - 8} tags + {t('common.tags', { count: classSummary.tags.length - 8 })} ) : null}
{classSummary.bestScore.toFixed(1)}
-
best version score
+
{t('skillDetail.bestVersionScore')}
-
Skill directory
-
{skillClass.skill_dir || 'Unavailable'}
+
{t('skillDetail.skillDirectory')}
+
{skillClass.skill_dir || t('common.unavailable')}
-
Latest version created
+
{t('skillDetail.latestVersionCreated')}
{formatDate(classSummary.latestCreatedAt)}
-
Representative version
+
{t('skillDetail.representativeVersion')}
{skillClass.skill_id}
-
Representative update
+
{t('skillDetail.representativeUpdate')}
{formatDate(skillClass.last_updated)}
- - - - + + + +
-
Evolution Graph
-

Version lineage

+
{t('skillDetail.evolutionGraph')}
+

{t('skillDetail.versionLineage')}

{drawerLoading ? ( -
Loading version drawer…
+
{t('skillDetail.loadingDrawer')}
) : null} {drawerError ? (
{drawerError}
@@ -317,7 +319,7 @@ export default function SkillDetailPage() {
{lineageGraph && lineageGraph.nodes.length === 0 ? ( - + ) : null} diff --git a/frontend/src/pages/SkillsPage.tsx b/frontend/src/pages/SkillsPage.tsx index 72cd637..5686773 100644 --- a/frontend/src/pages/SkillsPage.tsx +++ b/frontend/src/pages/SkillsPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { skillsApi, type Skill, type SkillStats } from '../api'; import EmptyState from '../components/EmptyState'; import MetricCard from '../components/MetricCard'; @@ -7,6 +8,7 @@ import { formatDate, truncate } from '../utils/format'; import { buildSkillClasses } from '../utils/skillClasses'; export default function SkillsPage() { + const { t } = useTranslation(); const [skills, setSkills] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); @@ -30,7 +32,7 @@ export default function SkillsPage() { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load skills'); + setError(err instanceof Error ? err.message : t('skills.failedToLoad')); } } finally { if (!cancelled) { @@ -42,7 +44,7 @@ export default function SkillsPage() { return () => { cancelled = true; }; - }, [sort]); + }, [sort, t]); const skillClasses = useMemo(() => buildSkillClasses(skills), [skills]); @@ -89,37 +91,37 @@ export default function SkillsPage() {
-

Skill classes

+

{t('skills.title')}

setQuery(event.target.value)} - placeholder="Search by name, id, description, tag, or origin" + placeholder={t('skills.searchPlaceholder')} className="px-3 py-2 min-w-[320px]" />
{stats ? (
- - - - + + + +
) : null} - {loading ?
Loading skills…
: null} + {loading ?
{t('skills.loadingSkills')}
: null} {error ?
{error}
: null} {!loading && !error && filteredClasses.length === 0 ? ( - + ) : null} {!loading && !error && filteredClasses.length > 0 ? ( @@ -137,18 +139,18 @@ export default function SkillsPage() {
{skillClass.best_score.toFixed(1)}
-
best score
+
{t('skills.bestScore')}
- {truncate(skillClass.representative.description || 'No class description', 160)} + {truncate(skillClass.representative.description || t('skills.noClassDescription'), 160)}
-
{skillClass.version_count} versions
-
{skillClass.active_count} active
-
{skillClass.total_selections} selections
+
{t('skills.versions', { count: skillClass.version_count })}
+
{t('skills.active', { count: skillClass.active_count })}
+
{t('skills.selectionsCount', { count: skillClass.total_selections })}
{formatDate(skillClass.latest_updated)}
@@ -160,7 +162,7 @@ export default function SkillsPage() { {tag} ))} {skillClass.tags.length > 5 ? ( - +{skillClass.tags.length - 5} tags + {t('common.tags', { count: skillClass.tags.length - 5 })} ) : null}
diff --git a/frontend/src/pages/WorkflowDetailPage.tsx b/frontend/src/pages/WorkflowDetailPage.tsx index fdb36a7..ae8270e 100644 --- a/frontend/src/pages/WorkflowDetailPage.tsx +++ b/frontend/src/pages/WorkflowDetailPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { Link, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { workflowsApi, type WorkflowDetail, type WorkflowTimelineEvent } from '../api'; import { formatDate, formatInstruction } from '../utils/format'; @@ -48,10 +49,6 @@ function formatPercent(value: number): string { return `${(value * 100).toFixed(1)}%`; } -function pluralize(value: number, singular: string, plural = `${singular}s`): string { - return `${value} ${value === 1 ? singular : plural}`; -} - function getString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value : null; } @@ -367,6 +364,7 @@ function describeTimelineEvent(event: WorkflowTimelineEvent): TimelinePresentati } export default function WorkflowDetailPage() { + const { t } = useTranslation(); const { workflowId = '' } = useParams(); const [workflow, setWorkflow] = useState(null); const [loading, setLoading] = useState(true); @@ -385,7 +383,7 @@ export default function WorkflowDetailPage() { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load workflow'); + setError(err instanceof Error ? err.message : t('workflowDetail.failedToLoad')); } } finally { if (!cancelled) { @@ -399,7 +397,7 @@ export default function WorkflowDetailPage() { return () => { cancelled = true; }; - }, [workflowId]); + }, [workflowId, t]); const timeline = useMemo(() => { const events = workflow?.timeline ?? []; @@ -464,7 +462,7 @@ export default function WorkflowDetailPage() { return (
-
Loading workflow detail…
+
{t('workflowDetail.loadingDetail')}
); @@ -474,7 +472,7 @@ export default function WorkflowDetailPage() { return (
-
{error ?? 'Workflow not found'}
+
{error ?? t('workflowDetail.workflowNotFound')}
); @@ -487,16 +485,16 @@ export default function WorkflowDetailPage() { const skillSelection = isRecord(metadata.skill_selection) ? metadata.skill_selection : null; const selectionMethod = skillSelection && typeof skillSelection.method === 'string' ? skillSelection.method : null; const executionDurationLabel = formatDurationSeconds(workflow.execution_time); - const selectedSkillLabel = `${pluralize(workflow.selected_skills.length, 'skill')} selected`; - const iterationsLabel = pluralize(workflow.iterations, 'iteration'); - const totalStepLabel = pluralize(workflow.total_steps, 'step'); - const actionCountLabel = pluralize(workflow.agent_action_count, 'agent action'); + const selectedSkillLabel = t('workflowDetail.skillsSelected', { count: workflow.selected_skills.length }); + const iterationsLabel = t('workflowDetail.iteration', { count: workflow.iterations }); + const totalStepLabel = t('workflowDetail.step', { count: workflow.total_steps }); + const actionCountLabel = t('workflowDetail.agentAction', { count: workflow.agent_action_count }); const agentActionCount = timelineSummary.byType.agent_action ?? 0; const toolExecutionCount = timelineSummary.byType.tool_execution ?? 0; const latestEventLabel = timelineSummary.lastTimestamp ? formatTimeLabel(timelineSummary.lastTimestamp) : '—'; - const timelineEventLabel = pluralize(timelineSummary.total, 'merged event'); + const timelineEventLabel = t('workflowDetail.mergedEvent', { count: timelineSummary.total }); const statusLabel = humanizeToken(workflow.status || 'unknown'); - const selectionMethodLabel = selectionMethod ? humanizeToken(selectionMethod) : 'Not recorded'; + const selectionMethodLabel = selectionMethod ? humanizeToken(selectionMethod) : t('workflowDetail.notRecorded'); const successRateLabel = formatPercent(workflow.success_rate); return ( @@ -510,7 +508,7 @@ export default function WorkflowDetailPage() { to="/workflows" className="workflow-chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink" > - ← Back to Workflows + {t('workflowDetail.backToWorkflows')} {statusLabel} {selectedSkillLabel} @@ -518,36 +516,36 @@ export default function WorkflowDetailPage() {
-
Workflow detail
+
{t('workflowDetail.workflowDetail')}

{workflow.task_name}

- {formatInstruction(workflow.instruction, 480)} + {formatInstruction(workflow.instruction, 480, t('format.noInstruction'))}

- {`${statusLabel} run with ${iterationsLabel} and ${actionCountLabel}.`} + {t('workflowDetail.runDescription', { status: statusLabel, iterations: iterationsLabel, actions: actionCountLabel })}

-
Started
+
{t('workflowDetail.started')}
{formatDate(workflow.start_time)}
-
Duration
+
{t('workflowDetail.duration')}
{executionDurationLabel}
-
Steps
+
{t('workflowDetail.stepsLabel')}
{totalStepLabel}
-
Latest event
+
{t('workflowDetail.latestEvent')}
{latestEventLabel}
@@ -556,24 +554,24 @@ export default function WorkflowDetailPage() {
@@ -582,8 +580,8 @@ export default function WorkflowDetailPage() {
{timeline.length === 0 ? ( ) : (
@@ -677,7 +675,7 @@ export default function WorkflowDetailPage() { ) : null}
-
Raw event JSON
+
{t('workflowDetail.rawEventJson')}
                                   {stringify(event.details)}
                                 
@@ -697,8 +695,8 @@ export default function WorkflowDetailPage() {
-
Selection
-

Selected skills

+
{t('workflowDetail.selection')}
+

{t('workflowDetail.selectedSkills')}

{workflow.selected_skills.length > 0 ? {workflow.selected_skills.length} : null}
@@ -718,42 +716,42 @@ export default function WorkflowDetailPage() {
) : (
-
No selected skills
-

No skills were selected or recorded for this run.

+
{t('workflowDetail.noSelectedSkills')}
+

{t('workflowDetail.noSelectedSkillsDesc')}

)}
-
Session
-

Overview

+
{t('workflowDetail.session')}
+

{t('workflowDetail.overview')}

- +
{workflow.task_id}
- +
{executionDurationLabel}
{iterationsLabel} · {totalStepLabel} · {actionCountLabel}
- +
{formatDate(workflow.start_time)}
-
Ended {formatDate(workflow.end_time)}
+
{t('workflowDetail.ended', { date: formatDate(workflow.end_time) })}
- +
{selectionMethodLabel}
{selectedSkillLabel}
{enabledBackends.length > 0 ? ( - +
{enabledBackends.map((backend) => ( {humanizeToken(backend)} @@ -763,7 +761,7 @@ export default function WorkflowDetailPage() { ) : null} {activityEntries.length > 0 ? ( - +
{activityEntries.map(([backend, count]) => ( {`${humanizeToken(backend)} ${count}`} diff --git a/frontend/src/pages/WorkflowsPage.tsx b/frontend/src/pages/WorkflowsPage.tsx index 7c04aa1..e5a16ca 100644 --- a/frontend/src/pages/WorkflowsPage.tsx +++ b/frontend/src/pages/WorkflowsPage.tsx @@ -1,10 +1,12 @@ import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { workflowsApi, type WorkflowSummary } from '../api'; import EmptyState from '../components/EmptyState'; import { formatDate, formatInstruction } from '../utils/format'; export default function WorkflowsPage() { + const { t } = useTranslation(); const [workflows, setWorkflows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -22,7 +24,7 @@ export default function WorkflowsPage() { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load workflows'); + setError(err instanceof Error ? err.message : t('workflows.failedToLoad')); } } finally { if (!cancelled) { @@ -34,7 +36,7 @@ export default function WorkflowsPage() { return () => { cancelled = true; }; - }, []); + }, [t]); const filtered = useMemo(() => { const normalized = query.trim().toLowerCase(); @@ -56,34 +58,34 @@ export default function WorkflowsPage() {
-

Recorded sessions

+

{t('workflows.title')}

setQuery(event.target.value)} - placeholder="Search by task name or instruction" + placeholder={t('workflows.searchPlaceholder')} className="px-3 py-2 min-w-[320px]" />
-
Workflow Sessions
+
{t('workflows.workflowSessions')}
{workflows.length}
-
Scanned from logs/recordings and logs/trajectories
+
{t('workflows.scannedFrom')}
-
Average Success
+
{t('workflows.averageSuccess')}
{averageSuccess}%
-
Mean success rate across sessions
+
{t('workflows.meanSuccessRate')}
- {loading ?
Loading workflows…
: null} + {loading ?
{t('workflows.loadingWorkflows')}
: null} {error ?
{error}
: null} {!loading && !error && filtered.length === 0 ? ( - + ) : null} {!loading && !error && filtered.length > 0 ? ( @@ -96,13 +98,13 @@ export default function WorkflowsPage() {
{(workflow.success_rate * 100).toFixed(1)}%
-
success
+
{t('common.success')}
-
{formatInstruction(workflow.instruction, 220)}
+
{formatInstruction(workflow.instruction, 220, t('format.noInstruction'))}
-
{workflow.total_steps} steps
-
{workflow.agent_action_count} agent actions
+
{t('common.steps', { count: workflow.total_steps })}
+
{t('common.agentActions', { count: workflow.agent_action_count })}
{formatDate(workflow.start_time)}
{workflow.selected_skills.length > 0 ? ( @@ -114,7 +116,7 @@ export default function WorkflowsPage() { ))} {workflow.selected_skills.length > 3 ? ( - +{workflow.selected_skills.length - 3} more + {t('workflows.more', { count: workflow.selected_skills.length - 3 })} ) : null}
diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index ba71a98..5d68acf 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -56,8 +56,9 @@ function shortenPaths(text: string, keep = 3): string { export function formatInstruction( raw: string | null | undefined, maxLen?: number, + fallback = 'No instruction captured', ): string { - if (!raw) return 'No instruction captured'; + if (!raw) return fallback; let text = shortenPaths(raw); diff --git a/openspace/.env.example b/openspace/.env.example index d032801..00c4fd6 100644 --- a/openspace/.env.example +++ b/openspace/.env.example @@ -3,10 +3,24 @@ # Copy this file to .env and fill in your keys # ============================================ -# ---- LLM API Keys ---- -# At least one LLM API key is required for OpenSpace to function. -# OpenSpace uses LiteLLM for model routing, so the key you need depends on your chosen model. -# See https://docs.litellm.ai/docs/providers for supported providers. +# ── LLM Credentials ────────────────────────────────────── +# +# OpenSpace resolves LLM credentials in this order (first match wins): +# +# 1. OPENSPACE_LLM_* — explicit override, always highest priority +# 2. Provider-native vars — OPENROUTER_API_KEY, OPENAI_API_KEY, etc. +# 3. ~/.nanobot/config.json or ~/.openclaw/openclaw.json — fallback (only when no explicit or provider key found) +# +# For most users, setting ONE of the provider-native keys below is enough. +# LiteLLM reads them automatically. See https://docs.litellm.ai/docs/providers +# +# Full configuration guide: openspace/config/README.md + +# --- Option A: Provider-native key (simplest) --- +# Set the key that matches your model's provider: + +# OpenRouter (for openrouter/* models, e.g. openrouter/anthropic/claude-sonnet-4.5) +OPENROUTER_API_KEY= # Anthropic (for anthropic/claude-* models) # ANTHROPIC_API_KEY= @@ -14,8 +28,22 @@ # OpenAI (for openai/gpt-* models) # OPENAI_API_KEY= -# OpenRouter (for openrouter/* models, e.g. openrouter/anthropic/claude-sonnet-4.5) -OPENROUTER_API_KEY= +# DeepSeek (for deepseek/* models) +# DEEPSEEK_API_KEY= + +# --- Option B: Explicit OpenSpace override (takes priority over Option A) --- +# Use these when you need full control, e.g. custom API base or non-standard provider. + +# OPENSPACE_MODEL=openrouter/anthropic/claude-sonnet-4.5 +# OPENSPACE_LLM_API_KEY=sk-xxx +# OPENSPACE_LLM_API_BASE=https://openrouter.ai/api/v1 + +# --- Option C: Local Ollama --- +# For ollama/* models, set OPENSPACE_MODEL and the local Ollama endpoint. +# +# OPENSPACE_MODEL=ollama/qwen3-coder:30b +# OLLAMA_API_BASE=http://127.0.0.1:11434 +# OLLAMA_API_KEY=ollama # ── OpenSpace Cloud (optional) ────────────────────────────── # Register at https://open-space.cloud to get your key. @@ -23,28 +51,25 @@ OPENROUTER_API_KEY= OPENSPACE_API_KEY=sk_xxxxxxxxxxxxxxxx -# ---- GUI Backend (Anthropic Computer Use) ---- -# Required only if using the GUI backend. Uses the same ANTHROPIC_API_KEY above. + +# ── GUI Backend (optional) ────────────────────────────────── +# Required only if using the GUI backend (Anthropic Computer Use). +# Uses the same ANTHROPIC_API_KEY above. # Optional backup key for rate limit fallback: # ANTHROPIC_API_KEY_BACKUP= -# ---- Web Backend (Deep Research) ---- -# Required only if using the Web backend for deep research. -# Uses OpenRouter API by default: -# OPENROUTER_API_KEY= - -# ---- Embedding (Optional) ---- +# ── Embedding (optional) ──────────────────────────────────── # For remote embedding API instead of local model. # If not set, OpenSpace uses a local embedding model (BAAI/bge-small-en-v1.5). # EMBEDDING_BASE_URL= # EMBEDDING_API_KEY= -# EMBEDDING_MODEL= "openai/text-embedding-3-small" +# EMBEDDING_MODEL=openai/text-embedding-3-small -# ---- E2B Sandbox (Optional) ---- +# ── E2B Sandbox (optional) ────────────────────────────────── # Required only if sandbox mode is enabled in security config. # E2B_API_KEY= -# ---- Local Server (Optional) ---- +# ── Local Server (optional) ───────────────────────────────── # Override the default local server URL (default: http://127.0.0.1:5000) # Useful for remote VM integration (e.g., OSWorld). # LOCAL_SERVER_URL=http://127.0.0.1:5000 diff --git a/openspace/__main__.py b/openspace/__main__.py index 28ce3c2..ba67d56 100644 --- a/openspace/__main__.py +++ b/openspace/__main__.py @@ -158,6 +158,43 @@ def _create_argument_parser() -> argparse.ArgumentParser: '--config', '-c', type=str, help='MCP configuration file path' ) + + communication_parser = subparsers.add_parser( + 'communication', + help='Run the communication gateway' + ) + communication_parser.add_argument( + '--config', + type=str, + dest='communication_config', + help='Communication configuration file path' + ) + communication_subparsers = communication_parser.add_subparsers( + dest='communication_command', + help='Communication gateway commands' + ) + communication_run_parser = communication_subparsers.add_parser( + 'run', + help='Start the communication gateway' + ) + communication_run_parser.add_argument( + '--config', + type=str, + dest='communication_config', + help='Communication configuration file path' + ) + communication_health_parser = communication_subparsers.add_parser( + 'health', + help='Check the communication gateway health endpoint' + ) + communication_health_parser.add_argument( + '--config', + type=str, + dest='communication_config', + help='Communication configuration file path' + ) + communication_health_parser.add_argument('--host', type=str, default=None) + communication_health_parser.add_argument('--port', type=int, default=None) # Basic arguments (for run mode) parser.add_argument('--config', '-c', type=str, help='Configuration file path (JSON format)') @@ -321,16 +358,47 @@ async def refresh_mcp_cache(config_path: Optional[str] = None): def _load_config(args) -> OpenSpaceConfig: """Load configuration""" + import os + from openspace.host_detection import ( + build_grounding_config_path, + build_llm_kwargs, + load_runtime_env, + ) + + load_runtime_env() + cli_overrides = {} - if args.model: - cli_overrides['llm_model'] = args.model if args.max_iterations is not None: cli_overrides['grounding_max_iterations'] = args.max_iterations if args.timeout is not None: cli_overrides['llm_timeout'] = args.timeout if args.log_level: cli_overrides['log_level'] = args.log_level - + + # Resolve LLM model & credentials + # CLI --model > OPENSPACE_MODEL env > host-agent auto-detect > default + env_model = args.model or os.environ.get("OPENSPACE_MODEL", "") + model, llm_kwargs = build_llm_kwargs(env_model) + cli_overrides['llm_model'] = model + cli_overrides['llm_kwargs'] = llm_kwargs + + max_iter = int(os.environ.get("OPENSPACE_MAX_ITERATIONS", "20")) + enable_rec = os.environ.get("OPENSPACE_ENABLE_RECORDING", "true").lower() in ("true", "1", "yes") + backend_scope_raw = os.environ.get("OPENSPACE_BACKEND_SCOPE") + backend_scope = ( + [b.strip() for b in backend_scope_raw.split(",") if b.strip()] + if backend_scope_raw else None + ) + config_path = build_grounding_config_path() + + if 'grounding_max_iterations' not in cli_overrides: + cli_overrides['grounding_max_iterations'] = max_iter + cli_overrides['enable_recording'] = enable_rec + if backend_scope is not None: + cli_overrides['backend_scope'] = backend_scope + if config_path: + cli_overrides['grounding_config_path'] = config_path + try: # Load from config file if provided if args.config: @@ -338,18 +406,17 @@ def _load_config(args) -> OpenSpaceConfig: with open(args.config, 'r', encoding='utf-8') as f: config_dict = json.load(f) - # Apply CLI overrides + # Apply CLI / env overrides config_dict.update(cli_overrides) config = OpenSpaceConfig(**config_dict) print(f"✓ Loaded from config file: {args.config}") else: - # Use default config + CLI overrides config = OpenSpaceConfig(**cli_overrides) print("✓ Using default configuration") - if cli_overrides: - print(f"✓ CLI overrides: {', '.join(cli_overrides.keys())}") + if args.model: + print(f"✓ CLI overrides: llm_model") if args.log_level: Logger.set_level(args.log_level) @@ -414,6 +481,20 @@ async def main(): if args.command == 'refresh-cache': await refresh_mcp_cache(args.config) return 0 + if args.command == 'communication': + from openspace.communication.gateway import main as communication_main + + communication_argv = [] + if args.communication_config: + communication_argv.extend(['--config', args.communication_config]) + if args.communication_command: + communication_argv.append(args.communication_command) + if args.communication_command == 'health': + if args.host: + communication_argv.extend(['--host', args.host]) + if args.port is not None: + communication_argv.extend(['--port', str(args.port)]) + return await communication_main(communication_argv) # Load configuration config = _load_config(args) @@ -470,4 +551,4 @@ def run_main(): if __name__ == "__main__": - run_main() \ No newline at end of file + run_main() diff --git a/openspace/agents/grounding_agent.py b/openspace/agents/grounding_agent.py index f66ae99..2ad8a9c 100644 --- a/openspace/agents/grounding_agent.py +++ b/openspace/agents/grounding_agent.py @@ -5,8 +5,15 @@ import json from typing import TYPE_CHECKING, Any, Dict, List, Optional from openspace.agents.base import BaseAgent +from openspace.agents.message_utils import ( + ITERATION_GUIDANCE_PREFIX, + build_channel_context_message, + cap_message_content, + normalize_external_history, + truncate_messages, +) +from openspace.agents.visual_analyzer import VisualAnalyzer from openspace.grounding.core.types import BackendType, ToolResult -from openspace.platform.screenshot import ScreenshotClient from openspace.prompts import GroundingAgentPrompts from openspace.utils.logging import Logger @@ -20,6 +27,7 @@ logger = Logger.get_logger(__name__) class GroundingAgent(BaseAgent): + def __init__( self, name: str = "GroundingAgent", @@ -58,9 +66,12 @@ class GroundingAgent(BaseAgent): self._system_prompt = system_prompt or self._default_system_prompt() self._max_iterations = max_iterations - self._visual_analysis_timeout = visual_analysis_timeout self._tool_retrieval_llm = tool_retrieval_llm - self._visual_analysis_model = visual_analysis_model + self._visual_analyzer = VisualAnalyzer( + llm_client=llm_client, + visual_analysis_model=visual_analysis_model, + visual_analysis_timeout=visual_analysis_timeout, + ) # Skill context injection (set externally before process()) self._skill_context: Optional[str] = None @@ -75,7 +86,7 @@ class GroundingAgent(BaseAgent): logger.info(f"Grounding Agent initialized: {name}") logger.info(f"Backend scope: {self._backend_scope}") logger.info(f"Max iterations: {self._max_iterations}") - logger.info(f"Visual analysis timeout: {self._visual_analysis_timeout}s") + logger.info(f"Visual analysis timeout: {visual_analysis_timeout}s") if tool_retrieval_llm: logger.info(f"Tool retrieval model: {tool_retrieval_llm.model}") if visual_analysis_model: @@ -119,82 +130,6 @@ class GroundingAgent(BaseAgent): count = len(registry.list_skills()) logger.info(f"Skill registry attached ({count} skill(s) available for mid-iteration retrieval)") - _MAX_SINGLE_CONTENT_CHARS = 30_000 - - @classmethod - def _cap_message_content(cls, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Truncate oversized individual message contents in-place. - - Targets tool-result messages and assistant messages that can - carry enormous file contents (read_file on large CSVs/scripts). - System messages and the first user instruction are never touched. - """ - cap = cls._MAX_SINGLE_CONTENT_CHARS - trimmed = 0 - for msg in messages: - content = msg.get("content") - if not isinstance(content, str) or len(content) <= cap: - continue - if msg.get("role") == "system": - continue - original_len = len(content) - msg["content"] = ( - content[: cap // 2] - + f"\n\n... [truncated {original_len - cap:,} chars] ...\n\n" - + content[-(cap // 2):] - ) - trimmed += 1 - if trimmed: - logger.info(f"Capped {trimmed} oversized message(s) to {cap:,} chars each") - return messages - - def _truncate_messages( - self, - messages: List[Dict[str, Any]], - keep_recent: int = 8, - max_tokens_estimate: int = 120000 - ) -> List[Dict[str, Any]]: - # First: cap any single oversized message to prevent one huge - # tool-result from dominating the context window. - messages = self._cap_message_content(messages) - - if len(messages) <= keep_recent + 2: # +2 for system and initial user - return messages - - total_text = json.dumps(messages, ensure_ascii=False) - estimated_tokens = len(total_text) // 4 - - if estimated_tokens < max_tokens_estimate: - return messages - - logger.info(f"Truncating message history: {len(messages)} messages, " - f"~{estimated_tokens:,} tokens -> keeping recent {keep_recent} rounds") - - system_messages = [] - user_instruction = None - conversation_messages = [] - - for msg in messages: - role = msg.get("role") - if role == "system": - system_messages.append(msg) - elif role == "user" and user_instruction is None: - user_instruction = msg - else: - conversation_messages.append(msg) - - recent_messages = conversation_messages[-(keep_recent * 2):] if conversation_messages else [] - - truncated = system_messages.copy() - if user_instruction: - truncated.append(user_instruction) - truncated.extend(recent_messages) - - logger.info(f"After truncation: {len(truncated)} messages, " - f"~{len(json.dumps(truncated, ensure_ascii=False))//4:,} tokens (estimated)") - - return truncated - async def process(self, context: Dict[str, Any]) -> Dict[str, Any]: """ Process a task execution request with multi-round iteration control. @@ -276,6 +211,14 @@ class GroundingAgent(BaseAgent): tools=tools, ) + async def _va_callback( + result: ToolResult, tool_name: str, tool_call: Dict, backend: str + ) -> ToolResult: + return await self._visual_analyzer.analyze_tool_result( + result, tool_name, tool_call, backend, + task_description=instruction, + ) + try: while current_iteration < max_iterations: current_iteration += 1 @@ -295,15 +238,15 @@ class GroundingAgent(BaseAgent): # Cap oversized individual messages every iteration to prevent # a single huge tool result from ballooning all subsequent calls. if current_iteration >= 2: - messages = self._cap_message_content(messages) + messages = cap_message_content(messages) # Truncate message history to prevent context length issues # Start truncating after 5 iterations to keep context manageable if current_iteration >= 5: - messages = self._truncate_messages( - messages, + messages = truncate_messages( + messages, keep_recent=8, - max_tokens_estimate=120000 + max_tokens_estimate=120000, ) messages_input_snapshot = copy.deepcopy(messages) @@ -325,7 +268,7 @@ class GroundingAgent(BaseAgent): tools=tools if context.get("auto_execute", True) else None, execute_tools=context.get("auto_execute", True), summary_prompt=None, # Disabled - tool_result_callback=self._visual_analysis_callback + tool_result_callback=_va_callback, ) # Update messages with LLM response @@ -349,7 +292,7 @@ class GroundingAgent(BaseAgent): f"Tool results: {len(tool_results_this_iteration)}, " f"Content length: {len(assistant_content)} chars") - if len(assistant_content) > 0: + if len(assistant_content.strip()) > 0: logger.info(f"Iteration {current_iteration} - Assistant content preview: {repr(assistant_content[:300])}") consecutive_empty_responses = 0 # Reset counter on valid response else: @@ -414,13 +357,19 @@ class GroundingAgent(BaseAgent): # Remove previous iteration guidance to avoid accumulation messages = [ - msg for msg in messages - if not (msg.get("role") == "system" and "Iteration" in msg.get("content", "") and "complete" in msg.get("content", "")) + msg for msg in messages + if not ( + isinstance(msg.get("content"), str) + and msg.get("content", "").startswith(ITERATION_GUIDANCE_PREFIX) + ) ] + # MiniMax rejects system messages injected mid-conversation, + # so runtime guidance is sent as an internal user note. guidance_msg = { - "role": "system", - "content": f"Iteration {current_iteration} complete. " + "role": "user", + "content": f"{ITERATION_GUIDANCE_PREFIX}\n" + f"Iteration {current_iteration} complete. " f"Check if task is finished - if yes, output {GroundingAgentPrompts.TASK_COMPLETE}. " f"If not, continue with next action." } @@ -516,7 +465,16 @@ class GroundingAgent(BaseAgent): "role": "system", "content": artifact_msg }) - + + channel_context_msg = build_channel_context_message( + context.get("channel_context") + ) + if channel_context_msg: + messages.append({ + "role": "system", + "content": channel_context_msg, + }) + # Skill injection — only active (selected) skills, full content if self._skill_context: messages.append({ @@ -524,10 +482,20 @@ class GroundingAgent(BaseAgent): "content": self._skill_context }) logger.info(f"Injected active skill context ({len(self._active_skill_ids)} skill(s))") - + + external_history = normalize_external_history( + context.get("conversation_history") + ) + if external_history: + messages.extend(external_history) + logger.info( + "Injected %d external conversation message(s)", + len(external_history), + ) + # User instruction messages.append({"role": "user", "content": instruction}) - + return messages async def _get_available_tools(self, task_description: Optional[str]) -> List: @@ -613,218 +581,6 @@ class GroundingAgent(BaseAgent): ) return all_tools - async def _visual_analysis_callback( - self, - result: ToolResult, - tool_name: str, - tool_call: Dict, - backend: str - ) -> ToolResult: - """ - Callback for LLMClient to handle visual analysis after tool execution. - """ - # 1. Check if LLM requested to skip visual analysis - skip_visual_analysis = False - try: - arguments = tool_call.function.arguments - if isinstance(arguments, str): - args = json.loads(arguments.strip() or "{}") - else: - args = arguments - - if isinstance(args, dict) and args.get("skip_visual_analysis"): - skip_visual_analysis = True - logger.info(f"Visual analysis skipped for {tool_name} (meta-parameter set by LLM)") - except Exception as e: - logger.debug(f"Could not parse tool arguments: {e}") - - # 2. If skip requested, return original result - if skip_visual_analysis: - return result - - # 3. Check if this backend needs visual analysis - if backend != "gui": - return result - - # 4. Check if tool has visual data - metadata = getattr(result, 'metadata', None) - has_screenshots = metadata and (metadata.get("screenshot") or metadata.get("screenshots")) - - # 5. If no visual data, try to capture a screenshot - if not has_screenshots: - try: - logger.info(f"No visual data from {tool_name}, capturing screenshot...") - screenshot_client = ScreenshotClient() - screenshot_bytes = await screenshot_client.capture() - - if screenshot_bytes: - # Add screenshot to result metadata - if metadata is None: - result.metadata = {} - metadata = result.metadata - metadata["screenshot"] = screenshot_bytes - has_screenshots = True - logger.info(f"Screenshot captured for visual analysis") - else: - logger.warning("Failed to capture screenshot") - except Exception as e: - logger.warning(f"Error capturing screenshot: {e}") - - # 6. If still no screenshots, return original result - if not has_screenshots: - logger.debug(f"No visual data available for {tool_name}") - return result - - # 7. Perform visual analysis - return await self._enhance_result_with_visual_context(result, tool_name) - - async def _enhance_result_with_visual_context( - self, - result: ToolResult, - tool_name: str - ) -> ToolResult: - """ - Enhance tool result with visual analysis for grounding agent workflows. - """ - import asyncio - import base64 - import litellm - - try: - metadata = getattr(result, 'metadata', None) - if not metadata: - return result - - # Collect all screenshots - screenshots_bytes = [] - - # Check for multiple screenshots first - if metadata.get("screenshots"): - screenshots_list = metadata["screenshots"] - if isinstance(screenshots_list, list): - screenshots_bytes = [s for s in screenshots_list if s] - # Fall back to single screenshot - elif metadata.get("screenshot"): - screenshots_bytes = [metadata["screenshot"]] - - if not screenshots_bytes: - return result - - # Select key screenshots if there are too many - selected_screenshots = self._select_key_screenshots(screenshots_bytes, max_count=3) - - # Convert to base64 - visual_b64_list = [] - for visual_data in selected_screenshots: - if isinstance(visual_data, bytes): - visual_b64_list.append(base64.b64encode(visual_data).decode('utf-8')) - else: - visual_b64_list.append(visual_data) # Already base64 - - # Build prompt based on number of screenshots - num_screenshots = len(visual_b64_list) - - prompt = GroundingAgentPrompts.visual_analysis( - tool_name=tool_name, - num_screenshots=num_screenshots, - task_description=getattr(self, '_current_instruction', '') - ) - - # Build content with text prompt + all images - content = [{"type": "text", "text": prompt}] - for visual_b64 in visual_b64_list: - content.append({ - "type": "image_url", - "image_url": { - "url": f"data:image/png;base64,{visual_b64}" - } - }) - - # Use dedicated visual analysis model if configured, otherwise use main LLM model - visual_model = self._visual_analysis_model or (self._llm_client.model if self._llm_client else "openrouter/anthropic/claude-sonnet-4.5") - response = await asyncio.wait_for( - litellm.acompletion( - model=visual_model, - messages=[{ - "role": "user", - "content": content - }], - timeout=self._visual_analysis_timeout - ), - timeout=self._visual_analysis_timeout + 5 - ) - - analysis = response.choices[0].message.content.strip() - - # Inject visual analysis into content - original_content = result.content or "(no text output)" - enhanced_content = f"{original_content}\n\n**Visual content**: {analysis}" - - # Create enhanced result - enhanced_result = ToolResult( - status=result.status, - content=enhanced_content, - error=result.error, - metadata={**metadata, "visual_analyzed": True, "visual_analysis": analysis}, - execution_time=result.execution_time - ) - - logger.info(f"Enhanced {tool_name} result with visual analysis ({num_screenshots} screenshot(s))") - return enhanced_result - - except asyncio.TimeoutError: - logger.warning(f"Visual analysis timed out for {tool_name}, returning original result") - return result - except Exception as e: - logger.warning(f"Failed to analyze visual content for {tool_name}: {e}") - return result - - def _select_key_screenshots( - self, - screenshots: List[bytes], - max_count: int = 3 - ) -> List[bytes]: - """ - Select key screenshots if there are too many. - """ - if len(screenshots) <= max_count: - return screenshots - - selected_indices = set() - - # Always include last (final state) - selected_indices.add(len(screenshots) - 1) - - # If room, include first (initial state) - if max_count >= 2: - selected_indices.add(0) - - # Fill remaining slots with evenly spaced middle screenshots - remaining_slots = max_count - len(selected_indices) - if remaining_slots > 0: - # Calculate spacing - available_indices = [ - i for i in range(1, len(screenshots) - 1) - if i not in selected_indices - ] - - if available_indices: - step = max(1, len(available_indices) // (remaining_slots + 1)) - for i in range(remaining_slots): - idx = min((i + 1) * step, len(available_indices) - 1) - if idx < len(available_indices): - selected_indices.add(available_indices[idx]) - - # Return screenshots in original order - selected = [screenshots[i] for i in sorted(selected_indices)] - - logger.debug( - f"Selected {len(selected)} screenshots at indices {sorted(selected_indices)} " - f"from total of {len(screenshots)}" - ) - - return selected - def _get_workspace_path(self, context: Dict[str, Any]) -> Optional[str]: """ Get workspace directory path from context. @@ -1209,4 +965,4 @@ class GroundingAgent(BaseAgent): "step": self.step, "instruction": instruction, } - ) \ No newline at end of file + ) diff --git a/openspace/agents/message_utils.py b/openspace/agents/message_utils.py new file mode 100644 index 0000000..4c3948e --- /dev/null +++ b/openspace/agents/message_utils.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional, Set + +from openspace.prompts import GroundingAgentPrompts +from openspace.utils.logging import Logger + +logger = Logger.get_logger(__name__) + +SUPPORTED_EXTERNAL_HISTORY_ROLES: Set[str] = {"user", "assistant"} +MAX_SINGLE_CONTENT_CHARS: int = 30_000 +ITERATION_GUIDANCE_PREFIX: str = "[INTERNAL ORCHESTRATION NOTE]" + + +def cap_message_content( + messages: List[Dict[str, Any]], + max_chars: int = MAX_SINGLE_CONTENT_CHARS, +) -> List[Dict[str, Any]]: + """Truncate oversized individual message contents in-place. + + Targets tool-result messages and assistant messages that can + carry enormous file contents (read_file on large CSVs/scripts). + System messages and the first user instruction are never touched. + """ + trimmed = 0 + for msg in messages: + content = msg.get("content") + if not isinstance(content, str) or len(content) <= max_chars: + continue + if msg.get("role") == "system": + continue + original_len = len(content) + msg["content"] = ( + content[: max_chars // 2] + + f"\n\n... [truncated {original_len - max_chars:,} chars] ...\n\n" + + content[-(max_chars // 2) :] + ) + trimmed += 1 + if trimmed: + logger.info(f"Capped {trimmed} oversized message(s) to {max_chars:,} chars each") + return messages + + +def truncate_messages( + messages: List[Dict[str, Any]], + keep_recent: int = 8, + max_tokens_estimate: int = 120_000, + guidance_prefix: str = ITERATION_GUIDANCE_PREFIX, +) -> List[Dict[str, Any]]: + """Truncate conversation history to fit within token budget. + + Preserves system messages and the first user instruction while + keeping only the most recent conversation turns. + """ + messages = cap_message_content(messages) + + if len(messages) <= keep_recent + 2: # +2 for system and initial user + return messages + + total_text = json.dumps(messages, ensure_ascii=False) + estimated_tokens = len(total_text) // 4 + + if estimated_tokens < max_tokens_estimate: + return messages + + logger.info( + f"Truncating message history: {len(messages)} messages, " + f"~{estimated_tokens:,} tokens -> keeping recent {keep_recent} rounds" + ) + + system_messages: List[Dict[str, Any]] = [] + user_instruction: Optional[Dict[str, Any]] = None + conversation_messages: List[Dict[str, Any]] = [] + + for msg in messages: + role = msg.get("role") + if role == "system": + system_messages.append(msg) + elif role == "user" and user_instruction is None: + user_instruction = msg + else: + conversation_messages.append(msg) + + recent_messages = ( + conversation_messages[-(keep_recent * 2) :] if conversation_messages else [] + ) + + truncated = system_messages.copy() + dropped = len(conversation_messages) - len(recent_messages) + if dropped > 0: + truncated.append( + { + "role": "system", + "content": ( + f"{guidance_prefix} {dropped} earlier messages were " + "truncated to save context. The original task instruction " + "is preserved below." + ), + } + ) + if user_instruction: + truncated.append(user_instruction) + truncated.extend(recent_messages) + + logger.info( + f"After truncation: {len(truncated)} messages, " + f"~{len(json.dumps(truncated, ensure_ascii=False)) // 4:,} tokens (estimated)" + ) + + return truncated + + +def normalize_external_history( + conversation_history: Any, + supported_roles: Set[str] = SUPPORTED_EXTERNAL_HISTORY_ROLES, +) -> List[Dict[str, str]]: + """Normalize external conversation history into ``{role, content}`` dicts.""" + if not isinstance(conversation_history, list): + return [] + + normalized: List[Dict[str, str]] = [] + for entry in conversation_history: + if not isinstance(entry, dict): + continue + role = str(entry.get("role", "")).strip().lower() + if role not in supported_roles: + continue + + content = entry.get("content") + if isinstance(content, list): + parts: List[str] = [] + for item in content: + if isinstance(item, dict): + text = item.get("text") + if isinstance(text, str) and text.strip(): + parts.append(text.strip()) + elif isinstance(item, str) and item.strip(): + parts.append(item.strip()) + content = "\n".join(parts).strip() + elif content is not None: + content = str(content).strip() + + if not content: + continue + + normalized.append({"role": role, "content": content}) + + return normalized + + +def build_channel_context_message(channel_context: Any) -> Optional[str]: + """Build a system message describing the communication channel context.""" + if not isinstance(channel_context, dict): + return None + + lines = [ + "## Channel Context", + ] + + platform = str(channel_context.get("platform", "")).strip() + chat_type = str(channel_context.get("chat_type", "")).strip() + chat_id = str(channel_context.get("chat_id", "")).strip() + chat_name = str(channel_context.get("chat_name", "")).strip() + thread_id = str(channel_context.get("thread_id", "")).strip() + user_name = str(channel_context.get("user_name", "")).strip() + user_id = str(channel_context.get("user_id", "")).strip() + session_key = str(channel_context.get("session_key", "")).strip() + message_id = str(channel_context.get("message_id", "")).strip() + reply_to_message_id = str(channel_context.get("reply_to_message_id", "")).strip() + reply_to_text = str(channel_context.get("reply_to_text", "")).strip() + + if platform: + lines.append(f"- Platform: {platform}") + if chat_type: + lines.append(f"- Chat type: {chat_type}") + if chat_id: + lines.append(f"- Chat ID: {chat_id}") + if chat_name: + lines.append(f"- Chat name: {chat_name}") + if thread_id: + lines.append(f"- Thread ID: {thread_id}") + if user_name: + lines.append(f"- User: {user_name}") + elif user_id: + lines.append(f"- User ID: {user_id}") + if session_key: + lines.append(f"- Session key: {session_key}") + if message_id: + lines.append(f"- Message ID: {message_id}") + if reply_to_message_id: + lines.append(f"- Reply-to message ID: {reply_to_message_id}") + if reply_to_text: + lines.append(f"- Reply context: {reply_to_text[:500]}") + + lines.extend( + [ + "", + "## Chat Reply Policy", + "- If the user is making simple conversation, answer directly in natural language.", + "- Do not call tools for greetings, acknowledgements, thanks, or brief " + "clarifications that can be answered from the current context.", + f"- When you reply directly without tools, include " + f"`{GroundingAgentPrompts.TASK_COMPLETE}` at the end of your response.", + ] + ) + + attachments = channel_context.get("attachments") + if isinstance(attachments, list) and attachments: + lines.append("- Attachments:") + for attachment in attachments: + if not isinstance(attachment, dict): + continue + path = str(attachment.get("path", "")).strip() + if not path: + continue + kind = str(attachment.get("kind", "file")).strip() or "file" + name = str(attachment.get("name", "")).strip() + label = f"{kind}: {path}" + if name: + label += f" ({name})" + lines.append(f" - {label}") + + if len(lines) == 1: + return None + + return "\n".join(lines) diff --git a/openspace/agents/visual_analyzer.py b/openspace/agents/visual_analyzer.py new file mode 100644 index 0000000..1179672 --- /dev/null +++ b/openspace/agents/visual_analyzer.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from openspace.grounding.core.types import ToolResult +from openspace.platforms.screenshot import ScreenshotClient +from openspace.prompts import GroundingAgentPrompts +from openspace.utils.logging import Logger + +if TYPE_CHECKING: + from openspace.llm import LLMClient + +logger = Logger.get_logger(__name__) + + +class VisualAnalyzer: + """Handles screenshot capture and LLM-based visual analysis of tool results.""" + + def __init__( + self, + llm_client: Optional[LLMClient] = None, + visual_analysis_model: Optional[str] = None, + visual_analysis_timeout: float = 30.0, + ) -> None: + self._llm_client = llm_client + self._visual_analysis_model = visual_analysis_model + self._visual_analysis_timeout = visual_analysis_timeout + + async def analyze_tool_result( + self, + result: ToolResult, + tool_name: str, + tool_call: Dict, + backend: str, + task_description: str = "", + ) -> ToolResult: + """Callback for LLMClient to handle visual analysis after tool execution.""" + skip_visual_analysis = False + try: + arguments = tool_call.function.arguments + if isinstance(arguments, str): + args = json.loads(arguments.strip() or "{}") + else: + args = arguments + + if isinstance(args, dict) and args.get("skip_visual_analysis"): + skip_visual_analysis = True + logger.info(f"Visual analysis skipped for {tool_name} (meta-parameter set by LLM)") + except Exception as e: + logger.debug(f"Could not parse tool arguments: {e}") + + if skip_visual_analysis: + return result + + if backend != "gui": + return result + + metadata = getattr(result, "metadata", None) + has_screenshots = metadata and ( + metadata.get("screenshot") or metadata.get("screenshots") + ) + + if not has_screenshots: + try: + logger.info(f"No visual data from {tool_name}, capturing screenshot...") + screenshot_client = ScreenshotClient() + screenshot_bytes = await screenshot_client.capture() + + if screenshot_bytes: + if metadata is None: + result.metadata = {} + metadata = result.metadata + metadata["screenshot"] = screenshot_bytes + has_screenshots = True + logger.info("Screenshot captured for visual analysis") + else: + logger.warning("Failed to capture screenshot") + except Exception as e: + logger.warning(f"Error capturing screenshot: {e}") + + if not has_screenshots: + logger.debug(f"No visual data available for {tool_name}") + return result + + return await self._enhance_result(result, tool_name, task_description) + + async def _enhance_result( + self, + result: ToolResult, + tool_name: str, + task_description: str = "", + ) -> ToolResult: + """Enhance tool result with LLM-based visual analysis.""" + import asyncio + import base64 + import litellm + + try: + metadata = getattr(result, "metadata", None) + if not metadata: + return result + + screenshots_bytes: List[bytes] = [] + if metadata.get("screenshots"): + screenshots_list = metadata["screenshots"] + if isinstance(screenshots_list, list): + screenshots_bytes = [s for s in screenshots_list if s] + elif metadata.get("screenshot"): + screenshots_bytes = [metadata["screenshot"]] + + if not screenshots_bytes: + return result + + selected_screenshots = self._select_key_screenshots( + screenshots_bytes, max_count=3 + ) + + visual_b64_list = [] + for visual_data in selected_screenshots: + if isinstance(visual_data, bytes): + visual_b64_list.append( + base64.b64encode(visual_data).decode("utf-8") + ) + else: + visual_b64_list.append(visual_data) + + num_screenshots = len(visual_b64_list) + + prompt = GroundingAgentPrompts.visual_analysis( + tool_name=tool_name, + num_screenshots=num_screenshots, + task_description=task_description, + ) + + content: List[Dict[str, Any]] = [{"type": "text", "text": prompt}] + for visual_b64 in visual_b64_list: + content.append( + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{visual_b64}"}, + } + ) + + visual_model = self._visual_analysis_model or ( + self._llm_client.model + if self._llm_client + else "openrouter/anthropic/claude-sonnet-4.5" + ) + _llm_extra: Dict[str, Any] = {} + if self._llm_client and visual_model == self._llm_client.model: + _llm_extra = ( + getattr(self._llm_client, "litellm_kwargs", {}) or {} + ) + elif self._visual_analysis_model: + try: + from openspace.host_detection import build_llm_kwargs + + visual_model, _llm_extra = build_llm_kwargs(visual_model) + except Exception as e: + logger.debug( + f"Failed to resolve dedicated visual model credentials: {e}" + ) + _llm_extra = {} + + response = await asyncio.wait_for( + litellm.acompletion( + model=visual_model, + messages=[{"role": "user", "content": content}], + timeout=self._visual_analysis_timeout, + **_llm_extra, + ), + timeout=self._visual_analysis_timeout + 5, + ) + + analysis = response.choices[0].message.content.strip() + + original_content = result.content or "(no text output)" + enhanced_content = ( + f"{original_content}\n\n**Visual content**: {analysis}" + ) + + enhanced_result = ToolResult( + status=result.status, + content=enhanced_content, + error=result.error, + metadata={ + **metadata, + "visual_analyzed": True, + "visual_analysis": analysis, + }, + execution_time=result.execution_time, + ) + + logger.info( + f"Enhanced {tool_name} result with visual analysis " + f"({num_screenshots} screenshot(s))" + ) + return enhanced_result + + except asyncio.TimeoutError: + logger.warning( + f"Visual analysis timed out for {tool_name}, returning original result" + ) + return result + except Exception as e: + logger.warning( + f"Failed to analyze visual content for {tool_name}: {e}" + ) + return result + + @staticmethod + def _select_key_screenshots( + screenshots: List[bytes], + max_count: int = 3, + ) -> List[bytes]: + """Select key screenshots from a sequence, preferring first/last/evenly-spaced.""" + if len(screenshots) <= max_count: + return screenshots + + selected_indices: set[int] = set() + + selected_indices.add(len(screenshots) - 1) + + if max_count >= 2: + selected_indices.add(0) + + remaining_slots = max_count - len(selected_indices) + if remaining_slots > 0: + available_indices = [ + i + for i in range(1, len(screenshots) - 1) + if i not in selected_indices + ] + + if available_indices: + step = max(1, len(available_indices) // (remaining_slots + 1)) + for i in range(remaining_slots): + idx = min((i + 1) * step, len(available_indices) - 1) + if idx < len(available_indices): + selected_indices.add(available_indices[idx]) + + selected = [screenshots[i] for i in sorted(selected_indices)] + + logger.debug( + f"Selected {len(selected)} screenshots at indices " + f"{sorted(selected_indices)} from total of {len(screenshots)}" + ) + + return selected diff --git a/openspace/cloud/client.py b/openspace/cloud/client.py index fb0792f..fdd31ac 100644 --- a/openspace/cloud/client.py +++ b/openspace/cloud/client.py @@ -5,6 +5,7 @@ All methods are **synchronous** (use ``urllib``). In async contexts Provides both low-level HTTP operations and higher-level workflows: - ``fetch_record`` / ``download_artifact`` / ``fetch_metadata`` + - ``search_record_embeddings`` - ``stage_artifact`` / ``create_record`` - ``upload_skill`` (stage → diff → create — full workflow) - ``import_skill`` (fetch → download → extract — full workflow) @@ -29,6 +30,7 @@ logger = logging.getLogger("openspace.cloud") SKILL_FILENAME = "SKILL.md" SKILL_ID_FILENAME = ".skill_id" +RECORD_EMBEDDING_SEARCH_MAX_LIMIT = 300 _TEXT_EXTENSIONS = frozenset({ ".md", ".txt", ".yaml", ".yml", ".json", ".py", ".sh", ".toml", @@ -99,9 +101,26 @@ class OpenSpaceClient: _, data = self._request("GET", path, timeout=timeout) return json.loads(data.decode("utf-8")) + @staticmethod + def _normalize_visibility_value(value: Any) -> Any: + """Treat legacy/group-shared non-public skills as private locally.""" + if value == "group_only": + return "private" + return value + + @classmethod + def _normalize_record_payload(cls, payload: Dict[str, Any]) -> Dict[str, Any]: + normalized = dict(payload) + if "visibility" in normalized: + normalized["visibility"] = cls._normalize_visibility_value( + normalized.get("visibility") + ) + return normalized + def fetch_record(self, record_id: str) -> Dict[str, Any]: """GET /records/{record_id} — fetch record metadata.""" - return self._get_json(f"/records/{urllib.parse.quote(record_id)}") + data = self._get_json(f"/records/{urllib.parse.quote(record_id)}") + return self._normalize_record_payload(data) def download_artifact(self, record_id: str) -> bytes: """GET /records/{record_id}/download — download artifact zip bytes.""" @@ -132,7 +151,10 @@ class OpenSpaceClient: path = f"/records/metadata?{urllib.parse.urlencode(params)}" data = self._get_json(path, timeout=15) - all_items.extend(data.get("items", [])) + all_items.extend( + self._normalize_record_payload(item) + for item in data.get("items", []) + ) if not data.get("has_more"): break @@ -142,6 +164,34 @@ class OpenSpaceClient: return all_items + def search_record_embeddings( + self, + *, + query: str, + limit: int = RECORD_EMBEDDING_SEARCH_MAX_LIMIT, + level: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> List[Dict[str, Any]]: + """POST /records/embeddings/search — fetch server-ranked embedding rows.""" + search_request_payload: Dict[str, Any] = { + "query": query, + "limit": limit, + } + if level: + search_request_payload["level"] = level + if tags: + search_request_payload["tags"] = tags + + _, response_body = self._request( + "POST", + "/records/embeddings/search", + body=json.dumps(search_request_payload).encode("utf-8"), + extra_headers={"Content-Type": "application/json"}, + timeout=30, + ) + items = json.loads(response_body.decode("utf-8")) + return [self._normalize_record_payload(item) for item in items] + def stage_artifact(self, skill_dir: Path) -> tuple[str, int]: """POST /artifacts/stage — upload skill files. @@ -266,7 +316,7 @@ class OpenSpaceClient: parents = parent_skill_ids or [] self._validate_origin_parents(origin, parents) - api_visibility = "group_only" if visibility == "private" else "public" + api_visibility = visibility # Step 1: Stage logger.info(f"upload_skill: staging files for '{name}'") @@ -340,7 +390,11 @@ class OpenSpaceClient: record_data = self.fetch_record(skill_id) skill_name = record_data.get("name", skill_id) - skill_dir = target_dir / skill_name + if "/" in skill_name or "\\" in skill_name or skill_name.startswith("."): + skill_name = skill_id + skill_dir = (target_dir / skill_name).resolve() + if not skill_dir.is_relative_to(target_dir.resolve()): + raise CloudError(f"Skill name {skill_name!r} escapes target directory") # Check if already exists locally if skill_dir.exists() and (skill_dir / SKILL_FILENAME).exists(): @@ -401,6 +455,7 @@ class OpenSpaceClient: def _extract_zip(zip_data: bytes, target_dir: Path) -> List[str]: """Extract zip bytes to target directory with path traversal protection.""" extracted: List[str] = [] + resolved_target = target_dir.resolve() try: with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: for info in zf.infolist(): @@ -409,7 +464,9 @@ class OpenSpaceClient: clean_name = Path(info.filename).as_posix() if clean_name.startswith("..") or clean_name.startswith("/"): continue - target_path = target_dir / clean_name + target_path = (target_dir / clean_name).resolve() + if not target_path.is_relative_to(resolved_target): + continue target_path.parent.mkdir(parents=True, exist_ok=True) target_path.write_bytes(zf.read(info)) extracted.append(clean_name) diff --git a/openspace/cloud/search.py b/openspace/cloud/search.py index 678f71b..bd25436 100644 --- a/openspace/cloud/search.py +++ b/openspace/cloud/search.py @@ -18,6 +18,7 @@ import re from typing import Any, Dict, List, Optional logger = logging.getLogger("openspace.cloud") +CLOUD_EMBEDDING_SEARCH_MAX_LIMIT = 300 def _check_safety(text: str) -> list[str]: @@ -159,36 +160,43 @@ class SkillSearchEngine: from openspace.cloud.embedding import cosine_similarity scored = [] - for c in candidates: - name = c.get("name", "") - slug = c.get("skill_id", name).split("__")[0].replace(":", "-") + for candidate in candidates: + candidate_name = candidate.get("name", "") + candidate_slug = candidate.get("skill_id", candidate_name).split("__")[0].replace(":", "-") - # Vector score - vector_score = 0.0 + # Vector score. If client-side query embeddings are unavailable, + # reuse the server-side cloud rank so cloud results keep semantic signal. + vector_score: Optional[float] = None + ranking_signal_score = 0.0 if query_embedding: - skill_emb = c.get("_embedding") - if skill_emb and isinstance(skill_emb, list): - vector_score = cosine_similarity(query_embedding, skill_emb) + candidate_embedding = candidate.get("_embedding") + if candidate_embedding and isinstance(candidate_embedding, list): + vector_score = cosine_similarity(query_embedding, candidate_embedding) + ranking_signal_score = vector_score + elif isinstance(candidate.get("_search_rank"), (int, float)): + ranking_signal_score = float(candidate["_search_rank"]) # Lexical boost - lexical = _lexical_boost(query_tokens, name, slug) + lexical_boost = _lexical_boost(query_tokens, candidate_name, candidate_slug) - final_score = vector_score + lexical + final_score = ranking_signal_score + lexical_boost - entry: Dict[str, Any] = { - "skill_id": c.get("skill_id", ""), - "name": name, - "description": c.get("description", ""), - "source": c.get("source", ""), + result_entry: Dict[str, Any] = { + "skill_id": candidate.get("skill_id", ""), + "name": candidate_name, + "description": candidate.get("description", ""), + "source": candidate.get("source", ""), "score": round(final_score, 4), } - if vector_score > 0: - entry["vector_score"] = round(vector_score, 4) + if vector_score is not None and vector_score > 0: + result_entry["vector_score"] = round(vector_score, 4) + if isinstance(candidate.get("_search_rank"), (int, float)): + result_entry["server_search_rank"] = round(float(candidate["_search_rank"]), 4) # Include optional fields for key in ("path", "visibility", "created_by", "origin", "tags", "quality", "safety_flags"): - if c.get(key): - entry[key] = c[key] - scored.append(entry) + if candidate.get(key): + result_entry[key] = candidate[key] + scored.append(result_entry) scored.sort(key=lambda x: -x["score"]) return scored @@ -275,47 +283,85 @@ def build_local_candidates( def build_cloud_candidates( - items: List[Dict[str, Any]], + cloud_items: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: - """Build search candidate dicts from cloud metadata items. + """Build search candidate dicts from cloud metadata/search items. Args: - items: Items from ``OpenSpaceClient.fetch_metadata()``. + cloud_items: Items from cloud metadata or embedding search endpoints. Returns: List of candidate dicts (with safety filtering applied). """ candidates: List[Dict[str, Any]] = [] - for item in items: - name = item.get("name", "") - desc = item.get("description", "") - tags = item.get("tags", []) - safety_text = f"{name}\n{desc}\n{' '.join(tags)}" + for item in cloud_items: + candidate_name = item.get("name", "") + candidate_description = item.get("description", "") + candidate_tags = item.get("tags", []) + safety_text = f"{candidate_name}\n{candidate_description}\n{' '.join(candidate_tags)}" flags = _check_safety(safety_text) if not _is_safe(flags): continue - c_entry: Dict[str, Any] = { + candidate_entry: Dict[str, Any] = { "skill_id": item.get("record_id", ""), - "name": name, - "description": desc, + "name": candidate_name, + "description": candidate_description, "source": "cloud", "visibility": item.get("visibility", "public"), "is_local": False, "created_by": item.get("created_by", ""), "origin": item.get("origin", ""), - "tags": tags, + "tags": candidate_tags, "safety_flags": flags if flags else None, } # Carry pre-computed embedding - platform_emb = item.get("embedding") - if platform_emb and isinstance(platform_emb, list): - c_entry["_embedding"] = platform_emb - candidates.append(c_entry) + server_embedding = item.get("embedding") + if server_embedding and isinstance(server_embedding, list): + candidate_entry["_embedding"] = server_embedding + server_search_rank = item.get("search_rank") + if isinstance(server_search_rank, (int, float)): + candidate_entry["_search_rank"] = float(server_search_rank) + candidates.append(candidate_entry) return candidates +def build_cloud_results( + cloud_search_items: List[Dict[str, Any]], + *, + limit: int, +) -> List[Dict[str, Any]]: + """Map server-ranked cloud search rows to MCP search result shape.""" + results: List[Dict[str, Any]] = [] + seen_names: set[str] = set() + + for candidate in build_cloud_candidates(cloud_search_items): + candidate_name = candidate.get("name", "") + dedupe_name = candidate_name or candidate.get("skill_id", "") + if dedupe_name in seen_names: + continue + seen_names.add(dedupe_name) + + entry: Dict[str, Any] = { + "skill_id": candidate.get("skill_id", ""), + "name": candidate_name, + "description": candidate.get("description", ""), + "source": "cloud", + "score": round(float(candidate.get("_search_rank", 0.0)), 4), + } + if isinstance(candidate.get("_search_rank"), (int, float)): + entry["server_search_rank"] = round(float(candidate["_search_rank"]), 4) + for key in ("visibility", "created_by", "origin", "tags", "safety_flags"): + if candidate.get(key): + entry[key] = candidate[key] + results.append(entry) + if len(results) >= limit: + break + + return results + + async def hybrid_search_skills( query: str, local_skills: list = None, @@ -341,8 +387,8 @@ async def hybrid_search_skills( """ from openspace.cloud.embedding import generate_embedding - q = query.strip() - if not q: + normalized_query = query.strip() + if not normalized_query: return [] candidates: List[Dict[str, Any]] = [] @@ -357,16 +403,16 @@ async def hybrid_search_skills( auth_headers, api_base = get_openspace_auth() if auth_headers: - client = OpenSpaceClient(auth_headers, api_base) - try: - from openspace.cloud.embedding import resolve_embedding_api - has_emb = bool(resolve_embedding_api()[0]) - except Exception: - has_emb = False - items = await asyncio.to_thread( - client.fetch_metadata, include_embedding=has_emb, limit=200, + cloud_client = OpenSpaceClient(auth_headers, api_base) + cloud_result_limit = limit if source == "cloud" else CLOUD_EMBEDDING_SEARCH_MAX_LIMIT + cloud_search_items = await asyncio.to_thread( + cloud_client.search_record_embeddings, + query=normalized_query, + limit=cloud_result_limit, ) - candidates.extend(build_cloud_candidates(items)) + if source == "cloud": + return build_cloud_results(cloud_search_items, limit=limit) + candidates.extend(build_cloud_candidates(cloud_search_items)) except Exception as e: logger.warning(f"hybrid_search_skills: cloud unavailable: {e}") @@ -376,18 +422,17 @@ async def hybrid_search_skills( # query embedding (optional — key/URL resolved inside generate_embedding) query_embedding: Optional[List[float]] = None try: - query_embedding = await asyncio.to_thread(generate_embedding, q) + query_embedding = await asyncio.to_thread(generate_embedding, normalized_query) if query_embedding: - for c in candidates: - if not c.get("_embedding") and c.get("_embedding_text"): - emb = await asyncio.to_thread( - generate_embedding, c["_embedding_text"], + for candidate in candidates: + if not candidate.get("_embedding") and candidate.get("_embedding_text"): + candidate_embedding = await asyncio.to_thread( + generate_embedding, candidate["_embedding_text"], ) - if emb: - c["_embedding"] = emb + if candidate_embedding: + candidate["_embedding"] = candidate_embedding except Exception: pass engine = SkillSearchEngine() - return engine.search(q, candidates, query_embedding=query_embedding, limit=limit) - + return engine.search(normalized_query, candidates, query_embedding=query_embedding, limit=limit) diff --git a/openspace/communication/__init__.py b/openspace/communication/__init__.py new file mode 100644 index 0000000..5e5ac01 --- /dev/null +++ b/openspace/communication/__init__.py @@ -0,0 +1,27 @@ +from openspace.communication.config import CommunicationConfig, load_communication_config +from openspace.communication.session_store import SessionStore, build_session_key +from openspace.communication.types import ( + AttachmentKind, + ChannelAttachment, + ChannelMessage, + ChannelPlatform, + ChannelReply, + ChannelSession, + ChannelSource, + SendResult, +) + +__all__ = [ + "AttachmentKind", + "ChannelAttachment", + "ChannelMessage", + "ChannelPlatform", + "ChannelReply", + "ChannelSession", + "ChannelSource", + "CommunicationConfig", + "SendResult", + "SessionStore", + "build_session_key", + "load_communication_config", +] diff --git a/openspace/communication/adapters/__init__.py b/openspace/communication/adapters/__init__.py new file mode 100644 index 0000000..649009a --- /dev/null +++ b/openspace/communication/adapters/__init__.py @@ -0,0 +1,9 @@ +from openspace.communication.adapters.base import BaseChannelAdapter +from openspace.communication.adapters.feishu import FeishuAdapter +from openspace.communication.adapters.whatsapp import WhatsAppAdapter + +__all__ = [ + "BaseChannelAdapter", + "FeishuAdapter", + "WhatsAppAdapter", +] diff --git a/openspace/communication/adapters/base.py b/openspace/communication/adapters/base.py new file mode 100644 index 0000000..fb0fece --- /dev/null +++ b/openspace/communication/adapters/base.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Awaitable, Callable, Optional + +from openspace.utils.logging import Logger + +from openspace.communication.types import ChannelMessage, ChannelPlatform, SendResult + +logger = Logger.get_logger(__name__) + +MessageHandler = Callable[[ChannelMessage], Awaitable[None]] + + +class BaseChannelAdapter(ABC): + platform: ChannelPlatform + + def __init__(self, platform: ChannelPlatform): + self.platform = platform + self._message_handler: Optional[MessageHandler] = None + self._connected = False + + @property + def is_connected(self) -> bool: + return self._connected + + def set_message_handler(self, handler: MessageHandler) -> None: + self._message_handler = handler + + async def dispatch_message(self, message: ChannelMessage) -> None: + if self._message_handler is None: + logger.warning("Dropping %s message because no handler is attached", self.platform.value) + return + await self._message_handler(message) + + def register_http_routes(self, app: Any) -> None: + """Optional hook for adapters that need inbound HTTP routes.""" + + def validate_configuration(self) -> None: + """Optional hook for adapter-specific startup validation.""" + + def get_lock_identity(self) -> Optional[tuple[str, str]]: + """Return an optional (scope, identity) tuple for gateway-scoped locking.""" + return None + + @abstractmethod + async def connect(self) -> bool: + raise NotImplementedError + + @abstractmethod + async def disconnect(self) -> None: + raise NotImplementedError + + @abstractmethod + async def send_text( + self, + chat_id: str, + content: str, + *, + reply_to_message_id: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, + ) -> SendResult: + raise NotImplementedError diff --git a/openspace/communication/adapters/feishu.py b/openspace/communication/adapters/feishu.py new file mode 100644 index 0000000..3c11063 --- /dev/null +++ b/openspace/communication/adapters/feishu.py @@ -0,0 +1,901 @@ +from __future__ import annotations + +import asyncio +import contextlib +import hashlib +import hmac +import json +import threading +import time +from collections import OrderedDict, deque +from pathlib import Path +from typing import Any, Optional + +from aiohttp import web + +from openspace.communication.adapters.base import BaseChannelAdapter +from openspace.communication.attachment_cache import AttachmentCache +from openspace.communication.config import FeishuConfig +from openspace.communication.policy import is_authorized +from openspace.communication.types import ( + AttachmentKind, + ChannelMessage, + ChannelPlatform, + ChannelSource, + SendResult, +) +from openspace.utils.logging import Logger + +logger = Logger.get_logger(__name__) + +_FEISHU_WEBHOOK_MAX_BODY_BYTES = 1 * 1024 * 1024 +_FEISHU_WEBHOOK_READ_TIMEOUT_SECONDS = 30 +_FEISHU_WEBHOOK_RATE_WINDOW_SECONDS = 60 +_FEISHU_WEBHOOK_RATE_LIMIT_MAX = 120 +_FEISHU_WEBHOOK_RATE_MAX_KEYS = 4096 +_FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS = 6 * 60 * 60 +_FEISHU_DEDUP_CACHE_SIZE = 2048 +_FEISHU_DEDUP_TTL_SECONDS = 24 * 60 * 60 + +try: + import lark_oapi as lark + from lark_oapi.api.im.v1 import ( + CreateMessageRequest, + CreateMessageRequestBody, + GetMessageRequest, + GetMessageResourceRequest, + ReplyMessageRequest, + ReplyMessageRequestBody, + ) + from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN + + FEISHU_AVAILABLE = True +except ImportError: + FEISHU_AVAILABLE = False + lark = None # type: ignore[assignment] + CreateMessageRequest = None # type: ignore[assignment] + CreateMessageRequestBody = None # type: ignore[assignment] + GetMessageRequest = None # type: ignore[assignment] + GetMessageResourceRequest = None # type: ignore[assignment] + ReplyMessageRequest = None # type: ignore[assignment] + ReplyMessageRequestBody = None # type: ignore[assignment] + FEISHU_DOMAIN = None # type: ignore[assignment] + LARK_DOMAIN = None # type: ignore[assignment] + + +class FeishuAdapter(BaseChannelAdapter): + MAX_MESSAGE_LENGTH = 8000 + _REPLY_CONTEXT_MAX_LEN = 200 + + def __init__( + self, + config: FeishuConfig, + attachment_cache: AttachmentCache, + *, + runtime_dir: Optional[Path] = None, + ): + super().__init__(ChannelPlatform.FEISHU) + self.config = config + self.attachment_cache = attachment_cache + self.runtime_dir = ( + Path(runtime_dir).expanduser().resolve() + if runtime_dir is not None + else attachment_cache.base_dir.parent.resolve() + ) + self._client: Any = None + self._bot_open_id = config.bot_open_id + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._ws_client: Any = None + self._ws_thread: Optional[threading.Thread] = None + self._running = False + self._dedup_state_path = self.runtime_dir / "feishu_seen_message_ids.json" + self._seen_message_ids: OrderedDict[str, float] = OrderedDict() + self._recent_sent_message_ids: OrderedDict[str, None] = OrderedDict() + self._rate_windows: dict[str, deque[float]] = {} + self._webhook_anomalies: dict[str, tuple[int, str, float]] = {} + self._dedup_dirty = False + self._load_seen_message_ids() + + def register_http_routes(self, app: Any) -> None: + if self.config.connection_mode == "webhook": + app.router.add_post(self.config.webhook_path, self._handle_webhook) + + def validate_configuration(self) -> None: + if self.config.connection_mode == "webhook" and not _optional_str(self.config.verification_token): + raise ValueError("Feishu webhook mode requires verification_token") + + def get_lock_identity(self) -> Optional[tuple[str, str]]: + app_id = _optional_str(self.config.app_id) + if not app_id: + return None + return ("feishu-app", app_id) + + async def connect(self) -> bool: + self.validate_configuration() + if not FEISHU_AVAILABLE: + logger.error("Feishu adapter requires lark-oapi") + return False + if not self.config.app_id or not self.config.app_secret: + logger.error("Feishu adapter missing app_id/app_secret") + return False + domain = FEISHU_DOMAIN if self.config.domain != "lark" else LARK_DOMAIN + self._client = ( + lark.Client.builder() + .app_id(self.config.app_id) + .app_secret(self.config.app_secret) + .domain(domain) + .log_level(lark.LogLevel.WARNING) + .build() + ) + self._running = True + self._loop = asyncio.get_running_loop() + if not self._bot_open_id: + self._bot_open_id = await asyncio.to_thread(self._fetch_bot_open_id) + if self.config.connection_mode == "websocket": + self._start_websocket_client() + self._connected = False + else: + self._connected = True + logger.info( + "Feishu adapter connected via %s mode", + self.config.connection_mode, + ) + return True + + async def disconnect(self) -> None: + self._running = False + self._connected = False + if self._ws_thread is not None and self._ws_thread.is_alive(): + await asyncio.to_thread(self._ws_thread.join, 5) + self._ws_thread = None + self._persist_seen_message_ids() + self._client = None + + async def send_text( + self, + chat_id: str, + content: str, + *, + reply_to_message_id: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, + ) -> SendResult: + if not self._client: + return SendResult(success=False, error="Feishu client not initialized") + + last_message_id: Optional[str] = None + for chunk in _split_text(content, self.MAX_MESSAGE_LENGTH): + payload = json.dumps({"text": chunk}, ensure_ascii=False) + if reply_to_message_id: + body = ( + ReplyMessageRequestBody.builder() + .msg_type("text") + .content(payload) + .build() + ) + request = ( + ReplyMessageRequest.builder() + .message_id(reply_to_message_id) + .request_body(body) + .build() + ) + response = await asyncio.to_thread(self._client.im.v1.message.reply, request) + else: + body = ( + CreateMessageRequestBody.builder() + .receive_id(chat_id) + .msg_type("text") + .content(payload) + .build() + ) + request = ( + CreateMessageRequest.builder() + .receive_id_type("chat_id") + .request_body(body) + .build() + ) + response = await asyncio.to_thread(self._client.im.v1.message.create, request) + + if not response.success(): + return SendResult( + success=False, + error=f"[{response.code}] {response.msg}", + raw_response=response, + ) + last_message_id = getattr(getattr(response, "data", None), "message_id", None) + if last_message_id: + self._remember_sent_message_id(last_message_id) + return SendResult(success=True, message_id=last_message_id) + + async def _handle_webhook(self, request: web.Request) -> web.Response: + remote_ip = _client_ip_from_request(request) + rate_key = f"{self.config.app_id}:{self.config.webhook_path}:{remote_ip}" + if not self._check_webhook_rate_limit(rate_key): + self._record_webhook_anomaly(remote_ip, "429") + return web.Response(status=429, text="Rate limit exceeded") + + content_length = request.content_length or 0 + if content_length > _FEISHU_WEBHOOK_MAX_BODY_BYTES: + self._record_webhook_anomaly(remote_ip, "413") + return web.Response(status=413, text="Payload too large") + + try: + async with asyncio.timeout(_FEISHU_WEBHOOK_READ_TIMEOUT_SECONDS): + body_bytes = await request.read() + except TimeoutError: + self._record_webhook_anomaly(remote_ip, "408") + return web.Response(status=408, text="Request timeout") + + if len(body_bytes) > _FEISHU_WEBHOOK_MAX_BODY_BYTES: + self._record_webhook_anomaly(remote_ip, "413") + return web.Response(status=413, text="Payload too large") + + try: + payload = json.loads(body_bytes.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + self._record_webhook_anomaly(remote_ip, "400") + return web.json_response({"code": 400, "msg": "invalid json"}, status=400) + + incoming_token = str((payload.get("header") or {}).get("token") or payload.get("token") or "") + if not incoming_token or not hmac.compare_digest(incoming_token, self.config.verification_token or ""): + self._record_webhook_anomaly(remote_ip, "401-token") + return web.Response(status=401, text="Invalid verification token") + + if self.config.encrypt_key and not _is_webhook_signature_valid( + encrypt_key=self.config.encrypt_key, + headers=request.headers, + body_bytes=body_bytes, + ): + self._record_webhook_anomaly(remote_ip, "401-signature") + return web.Response(status=401, text="Invalid signature") + if payload.get("encrypt"): + self._record_webhook_anomaly(remote_ip, "400-encrypted") + return web.json_response( + {"code": 400, "msg": "encrypted webhook payloads are not supported"}, + status=400, + ) + + self._clear_webhook_anomaly(remote_ip) + + if payload.get("type") == "url_verification": + return web.json_response({"challenge": payload.get("challenge", "")}) + + event_type = str((payload.get("header") or {}).get("event_type") or "") + if event_type == "im.message.receive_v1": + await self._handle_message_event(payload) + return web.json_response({"code": 0, "msg": "ok"}) + + def _start_websocket_client(self) -> None: + assert lark is not None + + handler = ( + lark.EventDispatcherHandler.builder( + self.config.encrypt_key or "", + self.config.verification_token or "", + ) + .register_p2_im_message_receive_v1(self._on_message_sync) + .build() + ) + self._ws_client = lark.ws.Client( + self.config.app_id, + self.config.app_secret, + event_handler=handler, + log_level=lark.LogLevel.INFO, + ) + + def _run_ws_forever() -> None: + import lark_oapi.ws.client as lark_ws_client + + ws_loop = asyncio.new_event_loop() + asyncio.set_event_loop(ws_loop) + lark_ws_client.loop = ws_loop + try: + while self._running: + try: + self._ws_client.start() + except Exception as exc: + self._connected = False + logger.warning("Feishu WebSocket client error: %s", exc) + if self._running: + time.sleep(5) + finally: + self._connected = False + ws_loop.close() + + self._ws_thread = threading.Thread( + target=_run_ws_forever, + daemon=True, + name="openspace-feishu-ws", + ) + self._ws_thread.start() + + def _on_message_sync(self, data: Any) -> None: + if not self._loop or not self._running: + return + self._connected = True + asyncio.run_coroutine_threadsafe( + self._handle_websocket_message(data), + self._loop, + ) + + async def _handle_message_event(self, payload: dict[str, Any]) -> None: + normalized = await self._normalize_webhook_payload(payload) + if normalized is not None: + await self.dispatch_message(normalized) + + async def _handle_websocket_message(self, data: Any) -> None: + normalized = await self._normalize_websocket_event(data) + if normalized is not None: + await self.dispatch_message(normalized) + + async def _normalize_webhook_payload(self, payload: dict[str, Any]) -> Optional[ChannelMessage]: + event = payload.get("event") or {} + message = event.get("message") or {} + sender = event.get("sender") or {} + sender_id = sender.get("sender_id") or {} + if str(sender.get("sender_type", "")).lower() == "bot": + return None + return await self._normalize_inbound_message( + message_id=_optional_str(message.get("message_id")), + chat_id=_optional_str(message.get("chat_id")), + chat_type=_optional_str(message.get("chat_type")) or "p2p", + sender_uid=( + _optional_str(sender_id.get("open_id")) + or _optional_str(sender_id.get("user_id")) + or _optional_str(sender_id.get("union_id")) + ), + sender_name=( + _optional_str(sender_id.get("name")) + or _optional_str(sender.get("sender_name")) + ), + thread_id=_optional_str(message.get("thread_id")), + message_type=_optional_str(message.get("message_type")) or "", + content=_safe_json_loads(message.get("content", "{}")), + mentions=list(message.get("mentions") or []), + reply_to_message_id=( + _optional_str(message.get("parent_id")) + or _optional_str(message.get("upper_message_id")) + ), + metadata={"webhook_payload": payload}, + resolve_mentions=False, + ) + + async def _normalize_websocket_event(self, data: Any) -> Optional[ChannelMessage]: + event = getattr(data, "event", None) + message = getattr(event, "message", None) + sender = getattr(event, "sender", None) + if message is None or sender is None: + return None + if str(getattr(sender, "sender_type", "")).lower() == "bot": + return None + + sender_id = getattr(sender, "sender_id", None) + return await self._normalize_inbound_message( + message_id=_optional_str(getattr(message, "message_id", None)), + chat_id=_optional_str(getattr(message, "chat_id", None)), + chat_type=_optional_str(getattr(message, "chat_type", None)) or "p2p", + sender_uid=( + _optional_str(getattr(sender_id, "open_id", None)) + or _optional_str(getattr(sender_id, "user_id", None)) + or _optional_str(getattr(sender_id, "union_id", None)) + ), + sender_name=_optional_str(getattr(sender, "sender_name", None)), + thread_id=_optional_str(getattr(message, "thread_id", None)), + message_type=_optional_str(getattr(message, "message_type", None)) or "", + content=_safe_json_loads(getattr(message, "content", "{}")), + mentions=list(getattr(message, "mentions", None) or []), + reply_to_message_id=( + _optional_str(getattr(message, "parent_id", None)) + or _optional_str(getattr(message, "upper_message_id", None)) + ), + metadata={"websocket_event": True}, + resolve_mentions=True, + ) + + async def _normalize_inbound_message( + self, + *, + message_id: Optional[str], + chat_id: Optional[str], + chat_type: str, + sender_uid: Optional[str], + sender_name: Optional[str], + thread_id: Optional[str], + message_type: str, + content: dict[str, Any], + mentions: list[Any], + reply_to_message_id: Optional[str], + metadata: dict[str, Any], + resolve_mentions: bool, + ) -> Optional[ChannelMessage]: + if not message_id or not chat_id: + return None + if self._is_message_seen(message_id): + logger.debug("Skipping duplicate Feishu message %s", message_id) + return None + + source = ChannelSource( + platform=ChannelPlatform.FEISHU, + chat_id=chat_id, + chat_type="dm" if str(chat_type).lower() == "p2p" else "group", + user_id=sender_uid, + user_name=sender_name, + chat_name=chat_id, + thread_id=thread_id, + ) + session_key = _build_session_key_hint(source) + normalized_type = str(message_type or "").strip().lower() + mentions_bot = self._mentions_bot(mentions) + + text = "" + if normalized_type == "text": + text = str(content.get("text", "")).strip() + if resolve_mentions: + text = _resolve_mentions(text, mentions) + elif normalized_type == "post": + text = _extract_post_text(content) + + prefilter_message = ChannelMessage( + source=source, + text=text, + message_id=message_id, + reply_to_message_id=reply_to_message_id, + mentions_bot=mentions_bot, + metadata=metadata, + ) + if not self._passes_prefilter(prefilter_message): + self._remember_message_seen(message_id) + return None + + attachments = [] + if normalized_type == "image": + attachment = await self._download_attachment( + session_key=session_key, + message_id=message_id, + file_key=str(content.get("image_key", "")).strip(), + file_name=str(content.get("image_key", "image")).strip() + ".png", + kind=AttachmentKind.IMAGE, + resource_type="image", + ) + if attachment is not None: + attachments.append(attachment) + elif normalized_type == "file": + attachment = await self._download_attachment( + session_key=session_key, + message_id=message_id, + file_key=str(content.get("file_key", "")).strip(), + file_name=str(content.get("file_name", "document")).strip(), + kind=AttachmentKind.DOCUMENT, + resource_type="file", + ) + if attachment is not None: + attachments.append(attachment) + + prefilter_message.attachments = attachments + prefilter_message.reply_to_text = await self._fetch_message_text(reply_to_message_id) + self._remember_message_seen(message_id) + return prefilter_message + + def _passes_prefilter(self, message: ChannelMessage) -> bool: + if not is_authorized(message, self.config): + logger.info("Rejected Feishu message from unauthorized user %s", message.source.user_id) + return False + if message.source.chat_type == "dm": + return self.config.allow_dm + if not self.config.allow_groups: + return False + if self.config.group_policy == "disabled": + return False + if self.config.group_policy == "mention_only": + return message.mentions_bot + if self.config.group_policy == "reply_or_mention": + return message.mentions_bot or self._is_reply_to_recent_bot_message( + message.reply_to_message_id + ) + return True + + def _is_reply_to_recent_bot_message(self, message_id: Optional[str]) -> bool: + if not message_id: + return False + return message_id in self._recent_sent_message_ids + + def _remember_sent_message_id(self, message_id: str) -> None: + self._recent_sent_message_ids.pop(message_id, None) + self._recent_sent_message_ids[message_id] = None + while len(self._recent_sent_message_ids) > _FEISHU_DEDUP_CACHE_SIZE: + self._recent_sent_message_ids.popitem(last=False) + + async def _download_attachment( + self, + *, + session_key: str, + message_id: str, + file_key: str, + file_name: str, + kind: AttachmentKind, + resource_type: str, + ): + if not self._client or not file_key: + return None + request = ( + GetMessageResourceRequest.builder() + .message_id(message_id) + .file_key(file_key) + .type(resource_type) + .build() + ) + response = await asyncio.to_thread(self._client.im.v1.message_resource.get, request) + if not response.success(): + logger.warning( + "Failed to download Feishu attachment: code=%s msg=%s", + response.code, + response.msg, + ) + return None + try: + file_data = await asyncio.to_thread( + _read_attachment_body, + response.file, + self.attachment_cache.max_attachment_bytes, + ) + except ValueError as exc: + logger.warning( + "Rejected Feishu attachment for session %s: %s", + session_key, + exc, + ) + return None + return self.attachment_cache.save_bytes( + session_key=session_key, + data=file_data, + filename=file_name, + kind=kind, + ) + + def _fetch_bot_open_id(self) -> Optional[str]: + if not self._client or not lark: + return None + try: + request = ( + lark.BaseRequest.builder() + .http_method(lark.HttpMethod.GET) + .uri("/open-apis/bot/v3/info") + .token_types({lark.AccessTokenType.APP}) + .build() + ) + response = self._client.request(request) + if not response.success(): + logger.warning( + "Failed to fetch Feishu bot info: code=%s msg=%s", + response.code, + response.msg, + ) + return None + payload = json.loads(response.raw.content) + bot = (payload.get("data") or payload).get("bot") or {} + return _optional_str(bot.get("open_id")) + except Exception as exc: + logger.warning("Failed to resolve Feishu bot open_id: %s", exc) + return None + + async def _fetch_message_text(self, message_id: Optional[str]) -> Optional[str]: + if not self._client or not message_id or GetMessageRequest is None: + return None + + request = GetMessageRequest.builder().message_id(message_id).build() + try: + response = await asyncio.to_thread(self._client.im.v1.message.get, request) + except Exception as exc: + logger.debug("Failed to fetch Feishu parent message %s: %s", message_id, exc) + return None + if not response.success(): + return None + + data = getattr(response, "data", None) + message_obj = None + items = getattr(data, "items", None) + if items: + message_obj = items[0] + elif data is not None: + message_obj = getattr(data, "message", None) or data + if message_obj is None: + return None + + body = getattr(message_obj, "body", None) + raw_content = getattr(body, "content", None) if body is not None else getattr(message_obj, "content", None) + message_type = ( + getattr(message_obj, "msg_type", None) + or getattr(message_obj, "message_type", None) + or "" + ) + content = _safe_json_loads(raw_content) + text = "" + if str(message_type).lower() == "text": + text = str(content.get("text", "")).strip() + elif str(message_type).lower() == "post": + text = _extract_post_text(content) + if not text: + return None + if len(text) > self._REPLY_CONTEXT_MAX_LEN: + text = text[: self._REPLY_CONTEXT_MAX_LEN] + "..." + return text + + def _mentions_bot(self, mentions: list[Any]) -> bool: + if not mentions: + return False + if not self._bot_open_id: + return False + + for mention in mentions: + mention_id = (mention.get("id") or {}) if isinstance(mention, dict) else getattr(mention, "id", None) + open_id = ( + _optional_str(mention_id.get("open_id")) + if isinstance(mention_id, dict) + else _optional_str(getattr(mention_id, "open_id", None)) + ) + if open_id == self._bot_open_id: + return True + return False + + def _load_seen_message_ids(self) -> None: + if not self._dedup_state_path.exists(): + return + try: + payload = json.loads(self._dedup_state_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + logger.warning("Failed to load Feishu dedup cache from %s", self._dedup_state_path) + return + entries = payload.get("message_ids", {}) if isinstance(payload, dict) else {} + now = time.time() + valid: list[tuple[str, float]] = [] + if isinstance(entries, dict): + for message_id, seen_at in entries.items(): + normalized_id = _optional_str(message_id) + if not normalized_id: + continue + try: + timestamp = float(seen_at) + except (TypeError, ValueError): + continue + if now - timestamp <= _FEISHU_DEDUP_TTL_SECONDS: + valid.append((normalized_id, timestamp)) + for message_id, seen_at in sorted(valid, key=lambda item: item[1])[-_FEISHU_DEDUP_CACHE_SIZE:]: + self._seen_message_ids[message_id] = seen_at + + def _persist_seen_message_ids(self) -> None: + if not self._dedup_dirty: + return + self._dedup_state_path.parent.mkdir(parents=True, exist_ok=True) + payload = {"message_ids": dict(self._seen_message_ids)} + try: + self._dedup_state_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + except OSError: + logger.warning("Failed to persist Feishu dedup cache to %s", self._dedup_state_path) + return + self._dedup_dirty = False + + def _is_message_seen(self, message_id: str) -> bool: + now = time.time() + self._prune_seen_message_ids(now) + return message_id in self._seen_message_ids + + def _remember_message_seen(self, message_id: str) -> None: + now = time.time() + self._prune_seen_message_ids(now) + if message_id in self._seen_message_ids: + self._seen_message_ids.move_to_end(message_id) + return + self._seen_message_ids[message_id] = now + self._seen_message_ids.move_to_end(message_id) + while len(self._seen_message_ids) > _FEISHU_DEDUP_CACHE_SIZE: + self._seen_message_ids.popitem(last=False) + self._dedup_dirty = True + self._persist_seen_message_ids() + + def _mark_message_seen(self, message_id: str) -> bool: + if self._is_message_seen(message_id): + return True + self._remember_message_seen(message_id) + return False + + def _prune_seen_message_ids(self, now: Optional[float] = None) -> None: + current = now or time.time() + stale = [ + message_id + for message_id, seen_at in self._seen_message_ids.items() + if current - seen_at > _FEISHU_DEDUP_TTL_SECONDS + ] + for message_id in stale: + self._seen_message_ids.pop(message_id, None) + self._dedup_dirty = True + + def _check_webhook_rate_limit(self, rate_key: str) -> bool: + now = time.time() + window = self._rate_windows.get(rate_key) + if window is None: + if len(self._rate_windows) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS: + stale_keys = [ + key + for key, timestamps in self._rate_windows.items() + if not timestamps or now - timestamps[-1] > _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS + ] + for key in stale_keys: + self._rate_windows.pop(key, None) + if rate_key not in self._rate_windows and len(self._rate_windows) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS: + return False + window = deque() + self._rate_windows[rate_key] = window + cutoff = now - _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS + while window and window[0] < cutoff: + window.popleft() + if len(window) >= _FEISHU_WEBHOOK_RATE_LIMIT_MAX: + return False + window.append(now) + return True + + def _record_webhook_anomaly(self, remote_ip: str, status: str) -> None: + now = time.time() + current = self._webhook_anomalies.get(remote_ip) + if current and now - current[2] < _FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS: + self._webhook_anomalies[remote_ip] = (current[0] + 1, status, current[2]) + return + self._webhook_anomalies[remote_ip] = (1, status, now) + + def _clear_webhook_anomaly(self, remote_ip: str) -> None: + self._webhook_anomalies.pop(remote_ip, None) + + +def _safe_json_loads(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + try: + return json.loads(str(value or "{}")) + except json.JSONDecodeError: + return {} + + +def _optional_str(value: Any) -> Optional[str]: + if value is None: + return None + value = str(value).strip() + return value or None + + +def _client_ip_from_request(request: web.Request) -> str: + forwarded = str(request.headers.get("x-forwarded-for", "") or "").split(",")[0].strip() + if forwarded: + return forwarded + peer = request.transport.get_extra_info("peername") if request.transport else None + if isinstance(peer, tuple) and peer: + return str(peer[0]) + return request.remote or "unknown" + + +def _resolve_mentions(text: str, mentions: list[Any]) -> str: + if not text or not mentions: + return text + + resolved = text + for mention in mentions: + key = _optional_str( + mention.get("key") if isinstance(mention, dict) else getattr(mention, "key", None) + ) + if not key or key not in resolved: + continue + name = _optional_str( + mention.get("name") if isinstance(mention, dict) else getattr(mention, "name", None) + ) or "user" + resolved = resolved.replace(key, f"@{name}") + return resolved + + +def _split_text(content: str, limit: int) -> list[str]: + text = content.strip() + if not text: + return [""] + if len(text) <= limit: + return [text] + chunks = [] + remaining = text + while remaining: + chunk = remaining[:limit] + if len(remaining) > limit: + split_at = chunk.rfind("\n") + if split_at < limit // 3: + split_at = chunk.rfind(" ") + if split_at >= limit // 3: + chunk = chunk[:split_at] + chunks.append(chunk.strip()) + remaining = remaining[len(chunk):].lstrip() + return [chunk for chunk in chunks if chunk] + + +def _is_webhook_signature_valid(*, encrypt_key: str, headers: Any, body_bytes: bytes) -> bool: + timestamp = str(headers.get("x-lark-request-timestamp", "") or "") + nonce = str(headers.get("x-lark-request-nonce", "") or "") + signature = str(headers.get("x-lark-signature", "") or "") + if not timestamp or not nonce or not signature: + return False + content = f"{timestamp}{nonce}{encrypt_key}{body_bytes.decode('utf-8', errors='replace')}" + expected = hashlib.sha256(content.encode("utf-8")).hexdigest() + return hmac.compare_digest(signature, expected) + + +def _build_session_key_hint(source: ChannelSource) -> str: + parts = [source.platform.value, source.chat_id] + if source.thread_id: + parts.append(source.thread_id) + return "__".join(part.replace("/", "_") for part in parts if part) + + +def _extract_post_text(content: dict[str, Any]) -> str: + texts: list[str] = [] + + def _walk(value: Any) -> None: + if isinstance(value, dict): + title = _optional_str(value.get("title")) + if title: + texts.append(title) + tag = _optional_str(value.get("tag")) + if tag == "at": + user_name = _optional_str(value.get("user_name")) or "user" + texts.append(f"@{user_name}") + else: + text = _optional_str(value.get("text")) + if text: + texts.append(text) + for nested in value.values(): + _walk(nested) + elif isinstance(value, list): + for item in value: + _walk(item) + + _walk(content) + deduped: list[str] = [] + for text in texts: + if text not in deduped: + deduped.append(text) + return "\n".join(deduped).strip() + + +def _read_attachment_body(raw_file: Any, max_bytes: int) -> bytes: + if raw_file is None: + return b"" + + if isinstance(raw_file, bytes): + data = raw_file + elif isinstance(raw_file, bytearray): + data = bytes(raw_file) + elif hasattr(raw_file, "read"): + chunks: list[bytes] = [] + total = 0 + try: + while True: + chunk = raw_file.read(65536) + if not chunk: + break + if isinstance(chunk, str): + chunk = chunk.encode("utf-8") + elif isinstance(chunk, bytearray): + chunk = bytes(chunk) + elif not isinstance(chunk, bytes): + chunk = bytes(chunk) + total += len(chunk) + if total > max_bytes: + raise ValueError( + f"attachment size {total} exceeds limit {max_bytes}" + ) + chunks.append(chunk) + finally: + close = getattr(raw_file, "close", None) + if callable(close): + close() + data = b"".join(chunks) + else: + data = bytes(raw_file) + + if len(data) > max_bytes: + raise ValueError(f"attachment size {len(data)} exceeds limit {max_bytes}") + return data diff --git a/openspace/communication/adapters/whatsapp.py b/openspace/communication/adapters/whatsapp.py new file mode 100644 index 0000000..7218965 --- /dev/null +++ b/openspace/communication/adapters/whatsapp.py @@ -0,0 +1,462 @@ +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import re +import secrets +import shutil +import subprocess +import uuid +from pathlib import Path +from typing import Any, Optional + +import aiohttp + +from openspace.communication.adapters.base import BaseChannelAdapter +from openspace.communication.attachment_cache import AttachmentCache +from openspace.communication.config import WhatsAppConfig +from openspace.communication.types import ( + AttachmentKind, + ChannelMessage, + ChannelPlatform, + ChannelSource, + SendResult, +) +from openspace.utils.logging import Logger + +logger = Logger.get_logger(__name__) + + +class WhatsAppAdapter(BaseChannelAdapter): + def __init__( + self, + config: WhatsAppConfig, + attachment_cache: AttachmentCache, + *, + runtime_dir: Optional[Path] = None, + poll_interval_seconds: float = 1.0, + ): + super().__init__(ChannelPlatform.WHATSAPP) + self.config = config + self.attachment_cache = attachment_cache + self.runtime_dir = ( + Path(runtime_dir).expanduser().resolve() + if runtime_dir is not None + else attachment_cache.base_dir.parent.resolve() + ) + self._poll_interval_seconds = poll_interval_seconds + self._http_session: Optional[aiohttp.ClientSession] = None + self._ws: Optional[aiohttp.ClientWebSocketResponse] = None + self._receiver_task: Optional[asyncio.Task] = None + self._bridge_process: Optional[subprocess.Popen] = None + self._pending_requests: dict[str, asyncio.Future[dict[str, Any]]] = {} + self._auth_event = asyncio.Event() + self._status_event = asyncio.Event() + self._bridge_state = "disconnected" + + def validate_configuration(self) -> None: + if self.config.bridge.enforce_loopback and self.config.bridge.host not in {"127.0.0.1", "localhost"}: + raise ValueError("WhatsApp bridge host must stay on loopback") + + def get_lock_identity(self) -> Optional[tuple[str, str]]: + return ("whatsapp-session", str(self._session_dir().resolve())) + + async def connect(self) -> bool: + self.validate_configuration() + if self._http_session is None: + self._http_session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=20), + ) + + for attempt in range(2): + try: + await self._open_control_socket() + except Exception as exc: + logger.info("WhatsApp bridge connection attempt %s failed: %s", attempt + 1, exc) + if attempt == 0: + await self._start_bridge_process() + await asyncio.sleep(1) + continue + return False + break + + for _ in range(20): + if self._bridge_state == "connected": + self._connected = True + return True + await asyncio.sleep(1) + logger.error("WhatsApp bridge control channel opened but WhatsApp session did not connect") + return False + + async def disconnect(self) -> None: + self._connected = False + self._bridge_state = "disconnected" + self._status_event.clear() + self._auth_event.clear() + + if self._receiver_task is not None: + self._receiver_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._receiver_task + self._receiver_task = None + + if self._ws is not None: + await self._ws.close() + self._ws = None + + for future in self._pending_requests.values(): + if not future.done(): + future.set_exception(RuntimeError("WhatsApp bridge disconnected")) + self._pending_requests.clear() + + if self._http_session is not None: + await self._http_session.close() + self._http_session = None + + if self._bridge_process is not None and self._bridge_process.poll() is None: + self._bridge_process.terminate() + try: + self._bridge_process.wait(timeout=10) + except subprocess.TimeoutExpired: + self._bridge_process.kill() + self._bridge_process = None + + async def send_text( + self, + chat_id: str, + content: str, + *, + reply_to_message_id: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, + ) -> SendResult: + if self._ws is None: + return SendResult(success=False, error="WhatsApp bridge not initialized") + + last_message_id: Optional[str] = None + for chunk in _split_text(content, 60000): + try: + payload = await self._send_command( + { + "type": "send", + "to": chat_id, + "text": chunk, + "replyToMessageId": reply_to_message_id, + } + ) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + last_message_id = _optional_str(payload.get("messageId")) or last_message_id + return SendResult(success=True, message_id=last_message_id) + + async def send_media( + self, + chat_id: str, + *, + file_path: str, + mimetype: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to_message_id: Optional[str] = None, + ) -> SendResult: + if self._ws is None: + return SendResult(success=False, error="WhatsApp bridge not initialized") + try: + payload = await self._send_command( + { + "type": "send_media", + "to": chat_id, + "filePath": file_path, + "mimetype": mimetype, + "caption": caption, + "fileName": file_name, + "replyToMessageId": reply_to_message_id, + } + ) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + return SendResult(success=True, message_id=_optional_str(payload.get("messageId"))) + + async def _open_control_socket(self) -> None: + if self._http_session is None: + raise RuntimeError("WhatsApp bridge HTTP session is not initialized") + if self._receiver_task is not None: + self._receiver_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._receiver_task + self._receiver_task = None + if self._ws is not None: + await self._ws.close() + self._ws = None + self._auth_event.clear() + self._status_event.clear() + ws = await self._http_session.ws_connect( + self.config.bridge.ws_url, + heartbeat=20, + autoping=True, + max_msg_size=4 * 1024 * 1024, + ) + self._ws = ws + self._receiver_task = asyncio.create_task(self._receive_loop(ws)) + await self._send_ws_json({"type": "auth", "token": self._effective_bridge_token()}) + await asyncio.wait_for(self._auth_event.wait(), timeout=5) + + async def _receive_loop(self, ws: aiohttp.ClientWebSocketResponse) -> None: + try: + async for msg in ws: + if msg.type != aiohttp.WSMsgType.TEXT: + if msg.type in { + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.ERROR, + }: + break + continue + try: + payload = json.loads(msg.data) + except json.JSONDecodeError: + logger.warning("Ignoring invalid WhatsApp bridge JSON: %r", msg.data[:200]) + continue + await self._handle_ws_payload(payload) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning("WhatsApp bridge receive loop stopped: %s", exc) + finally: + if self._ws is ws: + self._ws = None + self._connected = False + self._bridge_state = "disconnected" + self._status_event.clear() + for request_id, future in list(self._pending_requests.items()): + if not future.done(): + future.set_exception(RuntimeError("WhatsApp bridge disconnected")) + self._pending_requests.pop(request_id, None) + + async def _handle_ws_payload(self, payload: dict[str, Any]) -> None: + message_type = str(payload.get("type", "")).strip().lower() + if message_type == "auth_ok": + self._auth_event.set() + return + if message_type == "status": + self._bridge_state = str(payload.get("status", "")).strip().lower() or "disconnected" + self._connected = self._bridge_state == "connected" + self._status_event.set() + return + if message_type == "qr": + logger.info("WhatsApp bridge is waiting for QR scan") + return + if message_type == "ack": + request_id = _optional_str(payload.get("requestId")) + if request_id and request_id in self._pending_requests: + future = self._pending_requests.pop(request_id) + if not future.done(): + future.set_result(payload) + return + if message_type == "error": + request_id = _optional_str(payload.get("requestId")) + error = _optional_str(payload.get("error")) or "Unknown bridge error" + if request_id and request_id in self._pending_requests: + future = self._pending_requests.pop(request_id) + if not future.done(): + future.set_exception(RuntimeError(error)) + else: + logger.warning("WhatsApp bridge error: %s", error) + return + if message_type == "message": + message = await self._normalize_event(payload) + if message is not None: + await self.dispatch_message(message) + + async def _send_command(self, payload: dict[str, Any]) -> dict[str, Any]: + if self._ws is None: + raise RuntimeError("WhatsApp bridge control channel is not connected") + request_id = uuid.uuid4().hex[:12] + future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future() + self._pending_requests[request_id] = future + try: + await self._send_ws_json({**payload, "requestId": request_id}) + return await asyncio.wait_for(future, timeout=20) + finally: + self._pending_requests.pop(request_id, None) + + async def _send_ws_json(self, payload: dict[str, Any]) -> None: + if self._ws is None: + raise RuntimeError("WhatsApp bridge control channel is not connected") + await self._ws.send_str(json.dumps(payload, ensure_ascii=False)) + + async def _normalize_event(self, event: dict[str, Any]) -> Optional[ChannelMessage]: + chat_id = str(event.get("chatId", "")).strip() + message_id = str(event.get("messageId", "")).strip() + sender_id = str(event.get("senderId", "")).strip() + if not chat_id or not message_id: + return None + normalized_sender_id = _normalize_whatsapp_identifier(sender_id) + + source = ChannelSource( + platform=ChannelPlatform.WHATSAPP, + chat_id=chat_id, + chat_type="group" if event.get("isGroup") else "dm", + user_id=normalized_sender_id or sender_id or None, + user_name=_optional_str(event.get("senderName")), + chat_name=_optional_str(event.get("chatName")), + ) + + session_key = _build_session_key_hint(source) + attachments = [] + media_type = str(event.get("mediaType", "")).strip().lower() + attachment_kind = AttachmentKind.IMAGE if media_type == "image" else AttachmentKind.DOCUMENT + for media_path in event.get("mediaUrls") or []: + attachment = self.attachment_cache.copy_local_file( + session_key=session_key, + source_path=str(media_path), + kind=attachment_kind, + ) + if attachment is not None: + attachments.append(attachment) + + body = str(event.get("body", "") or "").strip() + return ChannelMessage( + source=source, + text=body, + message_id=message_id, + attachments=attachments, + reply_to_message_id=_optional_str(event.get("replyToMessageId")), + mentions_bot=bool(event.get("mentionsBot")), + metadata={ + "bridge_event": event, + "raw_user_id": sender_id or None, + "auth_candidates": [ + candidate + for candidate in ( + sender_id or None, + normalized_sender_id or None, + f"+{normalized_sender_id}" if normalized_sender_id else None, + ) + if candidate + ], + }, + ) + + async def _start_bridge_process(self) -> None: + if self._bridge_process is not None and self._bridge_process.poll() is None: + return + + bridge_script = self._resolve_bridge_script() + bridge_dir = bridge_script.parent + session_dir = self._session_dir() + session_dir.mkdir(parents=True, exist_ok=True) + self._outbound_media_root().mkdir(parents=True, exist_ok=True) + + if self.config.bridge.auto_install_dependencies and not (bridge_dir / "node_modules").exists(): + subprocess.run( + ["npm", "install", "--silent"], + cwd=bridge_dir, + check=True, + ) + + env = os.environ.copy() + env["BRIDGE_TOKEN"] = self._effective_bridge_token() + env["BRIDGE_MEDIA_ROOT"] = str(self._outbound_media_root()) + if self.config.allowed_users: + env["WHATSAPP_ALLOWED_USERS"] = ",".join(self.config.allowed_users) + if self.config.reply_prefix is not None: + env["WHATSAPP_REPLY_PREFIX"] = self.config.reply_prefix + + self._bridge_process = subprocess.Popen( + [ + "node", + str(bridge_script), + "--host", + self.config.bridge.host, + "--port", + str(self.config.bridge.port), + "--session", + str(session_dir), + "--mode", + self.config.bridge.mode, + ], + cwd=str(bridge_dir), + env=env, + ) + + def _resolve_bridge_script(self) -> Path: + if self.config.bridge.script_path: + custom_path = Path(self.config.bridge.script_path).expanduser().resolve() + return custom_path / "bridge.js" if custom_path.is_dir() else custom_path + + source_dir = Path(__file__).resolve().parent.parent / "bridges" / "whatsapp" + target_dir = self.runtime_dir / "whatsapp-bridge" + target_dir.mkdir(parents=True, exist_ok=True) + for filename in ("bridge.js", "allowlist.js", "package.json"): + shutil.copy2(source_dir / filename, target_dir / filename) + return target_dir / "bridge.js" + + def _effective_bridge_token(self) -> str: + configured = _optional_str(self.config.bridge.token) + if configured: + return configured + + token_path = self.runtime_dir / "bridge_tokens" / "whatsapp.token" + if token_path.exists(): + token = token_path.read_text(encoding="utf-8").strip() + if token: + return token + token_path.parent.mkdir(parents=True, exist_ok=True) + token = secrets.token_urlsafe(32) + token_path.write_text(token, encoding="utf-8") + try: + token_path.chmod(0o600) + except OSError: + pass + return token + + def _session_dir(self) -> Path: + if self.config.bridge.session_dir: + return Path(self.config.bridge.session_dir).expanduser().resolve() + return (self.runtime_dir / "whatsapp" / "session").resolve() + + def _outbound_media_root(self) -> Path: + return (self.runtime_dir / "outbound_media").resolve() + + +def _split_text(content: str, limit: int) -> list[str]: + text = content.strip() + if not text: + return [""] + if len(text) <= limit: + return [text] + chunks = [] + remaining = text + while remaining: + chunk = remaining[:limit] + if len(remaining) > limit: + split_at = chunk.rfind("\n") + if split_at < limit // 3: + split_at = chunk.rfind(" ") + if split_at >= limit // 3: + chunk = chunk[:split_at] + chunks.append(chunk.strip()) + remaining = remaining[len(chunk):].lstrip() + return [chunk for chunk in chunks if chunk] + + +def _optional_str(value: Any) -> Optional[str]: + if value is None: + return None + value = str(value).strip() + return value or None + + +def _build_session_key_hint(source: ChannelSource) -> str: + parts = [source.platform.value, source.chat_id] + if source.thread_id: + parts.append(source.thread_id) + return "__".join(part.replace("/", "_") for part in parts if part) + + +def _normalize_whatsapp_identifier(value: Any) -> str: + normalized = re.sub(r":.*@", "@", str(value or "").strip()) + normalized = re.sub(r"@.*", "", normalized) + return normalized.lstrip("+") diff --git a/openspace/communication/attachment_cache.py b/openspace/communication/attachment_cache.py new file mode 100644 index 0000000..4c660fe --- /dev/null +++ b/openspace/communication/attachment_cache.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import shutil +import uuid +from pathlib import Path +from typing import Optional + +from openspace.utils.logging import Logger + +from .types import AttachmentKind, ChannelAttachment + +logger = Logger.get_logger(__name__) + + +class AttachmentCache: + def __init__( + self, + base_dir: Path, + *, + max_attachment_bytes: int = 25 * 1024 * 1024, + max_session_attachment_bytes: int = 100 * 1024 * 1024, + ): + self.base_dir = base_dir + self.max_attachment_bytes = max_attachment_bytes + self.max_session_attachment_bytes = max_session_attachment_bytes + self.base_dir.mkdir(parents=True, exist_ok=True) + + def session_dir(self, session_key: str) -> Path: + directory = self.base_dir / session_key / "attachments" + directory.mkdir(parents=True, exist_ok=True) + return directory + + def save_bytes( + self, + *, + session_key: str, + data: bytes, + filename: str, + kind: AttachmentKind, + mime_type: str = "", + ) -> Optional[ChannelAttachment]: + data_size = len(data) + if not self._within_limits(session_key, data_size): + return None + + directory = self.session_dir(session_key) + safe_name = _safe_name(filename) + target = directory / f"{uuid.uuid4().hex[:12]}_{safe_name}" + target.write_bytes(data) + return ChannelAttachment( + kind=kind, + path=str(target), + name=safe_name, + mime_type=mime_type, + size_bytes=len(data), + ) + + def copy_local_file( + self, + *, + session_key: str, + source_path: str, + kind: AttachmentKind, + preferred_name: Optional[str] = None, + mime_type: str = "", + ) -> Optional[ChannelAttachment]: + source = Path(source_path).expanduser() + if not source.exists(): + logger.warning("Attachment source does not exist: %s", source) + return None + source_size = source.stat().st_size + if not self._within_limits(session_key, source_size): + return None + + directory = self.session_dir(session_key) + safe_name = _safe_name(preferred_name or source.name) + target = directory / f"{uuid.uuid4().hex[:12]}_{safe_name}" + shutil.copy2(source, target) + return ChannelAttachment( + kind=kind, + path=str(target), + name=safe_name, + mime_type=mime_type, + size_bytes=target.stat().st_size, + metadata={"source_path": str(source)}, + ) + + def _within_limits(self, session_key: str, attachment_size: int) -> bool: + if attachment_size > self.max_attachment_bytes: + logger.warning( + "Rejecting attachment for session %s because %d bytes exceeds limit %d", + session_key, + attachment_size, + self.max_attachment_bytes, + ) + return False + + session_usage = self._session_usage_bytes(session_key) + if session_usage + attachment_size > self.max_session_attachment_bytes: + logger.warning( + "Rejecting attachment for session %s because session quota would exceed %d bytes", + session_key, + self.max_session_attachment_bytes, + ) + return False + return True + + def _session_usage_bytes(self, session_key: str) -> int: + directory = self.base_dir / session_key / "attachments" + if not directory.exists(): + return 0 + + total = 0 + for path in directory.iterdir(): + if path.is_file(): + total += path.stat().st_size + return total + + +def _safe_name(name: str) -> str: + value = (name or "attachment").replace("\x00", "").strip() + value = Path(value).name + return value or "attachment" diff --git a/openspace/communication/bridges/whatsapp/allowlist.js b/openspace/communication/bridges/whatsapp/allowlist.js new file mode 100644 index 0000000..c4a4948 --- /dev/null +++ b/openspace/communication/bridges/whatsapp/allowlist.js @@ -0,0 +1,71 @@ +import path from 'path'; +import { existsSync, readFileSync } from 'fs'; + +export function normalizeWhatsAppIdentifier(value) { + return String(value || '') + .trim() + .replace(/:.*@/, '@') + .replace(/@.*/, '') + .replace(/^\+/, ''); +} + +export function parseAllowedUsers(rawValue) { + return new Set( + String(rawValue || '') + .split(',') + .map((value) => normalizeWhatsAppIdentifier(value)) + .filter(Boolean) + ); +} + +function readMappingFile(sessionDir, identifier, suffix = '') { + const filePath = path.join(sessionDir, `lid-mapping-${identifier}${suffix}.json`); + if (!existsSync(filePath)) { + return null; + } + + try { + const parsed = JSON.parse(readFileSync(filePath, 'utf8')); + const normalized = normalizeWhatsAppIdentifier(parsed); + return normalized || null; + } catch { + return null; + } +} + +export function expandWhatsAppIdentifiers(identifier, sessionDir) { + const normalized = normalizeWhatsAppIdentifier(identifier); + if (!normalized) { + return new Set(); + } + + const resolved = new Set(); + const queue = [normalized]; + while (queue.length > 0) { + const current = queue.shift(); + if (!current || resolved.has(current)) { + continue; + } + resolved.add(current); + for (const suffix of ['', '_reverse']) { + const mapped = readMappingFile(sessionDir, current, suffix); + if (mapped && !resolved.has(mapped)) { + queue.push(mapped); + } + } + } + return resolved; +} + +export function matchesAllowedUser(senderId, allowedUsers, sessionDir) { + if (!allowedUsers || allowedUsers.size === 0) { + return true; + } + const aliases = expandWhatsAppIdentifiers(senderId, sessionDir); + for (const alias of aliases) { + if (allowedUsers.has(alias)) { + return true; + } + } + return false; +} diff --git a/openspace/communication/bridges/whatsapp/bridge.js b/openspace/communication/bridges/whatsapp/bridge.js new file mode 100644 index 0000000..56e3c2d --- /dev/null +++ b/openspace/communication/bridges/whatsapp/bridge.js @@ -0,0 +1,577 @@ +#!/usr/bin/env node +import { + DisconnectReason, + downloadMediaMessage, + fetchLatestBaileysVersion, + makeWASocket, + useMultiFileAuthState, +} from '@whiskeysockets/baileys'; +import { Boom } from '@hapi/boom'; +import pino from 'pino'; +import path from 'path'; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + statSync, + writeFileSync, +} from 'fs'; +import { randomBytes } from 'crypto'; +import qrcode from 'qrcode-terminal'; +import { WebSocketServer, WebSocket } from 'ws'; + +import { + matchesAllowedUser, + normalizeWhatsAppIdentifier, + parseAllowedUsers, +} from './allowlist.js'; + +const args = process.argv.slice(2); + +function getArg(name, defaultValue) { + const index = args.indexOf(`--${name}`); + return index !== -1 && args[index + 1] ? args[index + 1] : defaultValue; +} + +const PORT = parseInt(getArg('port', '3000'), 10); +const HOST = getArg('host', '127.0.0.1'); +const BIND_HOST = HOST === 'localhost' ? '127.0.0.1' : HOST; +const SESSION_DIR = path.resolve( + getArg('session', path.join(process.env.HOME || '~', '.openspace', 'whatsapp', 'session')) +); +const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); +const BRIDGE_TOKEN = String(process.env.BRIDGE_TOKEN || '').trim(); +const DEFAULT_REPLY_PREFIX = 'OpenSpace\n────────────\n'; +const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined + ? DEFAULT_REPLY_PREFIX + : process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n'); +const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || ''); + +const IMAGE_CACHE_DIR = path.join(SESSION_DIR, '..', 'image_cache'); +const DOCUMENT_CACHE_DIR = path.join(SESSION_DIR, '..', 'document_cache'); +const AUDIO_CACHE_DIR = path.join(SESSION_DIR, '..', 'audio_cache'); + +const MAX_RECENT_SENT = 50; +const MAX_RECENT_INBOUND = 512; +const AUTH_TIMEOUT_MS = 5000; + +if (!BRIDGE_TOKEN) { + console.error('BRIDGE_TOKEN is required'); + process.exit(1); +} +if (!['127.0.0.1', 'localhost'].includes(HOST)) { + console.error(`Refusing to bind WhatsApp bridge to non-loopback host: ${HOST}`); + process.exit(1); +} + +mkdirSync(SESSION_DIR, { recursive: true }); +mkdirSync(IMAGE_CACHE_DIR, { recursive: true }); +mkdirSync(DOCUMENT_CACHE_DIR, { recursive: true }); +mkdirSync(AUDIO_CACHE_DIR, { recursive: true }); + +const logger = pino({ level: 'warn' }); +const clients = new Set(); +const recentlySentIds = new Set(); +const recentInboundById = new Map(); + +let sock = null; +let connectionState = 'disconnected'; +let reconnectTimer = null; + +function broadcast(payload) { + const encoded = JSON.stringify(payload); + for (const ws of clients) { + if (ws.readyState === WebSocket.OPEN && ws._authed) { + ws.send(encoded); + } + } +} + +function recordRecentOutbound(messageId) { + if (!messageId) { + return; + } + recentlySentIds.delete(messageId); + recentlySentIds.add(messageId); + while (recentlySentIds.size > MAX_RECENT_SENT) { + recentlySentIds.delete(recentlySentIds.values().next().value); + } +} + +function recordRecentInbound(messageId, rawMessage) { + if (!messageId || !rawMessage) { + return; + } + recentInboundById.delete(messageId); + recentInboundById.set(messageId, rawMessage); + while (recentInboundById.size > MAX_RECENT_INBOUND) { + const firstKey = recentInboundById.keys().next().value; + recentInboundById.delete(firstKey); + } +} + +function currentStatusPayload() { + return { type: 'status', status: connectionState }; +} + +function buildQuotedOptions(replyToMessageId) { + if (!replyToMessageId) { + return {}; + } + const quoted = recentInboundById.get(String(replyToMessageId).trim()); + return quoted ? { quoted } : {}; +} + +function formatOutgoingMessage(message) { + if (WHATSAPP_MODE !== 'self-chat') { + return message; + } + return REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : message; +} + +function buildLidMap() { + const mapping = {}; + try { + for (const fileName of readdirSync(SESSION_DIR)) { + const match = fileName.match(/^lid-mapping-(.+)\.json$/); + if (!match) { + continue; + } + const value = JSON.parse(readFileSync(path.join(SESSION_DIR, fileName), 'utf8')); + const normalized = normalizeWhatsAppIdentifier(value); + if (normalized) { + mapping[normalized] = match[1]; + mapping[match[1]] = normalized; + } + } + } catch { + return {}; + } + return mapping; +} + +let lidToPhone = buildLidMap(); + +function normalizeId(value) { + return normalizeWhatsAppIdentifier(value); +} + +function getMyIdentifiers() { + const ids = new Set(); + if (sock?.user?.id) { + ids.add(normalizeId(sock.user.id)); + } + if (sock?.user?.lid) { + ids.add(normalizeId(sock.user.lid)); + } + return ids; +} + +function getMessageContainer(message) { + return message?.message || {}; +} + +function extractContextInfo(message) { + const container = getMessageContainer(message); + return ( + container.extendedTextMessage?.contextInfo + || container.imageMessage?.contextInfo + || container.videoMessage?.contextInfo + || container.documentMessage?.contextInfo + || container.audioMessage?.contextInfo + || container.conversation?.contextInfo + || null + ); +} + +async function cacheMedia(rawMessage, mediaMessage, targetDir, prefix, fallbackExt) { + const buffer = await downloadMediaMessage( + rawMessage, + 'buffer', + {}, + { logger, reuploadRequest: sock.updateMediaMessage } + ); + mkdirSync(targetDir, { recursive: true }); + const mime = mediaMessage.mimetype || ''; + const ext = mime.includes('/') ? `.${mime.split('/')[1].split(';')[0]}` : fallbackExt; + const filePath = path.join(targetDir, `${prefix}_${randomBytes(6).toString('hex')}${ext || fallbackExt}`); + writeFileSync(filePath, buffer); + return filePath; +} + +function resolveAllowedFilePath(filePath) { + const mediaRootRaw = String(process.env.BRIDGE_MEDIA_ROOT || '').trim(); + if (!mediaRootRaw) { + throw new Error('BRIDGE_MEDIA_ROOT is not configured'); + } + const mediaRoot = realpathSync(mediaRootRaw); + const resolvedPath = realpathSync(String(filePath || '')); + const relative = path.relative(mediaRoot, resolvedPath); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`File path escapes bridge media root: ${filePath}`); + } + const stat = statSync(resolvedPath); + if (!stat.isFile()) { + throw new Error(`File is not a regular file: ${filePath}`); + } + return resolvedPath; +} + +async function sendText(to, text, replyToMessageId) { + if (!sock || connectionState !== 'connected') { + throw new Error('Not connected to WhatsApp'); + } + const sent = await sock.sendMessage( + to, + { text: formatOutgoingMessage(text) }, + buildQuotedOptions(replyToMessageId) + ); + recordRecentOutbound(sent?.key?.id); + return sent; +} + +async function sendMedia(to, filePath, mimetype, caption, fileName, replyToMessageId) { + if (!sock || connectionState !== 'connected') { + throw new Error('Not connected to WhatsApp'); + } + + const resolvedPath = resolveAllowedFilePath(filePath); + if (!existsSync(resolvedPath)) { + throw new Error(`File not found: ${resolvedPath}`); + } + const buffer = readFileSync(resolvedPath); + const normalizedMime = String(mimetype || '').toLowerCase(); + let payload; + if (normalizedMime.startsWith('image/')) { + payload = { image: buffer, caption: caption || undefined }; + } else if (normalizedMime.startsWith('video/')) { + payload = { video: buffer, caption: caption || undefined }; + } else if (normalizedMime.startsWith('audio/')) { + payload = { + audio: buffer, + mimetype: normalizedMime || 'audio/ogg; codecs=opus', + ptt: normalizedMime.includes('ogg') || normalizedMime.includes('opus'), + }; + } else { + payload = { + document: buffer, + fileName: fileName || path.basename(resolvedPath), + caption: caption || undefined, + }; + } + + const sent = await sock.sendMessage( + to, + payload, + buildQuotedOptions(replyToMessageId) + ); + recordRecentOutbound(sent?.key?.id); + return sent; +} + +async function handleInboundMessage(rawMessage) { + if (!rawMessage?.message) { + return; + } + + const chatId = rawMessage.key?.remoteJid || ''; + const senderId = rawMessage.key?.participant || chatId; + const isGroup = chatId.endsWith('@g.us'); + const senderNumber = senderId.replace(/@.*/, ''); + + if (rawMessage.key?.fromMe) { + if (isGroup || chatId.includes('status')) { + return; + } + if (WHATSAPP_MODE === 'bot') { + return; + } + + const myIds = getMyIdentifiers(); + const chatNumber = normalizeId(chatId); + if (!myIds.has(chatNumber)) { + return; + } + } + + if (!rawMessage.key?.fromMe && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) { + return; + } + + const container = getMessageContainer(rawMessage); + const contextInfo = extractContextInfo(rawMessage); + const mentionedIds = (contextInfo?.mentionedJid || []).map((value) => normalizeId(value)); + const mentionsBot = mentionedIds.some((value) => getMyIdentifiers().has(value)); + const replyToMessageId = contextInfo?.stanzaId || null; + + let body = ''; + let hasMedia = false; + let mediaType = ''; + const mediaUrls = []; + + if (container.conversation) { + body = container.conversation; + } else if (container.extendedTextMessage?.text) { + body = container.extendedTextMessage.text; + } else if (container.imageMessage) { + body = container.imageMessage.caption || ''; + hasMedia = true; + mediaType = 'image'; + try { + mediaUrls.push(await cacheMedia(rawMessage, container.imageMessage, IMAGE_CACHE_DIR, 'img', '.jpg')); + } catch (error) { + console.error('[bridge] Failed to download image:', error.message); + } + } else if (container.videoMessage) { + body = container.videoMessage.caption || ''; + hasMedia = true; + mediaType = 'video'; + try { + mediaUrls.push(await cacheMedia(rawMessage, container.videoMessage, DOCUMENT_CACHE_DIR, 'vid', '.mp4')); + } catch (error) { + console.error('[bridge] Failed to download video:', error.message); + } + } else if (container.audioMessage || container.pttMessage) { + hasMedia = true; + mediaType = container.pttMessage ? 'ptt' : 'audio'; + try { + const audioMessage = container.pttMessage || container.audioMessage; + mediaUrls.push(await cacheMedia(rawMessage, audioMessage, AUDIO_CACHE_DIR, 'aud', '.ogg')); + } catch (error) { + console.error('[bridge] Failed to download audio:', error.message); + } + } else if (container.documentMessage) { + body = container.documentMessage.caption || ''; + hasMedia = true; + mediaType = 'document'; + try { + mediaUrls.push( + await cacheMedia( + rawMessage, + container.documentMessage, + DOCUMENT_CACHE_DIR, + 'doc', + path.extname(container.documentMessage.fileName || '') || '.bin' + ) + ); + } catch (error) { + console.error('[bridge] Failed to download document:', error.message); + } + } + + const messageId = rawMessage.key?.id; + if (messageId && recentlySentIds.has(messageId)) { + recentlySentIds.delete(messageId); + return; + } + + if (messageId) { + recordRecentInbound(messageId, rawMessage); + } + + const normalizedSenderId = lidToPhone[normalizeId(senderId)] || senderId; + const event = { + type: 'message', + messageId, + chatId, + senderId: normalizedSenderId, + senderName: rawMessage.pushName || senderNumber, + chatName: isGroup ? chatId.split('@')[0] : (rawMessage.pushName || senderNumber), + isGroup, + body, + hasMedia, + mediaType, + mediaUrls, + replyToMessageId, + mentionedIds, + mentionsBot, + timestamp: rawMessage.messageTimestamp, + }; + broadcast(event); +} + +async function startSocket() { + const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR); + const { version } = await fetchLatestBaileysVersion(); + + sock = makeWASocket({ + version, + auth: state, + logger, + printQRInTerminal: false, + browser: ['OpenSpace', 'Chrome', '120.0'], + syncFullHistory: false, + markOnlineOnConnect: false, + getMessage: async () => ({ conversation: '' }), + }); + + sock.ev.on('creds.update', () => { + saveCreds(); + lidToPhone = buildLidMap(); + }); + + sock.ev.on('connection.update', (update) => { + const { connection, lastDisconnect, qr } = update; + if (qr) { + console.log('\nScan this QR code with WhatsApp on your phone:\n'); + qrcode.generate(qr, { small: true }); + console.log('\nWaiting for scan...\n'); + broadcast({ type: 'qr', qr }); + } + + if (connection === 'close') { + const reason = new Boom(lastDisconnect?.error)?.output?.statusCode; + connectionState = 'disconnected'; + broadcast(currentStatusPayload()); + if (reason === DisconnectReason.loggedOut) { + console.log('Logged out. Delete session and restart to re-authenticate.'); + process.exit(1); + } + clearTimeout(reconnectTimer); + reconnectTimer = setTimeout( + () => startSocket().catch((error) => console.error('WhatsApp reconnect failed:', error)), + reason === 515 ? 1000 : 3000 + ); + } else if (connection === 'open') { + connectionState = 'connected'; + console.log('WhatsApp connected'); + broadcast(currentStatusPayload()); + } + }); + + sock.ev.on('messages.upsert', async ({ messages, type }) => { + if (type !== 'notify' && type !== 'append') { + return; + } + for (const message of messages) { + try { + await handleInboundMessage(message); + } catch (error) { + console.error('[bridge] Failed to normalize inbound message:', error); + } + } + }); +} + +function startBridgeServer() { + const wss = new WebSocketServer({ host: BIND_HOST, port: PORT }); + console.log(`OpenSpace WhatsApp bridge listening on ws://${BIND_HOST}:${PORT} (mode: ${WHATSAPP_MODE})`); + + wss.on('connection', (ws, request) => { + if (request.headers.origin) { + ws.close(4003, 'Origin header is not allowed'); + return; + } + + ws._authed = false; + clients.add(ws); + const authTimeout = setTimeout(() => { + if (!ws._authed) { + ws.close(4001, 'Authentication timeout'); + } + }, AUTH_TIMEOUT_MS); + + ws.once('message', (raw) => { + try { + const payload = JSON.parse(raw.toString()); + if (payload.type !== 'auth' || payload.token !== BRIDGE_TOKEN) { + ws.close(4003, 'Invalid bridge token'); + return; + } + ws._authed = true; + clearTimeout(authTimeout); + ws.send(JSON.stringify({ type: 'auth_ok' })); + ws.send(JSON.stringify(currentStatusPayload())); + + ws.on('message', async (commandRaw) => { + try { + const command = JSON.parse(commandRaw.toString()); + const requestId = command.requestId || null; + let sent = null; + if (command.type === 'send') { + sent = await sendText(command.to, command.text || '', command.replyToMessageId); + } else if (command.type === 'send_media') { + sent = await sendMedia( + command.to, + command.filePath, + command.mimetype, + command.caption, + command.fileName, + command.replyToMessageId + ); + } else { + throw new Error(`Unsupported bridge command: ${command.type}`); + } + ws.send(JSON.stringify({ + type: 'ack', + requestId, + messageId: sent?.key?.id || null, + })); + } catch (error) { + ws.send(JSON.stringify({ + type: 'error', + requestId: (() => { + try { + return JSON.parse(commandRaw.toString()).requestId || null; + } catch { + return null; + } + })(), + error: error?.message || String(error), + })); + } + }); + } catch { + ws.close(4003, 'Invalid auth payload'); + } + }); + + ws.on('close', () => { + clearTimeout(authTimeout); + clients.delete(ws); + }); + ws.on('error', () => { + clearTimeout(authTimeout); + clients.delete(ws); + }); + }); + + return wss; +} + +async function shutdown(server) { + clearTimeout(reconnectTimer); + for (const ws of clients) { + try { + ws.close(); + } catch {} + } + clients.clear(); + if (server) { + await new Promise((resolve) => server.close(resolve)); + } + if (sock) { + try { + sock.end(new Error('Bridge shutdown')); + } catch {} + sock = null; + } +} + +const server = startBridgeServer(); +startSocket().catch((error) => { + console.error('Failed to start WhatsApp socket:', error); + process.exit(1); +}); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, async () => { + try { + await shutdown(server); + } finally { + process.exit(0); + } + }); +} diff --git a/openspace/communication/bridges/whatsapp/package.json b/openspace/communication/bridges/whatsapp/package.json new file mode 100644 index 0000000..0279e20 --- /dev/null +++ b/openspace/communication/bridges/whatsapp/package.json @@ -0,0 +1,17 @@ +{ + "name": "openspace-whatsapp-bridge", + "version": "1.0.0", + "description": "WhatsApp bridge for OpenSpace using Baileys", + "private": true, + "type": "module", + "scripts": { + "start": "node bridge.js" + }, + "dependencies": { + "@hapi/boom": "^10.0.1", + "@whiskeysockets/baileys": "7.0.0-rc.9", + "pino": "^9.0.0", + "qrcode-terminal": "^0.12.0", + "ws": "^8.18.0" + } +} diff --git a/openspace/communication/config.py b/openspace/communication/config.py new file mode 100644 index 0000000..787839d --- /dev/null +++ b/openspace/communication/config.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, Field, field_validator, model_validator + +from openspace.host_detection import load_runtime_env +from openspace.utils.logging import Logger + +logger = Logger.get_logger(__name__) + + +class GatewayServerConfig(BaseModel): + host: str = "127.0.0.1" + port: int = Field(8765, ge=1, le=65535) + health_path: str = "/health" + + @field_validator("health_path") + @classmethod + def validate_health_path(cls, value: str) -> str: + value = value.strip() or "/health" + if not value.startswith("/"): + value = "/" + value + return value + + +class AgentExecutionConfig(BaseModel): + max_iterations: int = Field(20, ge=1, le=200) + enable_recording: bool = True + recording_backends: List[str] = Field(default_factory=lambda: ["shell"]) + backend_scope: Optional[List[str]] = None + grounding_config_path: Optional[str] = None + workspace_root: Optional[str] = None + llm_timeout: float = Field(120.0, ge=1.0, le=3600.0) + + +class SessionProcessingConfig(BaseModel): + history_max_turns: int = Field(12, ge=1, le=100) + max_parallel_sessions: int = Field(2, ge=1, le=64) + idle_ttl_seconds: int = Field(900, ge=30, le=86400) + per_session_queue_size: int = Field(32, ge=1, le=512) + whatsapp_poll_interval_seconds: float = Field(1.0, ge=0.1, le=60.0) + max_attachment_bytes: int = Field(25 * 1024 * 1024, ge=1, le=512 * 1024 * 1024) + max_session_attachment_bytes: int = Field( + 100 * 1024 * 1024, + ge=1, + le=10 * 1024 * 1024 * 1024, + ) + + +class ChannelAccessConfig(BaseModel): + enabled: bool = False + allow_all_users: bool = False + allowed_users: List[str] = Field(default_factory=list) + allow_dm: bool = True + allow_groups: bool = True + group_policy: Literal["disabled", "mention_only", "reply_or_mention", "all"] = "reply_or_mention" + + +class WhatsAppBridgeConfig(BaseModel): + host: str = "127.0.0.1" + port: int = Field(3000, ge=1, le=65535) + script_path: Optional[str] = None + session_dir: Optional[str] = None + mode: Literal["self-chat", "bot"] = "self-chat" + auto_install_dependencies: bool = True + token: Optional[str] = None + enforce_loopback: bool = True + + @model_validator(mode="after") + def validate_loopback_constraints(self) -> "WhatsAppBridgeConfig": + host = self.host.strip().lower() or "127.0.0.1" + if self.enforce_loopback and host not in {"127.0.0.1", "localhost"}: + raise ValueError( + "WhatsApp bridge host must be loopback when enforce_loopback is enabled" + ) + self.host = host + return self + + @property + def base_url(self) -> str: + return f"http://{self.host}:{self.port}" + + @property + def ws_url(self) -> str: + return f"ws://{self.listen_host}:{self.port}" + + @property + def listen_host(self) -> str: + return "127.0.0.1" if self.host == "localhost" else self.host + + +class WhatsAppConfig(ChannelAccessConfig): + bridge: WhatsAppBridgeConfig = Field(default_factory=WhatsAppBridgeConfig) + reply_prefix: Optional[str] = None + + +class FeishuConfig(ChannelAccessConfig): + app_id: Optional[str] = None + app_secret: Optional[str] = None + domain: Literal["feishu", "lark"] = "feishu" + connection_mode: Literal["webhook", "websocket"] = "webhook" + verification_token: Optional[str] = None + encrypt_key: Optional[str] = None + bot_open_id: Optional[str] = None + webhook_path: str = "/feishu/webhook" + + @model_validator(mode="after") + def validate_webhook_requirements(self) -> "FeishuConfig": + if self.enabled and self.connection_mode == "webhook" and not (self.verification_token or "").strip(): + raise ValueError("Feishu webhook mode requires verification_token") + return self + + @field_validator("webhook_path") + @classmethod + def validate_webhook_path(cls, value: str) -> str: + value = value.strip() or "/feishu/webhook" + if not value.startswith("/"): + value = "/" + value + return value + + @model_validator(mode="after") + def validate_webhook_security(self) -> "FeishuConfig": + if self.enabled and self.connection_mode == "webhook": + token = (self.verification_token or "").strip() + if not token: + raise ValueError( + "Feishu webhook mode requires verification_token when enabled" + ) + self.verification_token = token + if self.encrypt_key is not None: + self.encrypt_key = self.encrypt_key.strip() or None + if self.bot_open_id is not None: + self.bot_open_id = self.bot_open_id.strip() or None + return self + + +class CommunicationConfig(BaseModel): + data_dir: str = Field( + default_factory=lambda: str( + Path(__file__).resolve().parents[2] / "logs" / "communication" + ) + ) + server: GatewayServerConfig = Field(default_factory=GatewayServerConfig) + agent: AgentExecutionConfig = Field(default_factory=AgentExecutionConfig) + sessions: SessionProcessingConfig = Field(default_factory=SessionProcessingConfig) + whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) + feishu: FeishuConfig = Field(default_factory=FeishuConfig) + + @property + def openspace(self) -> AgentExecutionConfig: + return self.agent + + @property + def runtime(self) -> SessionProcessingConfig: + return self.sessions + + @property + def data_path(self) -> Path: + return Path(self.data_dir).expanduser().resolve() + + @property + def sessions_dir(self) -> Path: + return self.data_path / "sessions" + + @property + def bridge_assets_dir(self) -> Path: + return Path(__file__).resolve().parent / "bridges" / "whatsapp" + + @property + def runtime_status_path(self) -> Path: + return self.data_path / "runtime_status.json" + + @property + def locks_dir(self) -> Path: + return self.data_path / "locks" + + @property + def bridge_tokens_dir(self) -> Path: + return self.data_path / "bridge_tokens" + + @property + def whatsapp_bridge_token_path(self) -> Path: + return self.bridge_tokens_dir / "whatsapp.token" + + @property + def outbound_media_dir(self) -> Path: + return self.data_path / "outbound_media" + + @property + def feishu_seen_message_ids_path(self) -> Path: + return self.data_path / "feishu_seen_message_ids.json" + + @property + def enabled_platforms(self) -> List[str]: + platforms: List[str] = [] + if self.whatsapp.enabled: + platforms.append("whatsapp") + if self.feishu.enabled: + platforms.append("feishu") + return platforms + + +def load_communication_config(path: Optional[str] = None) -> CommunicationConfig: + load_runtime_env() + config_path = _resolve_config_path(path) + raw: Dict[str, Any] = {} + + if config_path and config_path.is_file(): + with open(config_path, "r", encoding="utf-8") as handle: + raw = json.load(handle) or {} + raw = _normalize_legacy_keys(raw) + logger.info("Loaded communication config: %s", config_path) + + config = CommunicationConfig.model_validate(raw) + _apply_env_overrides(config) + return CommunicationConfig.model_validate(config.model_dump(mode="python")) + + +def _resolve_config_path(path: Optional[str]) -> Optional[Path]: + explicit_path = Path(path).expanduser() if path else None + if explicit_path is not None: + if not explicit_path.is_file(): + raise FileNotFoundError(f"Communication config file not found: {explicit_path}") + return explicit_path + + env_path = ( + Path(os.environ["OPENSPACE_COMMUNICATION_CONFIG"]).expanduser() + if os.environ.get("OPENSPACE_COMMUNICATION_CONFIG") + else None + ) + if env_path is not None: + if not env_path.is_file(): + raise FileNotFoundError(f"Communication config file not found: {env_path}") + return env_path + + default_path = Path(__file__).resolve().parents[1] / "config" / "config_communication.json" + return default_path if default_path.is_file() else None + + +def _apply_env_overrides(config: CommunicationConfig) -> None: + _maybe_set_bool(config.whatsapp, "enabled", os.getenv("WHATSAPP_ENABLED")) + _maybe_set_bool(config.whatsapp, "allow_all_users", os.getenv("WHATSAPP_ALLOW_ALL_USERS")) + _maybe_set_list(config.whatsapp, "allowed_users", os.getenv("WHATSAPP_ALLOWED_USERS")) + _maybe_set_bool(config.whatsapp, "allow_dm", os.getenv("WHATSAPP_ALLOW_DM")) + _maybe_set_bool(config.whatsapp, "allow_groups", os.getenv("WHATSAPP_ALLOW_GROUPS")) + _maybe_set_str(config.whatsapp, "group_policy", os.getenv("WHATSAPP_GROUP_POLICY")) + _maybe_set_str(config.whatsapp.bridge, "host", os.getenv("WHATSAPP_BRIDGE_HOST")) + _maybe_set_int(config.whatsapp.bridge, "port", os.getenv("WHATSAPP_BRIDGE_PORT")) + _maybe_set_str(config.whatsapp.bridge, "script_path", os.getenv("WHATSAPP_BRIDGE_SCRIPT")) + _maybe_set_str(config.whatsapp.bridge, "session_dir", os.getenv("WHATSAPP_SESSION_DIR")) + _maybe_set_str(config.whatsapp.bridge, "mode", os.getenv("WHATSAPP_MODE")) + _maybe_set_str(config.whatsapp.bridge, "token", os.getenv("WHATSAPP_BRIDGE_TOKEN")) + _maybe_set_bool(config.whatsapp.bridge, "enforce_loopback", os.getenv("WHATSAPP_BRIDGE_ENFORCE_LOOPBACK")) + _maybe_set_str(config.whatsapp, "reply_prefix", os.getenv("WHATSAPP_REPLY_PREFIX")) + + _maybe_set_bool(config.feishu, "enabled", os.getenv("FEISHU_ENABLED")) + _maybe_set_bool(config.feishu, "allow_all_users", os.getenv("FEISHU_ALLOW_ALL_USERS")) + _maybe_set_list(config.feishu, "allowed_users", os.getenv("FEISHU_ALLOWED_USERS")) + _maybe_set_bool(config.feishu, "allow_dm", os.getenv("FEISHU_ALLOW_DM")) + _maybe_set_bool(config.feishu, "allow_groups", os.getenv("FEISHU_ALLOW_GROUPS")) + _maybe_set_str(config.feishu, "group_policy", os.getenv("FEISHU_GROUP_POLICY")) + _maybe_set_str(config.feishu, "app_id", os.getenv("FEISHU_APP_ID")) + _maybe_set_str(config.feishu, "app_secret", os.getenv("FEISHU_APP_SECRET")) + _maybe_set_str(config.feishu, "verification_token", os.getenv("FEISHU_VERIFICATION_TOKEN")) + _maybe_set_str(config.feishu, "encrypt_key", os.getenv("FEISHU_ENCRYPT_KEY")) + _maybe_set_str(config.feishu, "bot_open_id", os.getenv("FEISHU_BOT_OPEN_ID")) + _maybe_set_str(config.feishu, "domain", os.getenv("FEISHU_DOMAIN")) + _maybe_set_str(config.feishu, "connection_mode", os.getenv("FEISHU_CONNECTION_MODE")) + _maybe_set_str(config.feishu, "webhook_path", os.getenv("FEISHU_WEBHOOK_PATH")) + + _maybe_set_str(config, "data_dir", os.getenv("OPENSPACE_COMMUNICATION_DATA_DIR")) + _maybe_set_str(config.server, "host", os.getenv("OPENSPACE_COMMUNICATION_HOST")) + _maybe_set_int(config.server, "port", os.getenv("OPENSPACE_COMMUNICATION_PORT")) + _maybe_set_int( + config.agent, + "max_iterations", + os.getenv("OPENSPACE_COMMUNICATION_MAX_ITERATIONS") or os.getenv("OPENSPACE_MAX_ITERATIONS"), + ) + _maybe_set_bool( + config.agent, + "enable_recording", + os.getenv("OPENSPACE_COMMUNICATION_ENABLE_RECORDING") or os.getenv("OPENSPACE_ENABLE_RECORDING"), + ) + _maybe_set_list( + config.agent, + "recording_backends", + os.getenv("OPENSPACE_COMMUNICATION_RECORDING_BACKENDS"), + ) + _maybe_set_list( + config.agent, + "backend_scope", + os.getenv("OPENSPACE_COMMUNICATION_BACKEND_SCOPE") or os.getenv("OPENSPACE_BACKEND_SCOPE"), + ) + _maybe_set_str( + config.agent, + "grounding_config_path", + os.getenv("OPENSPACE_COMMUNICATION_GROUNDING_CONFIG_PATH") or os.getenv("OPENSPACE_CONFIG_PATH"), + ) + _maybe_set_str(config.agent, "workspace_root", os.getenv("OPENSPACE_COMMUNICATION_WORKSPACE_ROOT")) + _maybe_set_float(config.agent, "llm_timeout", os.getenv("OPENSPACE_COMMUNICATION_LLM_TIMEOUT")) + _maybe_set_int(config.sessions, "history_max_turns", os.getenv("OPENSPACE_COMMUNICATION_HISTORY_TURNS")) + _maybe_set_int(config.sessions, "max_parallel_sessions", os.getenv("OPENSPACE_COMMUNICATION_MAX_PARALLEL")) + _maybe_set_int(config.sessions, "idle_ttl_seconds", os.getenv("OPENSPACE_COMMUNICATION_IDLE_TTL")) + _maybe_set_int(config.sessions, "per_session_queue_size", os.getenv("OPENSPACE_COMMUNICATION_QUEUE_SIZE")) + _maybe_set_int( + config.sessions, + "max_attachment_bytes", + os.getenv("OPENSPACE_COMMUNICATION_MAX_ATTACHMENT_BYTES"), + ) + _maybe_set_int( + config.sessions, + "max_session_attachment_bytes", + os.getenv("OPENSPACE_COMMUNICATION_MAX_SESSION_ATTACHMENT_BYTES"), + ) + _maybe_set_float( + config.sessions, + "whatsapp_poll_interval_seconds", + os.getenv("OPENSPACE_COMMUNICATION_WHATSAPP_POLL_INTERVAL"), + ) + + +def _normalize_legacy_keys(raw: Dict[str, Any]) -> Dict[str, Any]: + normalized = dict(raw) + if "agent" not in normalized and "openspace" in normalized: + normalized["agent"] = normalized["openspace"] + if "sessions" not in normalized and "runtime" in normalized: + normalized["sessions"] = normalized["runtime"] + return normalized + + +def _maybe_set_bool(target: Any, field_name: str, raw: Optional[str]) -> None: + if raw is None: + return + lowered = raw.strip().lower() + if lowered in {"true", "1", "yes", "on"}: + setattr(target, field_name, True) + elif lowered in {"false", "0", "no", "off"}: + setattr(target, field_name, False) + + +def _maybe_set_int(target: Any, field_name: str, raw: Optional[str]) -> None: + if raw is None or not raw.strip(): + return + try: + setattr(target, field_name, int(raw)) + except ValueError: + logger.warning("Invalid integer for %s: %r", field_name, raw) + + +def _maybe_set_list(target: Any, field_name: str, raw: Optional[str]) -> None: + if raw is None: + return + values = [item.strip() for item in raw.split(",") if item.strip()] + setattr(target, field_name, values) + + +def _maybe_set_float(target: Any, field_name: str, raw: Optional[str]) -> None: + if raw is None or not raw.strip(): + return + try: + setattr(target, field_name, float(raw)) + except ValueError: + logger.warning("Invalid float for %s: %r", field_name, raw) + + +def _maybe_set_str(target: Any, field_name: str, raw: Optional[str]) -> None: + if raw is None: + return + value = raw.strip() + if value: + setattr(target, field_name, value) diff --git a/openspace/communication/gateway.py b/openspace/communication/gateway.py new file mode 100644 index 0000000..737ae77 --- /dev/null +++ b/openspace/communication/gateway.py @@ -0,0 +1,577 @@ +from __future__ import annotations + +import argparse +import asyncio +import os +from pathlib import Path +from typing import Any, Dict, Optional + +import requests +from aiohttp import web + +from openspace.communication.adapters import FeishuAdapter, WhatsAppAdapter +from openspace.communication.adapters.base import BaseChannelAdapter +from openspace.communication.attachment_cache import AttachmentCache +from openspace.communication.config import CommunicationConfig, load_communication_config +from openspace.communication.gateway_runtime import RuntimeStatusStore, ScopedLock, ScopedLockManager +from openspace.communication.policy import ( + build_attachment_instruction, + is_authorized, + should_accept_message, +) +from openspace.communication.runtime_manager import SessionRuntimeManager +from openspace.communication.session_store import SessionStore +from openspace.communication.types import ChannelMessage, ChannelPlatform, ChannelSession +from openspace.host_detection import build_grounding_config_path, build_llm_kwargs, load_runtime_env +from openspace.tool_layer import OpenSpace, OpenSpaceConfig +from openspace.utils.logging import Logger + +logger = Logger.get_logger(__name__) + + +def _append_no_proxy_hosts(*hosts: str) -> None: + for env_name in ("NO_PROXY", "no_proxy"): + current = os.environ.get(env_name, "") + entries = [entry.strip() for entry in current.split(",") if entry.strip()] + updated = False + for host in hosts: + if host not in entries: + entries.append(host) + updated = True + if updated: + os.environ[env_name] = ",".join(entries) + + +def _configure_ollama_process_env(model: str) -> None: + if not model.lower().startswith("ollama/"): + return + + _append_no_proxy_hosts("127.0.0.1", "localhost") + for env_name in ( + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ): + if os.environ.get(env_name): + logger.info("Clearing %s for local Ollama access", env_name) + os.environ.pop(env_name, None) + + +class CommunicationGateway: + def __init__(self, config: CommunicationConfig): + self.config = config + workspace_root = ( + Path(config.agent.workspace_root).expanduser().resolve() + if config.agent.workspace_root + else None + ) + self.session_store = SessionStore( + config.sessions_dir, + workspace_root=workspace_root, + ) + self.attachment_cache = AttachmentCache( + config.sessions_dir, + max_attachment_bytes=config.sessions.max_attachment_bytes, + max_session_attachment_bytes=config.sessions.max_session_attachment_bytes, + ) + self.runtime_manager = SessionRuntimeManager(config, self._create_openspace_runtime) + self._session_queues: Dict[str, asyncio.Queue[ChannelMessage]] = {} + self._session_workers: Dict[str, asyncio.Task] = {} + self._adapters: Dict[ChannelPlatform, BaseChannelAdapter] = {} + self._web_app: Optional[web.Application] = None + self._web_runner: Optional[web.AppRunner] = None + self._web_site: Optional[web.TCPSite] = None + self._running = False + self._runtime_manager_started = False + self._runtime_status = RuntimeStatusStore(self._runtime_status_path) + self._lock_manager = ScopedLockManager(self._locks_dir) + self._acquired_locks: list[ScopedLock] = [] + + async def start(self) -> None: + if self._running: + return + + self.config.data_path.mkdir(parents=True, exist_ok=True) + self._locks_dir.mkdir(parents=True, exist_ok=True) + self._bridge_tokens_dir.mkdir(parents=True, exist_ok=True) + self._outbound_media_dir.mkdir(parents=True, exist_ok=True) + + try: + self._build_adapters() + for adapter in self._adapters.values(): + validate_configuration = getattr(adapter, "validate_configuration", None) + if callable(validate_configuration): + validate_configuration() + self._acquire_adapter_locks() + self._write_runtime_status("starting") + await self.runtime_manager.start() + self._runtime_manager_started = True + + self._web_app = web.Application() + self._web_app.router.add_get(self.config.server.health_path, self._handle_health) + for adapter in self._adapters.values(): + adapter.register_http_routes(self._web_app) + + self._web_runner = web.AppRunner(self._web_app) + await self._web_runner.setup() + self._web_site = web.TCPSite( + self._web_runner, + self.config.server.host, + self.config.server.port, + ) + await self._web_site.start() + + for adapter in self._adapters.values(): + connected = await adapter.connect() + if not connected: + raise RuntimeError( + f"Communication adapter failed to connect: {adapter.platform.value}" + ) + + self._running = True + self._write_runtime_status("running") + logger.info( + "Communication gateway started on %s:%s for platforms=%s", + self.config.server.host, + self.config.server.port, + ",".join(self.config.enabled_platforms) or "(none)", + ) + except Exception as exc: + await self._rollback_start(exc) + raise + + async def stop(self) -> None: + if not self._running and not self._has_live_resources(): + return + + self._write_runtime_status("stopping") + self._running = False + await self._stop_session_workers() + await self._disconnect_adapters() + await self._cleanup_web_runner() + await self._stop_runtime_manager() + self._release_locks() + self._write_runtime_status("stopped") + logger.info("Communication gateway stopped") + + def _build_adapters(self) -> None: + adapters: Dict[ChannelPlatform, BaseChannelAdapter] = {} + if self.config.whatsapp.enabled: + adapter = self._instantiate_adapter( + WhatsAppAdapter, + self.config.whatsapp, + self.attachment_cache, + runtime_dir=self.config.data_path, + poll_interval_seconds=self.config.sessions.whatsapp_poll_interval_seconds, + ) + adapter.set_message_handler(self.handle_message) + adapters[ChannelPlatform.WHATSAPP] = adapter + if self.config.feishu.enabled: + adapter = self._instantiate_adapter( + FeishuAdapter, + self.config.feishu, + self.attachment_cache, + runtime_dir=self.config.data_path, + ) + adapter.set_message_handler(self.handle_message) + adapters[ChannelPlatform.FEISHU] = adapter + self._adapters = adapters + + @staticmethod + def _instantiate_adapter(adapter_cls: Any, *args: Any, **kwargs: Any) -> BaseChannelAdapter: + try: + return adapter_cls(*args, **kwargs) + except TypeError as exc: + if "unexpected keyword argument" not in str(exc): + raise + compatibility_kwargs = dict(kwargs) + compatibility_kwargs.pop("runtime_dir", None) + return adapter_cls(*args, **compatibility_kwargs) + + def _acquire_adapter_locks(self) -> None: + self._release_locks() + for adapter in self._adapters.values(): + get_lock_identity = getattr(adapter, "get_lock_identity", None) + binding = get_lock_identity() if callable(get_lock_identity) else None + if binding is None: + continue + scope, identity = binding + lock = self._lock_manager.acquire( + scope=scope, + identity=identity, + metadata={"platform": adapter.platform.value}, + ) + self._acquired_locks.append(lock) + + def _release_locks(self) -> None: + while self._acquired_locks: + self._lock_manager.release(self._acquired_locks.pop()) + + def _write_runtime_status( + self, + gateway_state: str, + *, + fatal_error: Optional[str] = None, + ) -> None: + platform_states = { + adapter.platform.value: {"connected": adapter.is_connected} + for adapter in self._adapters.values() + } + self._runtime_status.write( + gateway_state=gateway_state, + platforms=platform_states, + config_path=str(self.config.data_path), + fatal_error=fatal_error, + ) + + async def _rollback_start(self, exc: Exception) -> None: + logger.error("Communication gateway startup failed: %s", exc, exc_info=True) + self._running = False + await self._disconnect_adapters() + await self._cleanup_web_runner() + try: + await self._stop_runtime_manager() + finally: + self._release_locks() + self._write_runtime_status("failed", fatal_error=str(exc)) + + async def _stop_session_workers(self) -> None: + worker_tasks = list(self._session_workers.values()) + self._session_workers.clear() + for task in worker_tasks: + task.cancel() + if worker_tasks: + await asyncio.gather(*worker_tasks, return_exceptions=True) + self._session_queues.clear() + + async def _disconnect_adapters(self) -> None: + adapters = list(self._adapters.values()) + self._adapters.clear() + for adapter in adapters: + try: + await adapter.disconnect() + except Exception: + logger.warning( + "Failed to disconnect adapter during cleanup: %s", + getattr(adapter.platform, "value", "unknown"), + exc_info=True, + ) + + async def _cleanup_web_runner(self) -> None: + if self._web_runner is None: + return + try: + await self._web_runner.cleanup() + finally: + self._web_runner = None + self._web_site = None + self._web_app = None + + async def _stop_runtime_manager(self) -> None: + if not self._runtime_manager_started: + return + try: + await self.runtime_manager.stop() + finally: + self._runtime_manager_started = False + + def _has_live_resources(self) -> bool: + return any( + ( + self._runtime_manager_started, + bool(self._adapters), + self._web_runner is not None, + bool(self._acquired_locks), + bool(self._session_workers), + bool(self._session_queues), + ) + ) + + @property + def _runtime_status_path(self) -> Path: + return getattr(self.config, "runtime_status_path", self.config.data_path / "runtime_status.json") + + @property + def _locks_dir(self) -> Path: + return getattr(self.config, "locks_dir", self.config.data_path / "locks") + + @property + def _bridge_tokens_dir(self) -> Path: + return getattr(self.config, "bridge_tokens_dir", self.config.data_path / "bridge_tokens") + + @property + def _outbound_media_dir(self) -> Path: + return getattr(self.config, "outbound_media_dir", self.config.data_path / "outbound_media") + + async def handle_message(self, message: ChannelMessage) -> None: + session = self.session_store.get_or_create_session(message.source) + queue = self._session_queues.get(session.session_key) + if queue is None: + queue = asyncio.Queue(maxsize=self.config.sessions.per_session_queue_size) + self._session_queues[session.session_key] = queue + worker = self._session_workers.get(session.session_key) + if worker is None or worker.done(): + self._session_workers[session.session_key] = asyncio.create_task( + self._session_worker(session, queue) + ) + await queue.put(message) + + async def _session_worker( + self, + session: ChannelSession, + queue: asyncio.Queue[ChannelMessage], + ) -> None: + session_key = session.session_key + try: + while True: + try: + message = await asyncio.wait_for( + queue.get(), + timeout=self.config.sessions.idle_ttl_seconds, + ) + except asyncio.TimeoutError: + if queue.empty(): + logger.info("Retiring idle communication worker: %s", session_key) + return + continue + + try: + await self._process_message(session, message) + except Exception as exc: + logger.error( + "Failed to process %s message for session %s: %s", + message.source.platform.value, + session.session_key, + exc, + exc_info=True, + ) + adapter = self._adapters.get(message.source.platform) + if adapter: + await adapter.send_text( + message.source.chat_id, + f"OpenSpace communication error: {exc}", + ) + finally: + queue.task_done() + finally: + current_task = asyncio.current_task() + if self._session_workers.get(session_key) is current_task: + self._session_workers.pop(session_key, None) + if queue.empty(): + if ( + self._session_queues.get(session_key) is queue + and session_key not in self._session_workers + ): + self._session_queues.pop(session_key, None) + elif self._running and session_key not in self._session_workers: + self._session_workers[session_key] = asyncio.create_task( + self._session_worker(session, queue) + ) + + async def _process_message(self, session: ChannelSession, message: ChannelMessage) -> None: + platform_config = self._get_platform_config(message.source.platform) + if not is_authorized(message, platform_config): + logger.info( + "Rejected %s message from unauthorized user %s", + message.source.platform.value, + message.source.user_id, + ) + return + + reply_to_bot = self.session_store.is_reply_to_assistant( + session, + message.reply_to_message_id, + ) + if not should_accept_message(message, platform_config, reply_to_bot): + logger.debug( + "Skipped %s group message that did not satisfy policy", + message.source.platform.value, + ) + return + + history = self.session_store.load_history( + session, + self.config.sessions.history_max_turns, + ) + if not message.text.strip(): + message.text = build_attachment_instruction(message) + + self.session_store.append_user_message(session, message) + + result = await self.runtime_manager.execute_turn( + session=session, + message=message, + conversation_history=history, + channel_context=message.to_channel_context(session.session_key), + ) + response_text = self._extract_response_text(result) + + adapter = self._adapters.get(message.source.platform) + if adapter is None: + raise RuntimeError(f"No adapter registered for {message.source.platform.value}") + + send_result = await adapter.send_text( + message.source.chat_id, + response_text, + reply_to_message_id=message.message_id, + ) + if not send_result.success: + logger.warning( + "Failed to send %s response for session %s: %s", + message.source.platform.value, + session.session_key, + send_result.error, + ) + self.session_store.append_assistant_message( + session, + content=response_text, + platform_message_id=send_result.message_id, + metadata={ + "task_id": result.get("task_id"), + "status": result.get("status"), + "send_success": send_result.success, + "send_error": send_result.error, + }, + ) + + async def _handle_health(self, request: web.Request) -> web.Response: + runtime_status = await self.runtime_manager.status() + gateway_status = self._runtime_status.read() or {} + return web.json_response( + { + "status": "ok" if self._running else "starting", + "gateway": gateway_status, + "platforms": { + platform.value: { + "connected": adapter.is_connected, + } + for platform, adapter in self._adapters.items() + }, + "runtime": runtime_status, + "sessions": len(self.session_store.list_sessions()), + } + ) + + async def _create_openspace_runtime(self, session: ChannelSession) -> OpenSpace: + load_runtime_env() + env_model = os.environ.get("OPENSPACE_MODEL", "") + model, llm_kwargs = build_llm_kwargs(env_model) + llm_kwargs = dict(llm_kwargs) + if model.lower().startswith("ollama/"): + llm_kwargs["api_base"] = os.environ.get("OLLAMA_API_BASE", "").strip() or "http://127.0.0.1:11434" + llm_kwargs["api_key"] = os.environ.get("OLLAMA_API_KEY", "").strip() or llm_kwargs.get("api_key") or "ollama" + llm_kwargs.pop("extra_headers", None) + backend_scope = self.config.agent.backend_scope + grounding_config_path = ( + self.config.agent.grounding_config_path + or build_grounding_config_path() + ) + recording_dir = self.config.data_path / "recordings" + openspace_config = OpenSpaceConfig( + llm_model=model, + llm_kwargs=llm_kwargs, + workspace_dir=session.workspace_dir, + grounding_max_iterations=self.config.agent.max_iterations, + enable_recording=self.config.agent.enable_recording, + recording_backends=self.config.agent.recording_backends, + recording_log_dir=str(recording_dir), + backend_scope=backend_scope, + grounding_config_path=grounding_config_path, + llm_timeout=self.config.agent.llm_timeout, + ) + runtime = OpenSpace(openspace_config) + await runtime.initialize() + return runtime + + def _get_platform_config(self, platform: ChannelPlatform) -> Any: + if platform == ChannelPlatform.WHATSAPP: + return self.config.whatsapp + if platform == ChannelPlatform.FEISHU: + return self.config.feishu + raise ValueError(f"Unsupported platform: {platform}") + + @staticmethod + def _extract_response_text(result: Dict[str, Any]) -> str: + response = str(result.get("response", "")).strip() + if response: + return response + error = str(result.get("error", "")).strip() + if error: + return f"OpenSpace error: {error}" + return "OpenSpace completed the task but returned no response." + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="OpenSpace communication gateway", + ) + parser.add_argument( + "--config", + type=str, + help="Path to the communication JSON config file", + ) + subparsers = parser.add_subparsers(dest="command") + run_parser = subparsers.add_parser("run", help="Start the communication gateway") + run_parser.add_argument( + "--config", + type=str, + help="Path to the communication JSON config file", + ) + health_parser = subparsers.add_parser("health", help="Check the running gateway health endpoint") + health_parser.add_argument( + "--config", + type=str, + help="Path to the communication JSON config file", + ) + health_parser.add_argument("--host", type=str, default=None) + health_parser.add_argument("--port", type=int, default=None) + return parser + + +async def _run_gateway(config_path: Optional[str]) -> int: + config = load_communication_config(config_path) + _configure_ollama_process_env(os.environ.get("OPENSPACE_MODEL", "")) + gateway = CommunicationGateway(config) + try: + await gateway.start() + except Exception as exc: + logger.error("Failed to start communication gateway: %s", exc) + return 1 + + try: + while True: + await asyncio.sleep(3600) + except (asyncio.CancelledError, KeyboardInterrupt): + pass + finally: + await gateway.stop() + return 0 + + +def _check_health(config_path: Optional[str], host: Optional[str], port: Optional[int]) -> int: + config = load_communication_config(config_path) + url = f"http://{host or config.server.host}:{port or config.server.port}{config.server.health_path}" + response = requests.get(url, timeout=5) + response.raise_for_status() + print(response.text) + return 0 + + +async def main(argv: Optional[list[str]] = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + command = args.command or "run" + if command == "health": + return _check_health(args.config, args.host, args.port) + return await _run_gateway(args.config) + + +def run_main() -> None: + raise SystemExit(asyncio.run(main())) + + +if __name__ == "__main__": + run_main() diff --git a/openspace/communication/gateway_runtime.py b/openspace/communication/gateway_runtime.py new file mode 100644 index 0000000..cbcdbc4 --- /dev/null +++ b/openspace/communication/gateway_runtime.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import hashlib +import json +import os +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from openspace.utils.logging import Logger + +logger = Logger.get_logger(__name__) + +_GATEWAY_KIND = "openspace-communication-gateway" + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _scope_hash(identity: str) -> str: + return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16] + + +def _process_start_time(pid: int) -> Optional[int]: + stat_path = Path(f"/proc/{pid}/stat") + try: + return int(stat_path.read_text(encoding="utf-8").split()[21]) + except (FileNotFoundError, IndexError, PermissionError, ValueError, OSError): + return None + + +def _is_pid_alive(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + else: + return True + + +def _build_process_record() -> dict[str, Any]: + pid = os.getpid() + return { + "pid": pid, + "kind": _GATEWAY_KIND, + "argv": list(sys.argv), + "start_time": _process_start_time(pid), + } + + +def _record_matches_live_process(record: dict[str, Any]) -> bool: + try: + pid = int(record["pid"]) + except (KeyError, TypeError, ValueError): + return False + if not _is_pid_alive(pid): + return False + + recorded_start_time = record.get("start_time") + live_start_time = _process_start_time(pid) + if recorded_start_time is None or live_start_time is None: + return True + return live_start_time == recorded_start_time + + +def _read_json(path: Path) -> Optional[dict[str, Any]]: + if not path.exists(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +class LockConflictError(RuntimeError): + def __init__(self, scope: str, identity: str, record: Optional[dict[str, Any]] = None): + super().__init__(f"Communication gateway lock is already held for {scope}:{identity}") + self.scope = scope + self.identity = identity + self.record = record or {} + + +@dataclass +class ScopedRuntimeLock: + path: Path + record: dict[str, Any] + released: bool = False + + @classmethod + def acquire( + cls, + *, + locks_dir: Path, + scope: str, + identity: str, + metadata: Optional[dict[str, Any]] = None, + ) -> "ScopedRuntimeLock": + locks_dir.mkdir(parents=True, exist_ok=True) + lock_path = locks_dir / f"{scope}-{_scope_hash(identity)}.lock" + record = { + **_build_process_record(), + "scope": scope, + "identity": identity, + "metadata": metadata or {}, + "created_at": _utcnow_iso(), + "updated_at": _utcnow_iso(), + } + + while True: + existing = _read_json(lock_path) + if existing is not None: + if _record_matches_live_process(existing): + raise LockConflictError(scope, identity, existing) + try: + lock_path.unlink() + except FileNotFoundError: + continue + except OSError as exc: + raise RuntimeError(f"Failed to remove stale gateway lock {lock_path}: {exc}") from exc + elif lock_path.exists(): + try: + lock_path.unlink() + except FileNotFoundError: + continue + except OSError as exc: + raise RuntimeError( + f"Failed to remove malformed gateway lock {lock_path}: {exc}" + ) from exc + continue + + try: + fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError: + continue + + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(record, handle, ensure_ascii=False, indent=2) + except Exception: + try: + lock_path.unlink(missing_ok=True) + except OSError: + pass + raise + return cls(path=lock_path, record=record) + + def release(self) -> None: + if self.released: + return + try: + current = _read_json(self.path) + if current and current.get("pid") == self.record.get("pid"): + self.path.unlink(missing_ok=True) + except OSError as exc: + logger.warning("Failed to release communication gateway lock %s: %s", self.path, exc) + finally: + self.released = True + + +class GatewayRuntimeTracker: + def __init__(self, status_path: Path): + self.status_path = status_path + + def write_status( + self, + *, + gateway_state: str, + platforms: dict[str, dict[str, Any]], + fatal_error: Optional[str] = None, + exit_reason: Optional[str] = None, + config_path: Optional[str] = None, + sessions: Optional[int] = None, + ) -> None: + payload = { + **_build_process_record(), + "gateway_state": gateway_state, + "fatal_error": fatal_error, + "exit_reason": exit_reason, + "config_path": config_path, + "platforms": platforms, + "sessions": sessions, + "updated_at": _utcnow_iso(), + } + _write_json(self.status_path, payload) + + def read_status(self) -> Optional[dict[str, Any]]: + return _read_json(self.status_path) + + +class ScopedLockManager: + def __init__(self, locks_dir: Path): + self.locks_dir = locks_dir + + def acquire( + self, + scope: str, + identity: str, + metadata: Optional[dict[str, Any]] = None, + ) -> "ScopedLock": + return ScopedRuntimeLock.acquire( + locks_dir=self.locks_dir, + scope=scope, + identity=identity, + metadata=metadata, + ) + + @staticmethod + def release(lock: "ScopedLock") -> None: + lock.release() + + +class RuntimeStatusStore: + def __init__(self, status_path: Path): + self._tracker = GatewayRuntimeTracker(status_path) + + def write( + self, + *, + gateway_state: str, + platforms: dict[str, dict[str, Any]], + fatal_error: Optional[str] = None, + exit_reason: Optional[str] = None, + config_path: Optional[str] = None, + sessions: Optional[int] = None, + ) -> None: + self._tracker.write_status( + gateway_state=gateway_state, + platforms=platforms, + fatal_error=fatal_error, + exit_reason=exit_reason, + config_path=config_path, + sessions=sessions, + ) + + def read(self) -> Optional[dict[str, Any]]: + return self._tracker.read_status() + + +ScopedLock = ScopedRuntimeLock diff --git a/openspace/communication/policy.py b/openspace/communication/policy.py new file mode 100644 index 0000000..fb24d0c --- /dev/null +++ b/openspace/communication/policy.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import Any + +from openspace.communication.types import ChannelMessage + + +def build_attachment_instruction(message: ChannelMessage) -> str: + attachment_paths = ", ".join(attachment.path for attachment in message.attachments) + return ( + "Please inspect the attached files and help the user based on their contents. " + f"Attachment paths: {attachment_paths}" + ) + + +def is_authorized(message: ChannelMessage, platform_config: Any) -> bool: + if getattr(platform_config, "allow_all_users", False): + return True + + allowed_users = { + entry.strip() + for entry in getattr(platform_config, "allowed_users", []) + if entry and entry.strip() + } + if not allowed_users: + return False + + user_candidates = { + candidate.strip() + for candidate in ( + message.source.user_id, + message.source.user_name, + message.metadata.get("raw_user_id") if isinstance(message.metadata, dict) else None, + ) + if candidate and candidate.strip() + } + if isinstance(message.metadata, dict): + for candidate in message.metadata.get("auth_candidates", []) or []: + if isinstance(candidate, str) and candidate.strip(): + user_candidates.add(candidate.strip()) + return bool(user_candidates & allowed_users) + + +def should_accept_message( + message: ChannelMessage, + platform_config: Any, + reply_to_bot: bool, +) -> bool: + if message.source.chat_type == "dm": + return bool(getattr(platform_config, "allow_dm", True)) + if not getattr(platform_config, "allow_groups", True): + return False + + group_policy = str(getattr(platform_config, "group_policy", "reply_or_mention")) + if group_policy == "disabled": + return False + if group_policy == "all": + return True + if group_policy == "mention_only": + return message.mentions_bot + return message.mentions_bot or reply_to_bot diff --git a/openspace/communication/runtime_manager.py b/openspace/communication/runtime_manager.py new file mode 100644 index 0000000..5148fbe --- /dev/null +++ b/openspace/communication/runtime_manager.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import asyncio +import contextlib +import uuid +from time import monotonic +from typing import Any, Awaitable, Callable, Dict, Optional + +from openspace.tool_layer import OpenSpace +from openspace.utils.logging import Logger + +from .config import CommunicationConfig +from .types import ChannelMessage, ChannelSession + +logger = Logger.get_logger(__name__) + + +OpenSpaceFactory = Callable[[ChannelSession], Awaitable[OpenSpace]] + + +class SessionRuntime: + def __init__(self, session: ChannelSession, openspace_factory: OpenSpaceFactory): + self.session = session + self._openspace_factory = openspace_factory + self._openspace: Optional[OpenSpace] = None + self._lock = asyncio.Lock() + self.last_used_monotonic = monotonic() + + @property + def openspace(self) -> Optional[OpenSpace]: + return self._openspace + + async def ensure_initialized(self) -> OpenSpace: + if self._openspace is None: + self._openspace = await self._openspace_factory(self.session) + self.last_used_monotonic = monotonic() + return self._openspace + + async def execute_turn( + self, + *, + message: ChannelMessage, + conversation_history: list[dict[str, str]], + channel_context: dict[str, Any], + max_iterations: Optional[int] = None, + ) -> Dict[str, Any]: + async with self._lock: + openspace = await self.ensure_initialized() + self.last_used_monotonic = monotonic() + task_id = f"comm_{self.session.session_key}_{uuid.uuid4().hex[:10]}" + result = await openspace.execute( + task=message.text, + context={ + "conversation_history": conversation_history, + "channel_context": channel_context, + "session_key": self.session.session_key, + }, + workspace_dir=self.session.workspace_dir, + max_iterations=max_iterations, + task_id=task_id, + ) + self.last_used_monotonic = monotonic() + return result + + async def close(self) -> None: + if self._openspace is not None: + await self._openspace.cleanup() + self._openspace = None + + def is_idle(self, idle_ttl_seconds: int) -> bool: + if self._lock.locked(): + return False + return (monotonic() - self.last_used_monotonic) >= idle_ttl_seconds + + +class SessionRuntimeManager: + def __init__( + self, + config: CommunicationConfig, + openspace_factory: OpenSpaceFactory, + ): + self.config = config + self._openspace_factory = openspace_factory + self._runtimes: Dict[str, SessionRuntime] = {} + self._lock = asyncio.Lock() + self._semaphore = asyncio.Semaphore(config.sessions.max_parallel_sessions) + self._eviction_task: Optional[asyncio.Task] = None + + async def start(self) -> None: + if self._eviction_task is None: + self._eviction_task = asyncio.create_task(self._evict_idle_loop()) + + async def stop(self) -> None: + if self._eviction_task is not None: + self._eviction_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._eviction_task + self._eviction_task = None + + async with self._lock: + runtimes = list(self._runtimes.values()) + self._runtimes.clear() + for runtime in runtimes: + await runtime.close() + + async def execute_turn( + self, + *, + session: ChannelSession, + message: ChannelMessage, + conversation_history: list[dict[str, str]], + channel_context: dict[str, Any], + ) -> Dict[str, Any]: + runtime = await self._get_or_create_runtime(session) + async with self._semaphore: + return await runtime.execute_turn( + message=message, + conversation_history=conversation_history, + channel_context=channel_context, + max_iterations=self.config.agent.max_iterations, + ) + + async def status(self) -> Dict[str, Any]: + async with self._lock: + return { + "active_runtimes": len(self._runtimes), + "session_keys": sorted(self._runtimes.keys()), + } + + async def _get_or_create_runtime(self, session: ChannelSession) -> SessionRuntime: + async with self._lock: + runtime = self._runtimes.get(session.session_key) + if runtime is None: + runtime = SessionRuntime(session, self._openspace_factory) + self._runtimes[session.session_key] = runtime + return runtime + + async def _evict_idle_loop(self) -> None: + while True: + await asyncio.sleep(30) + stale_keys: list[str] = [] + async with self._lock: + for session_key, runtime in self._runtimes.items(): + if runtime.is_idle(self.config.sessions.idle_ttl_seconds): + stale_keys.append(session_key) + runtimes = [self._runtimes.pop(key) for key in stale_keys] + for runtime in runtimes: + logger.info("Evicting idle communication runtime: %s", runtime.session.session_key) + await runtime.close() diff --git a/openspace/communication/session_store.py b/openspace/communication/session_store.py new file mode 100644 index 0000000..4c66e39 --- /dev/null +++ b/openspace/communication/session_store.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import json +import re +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from openspace.utils.logging import Logger + +from .types import ChannelAttachment, ChannelMessage, ChannelSession, ChannelSource + +logger = Logger.get_logger(__name__) + + +class SessionStore: + def __init__( + self, + sessions_dir: Path, + *, + workspace_root: Optional[Path] = None, + ): + self.sessions_dir = sessions_dir + self.workspace_root = workspace_root + self.sessions_dir.mkdir(parents=True, exist_ok=True) + if self.workspace_root is not None: + self.workspace_root.mkdir(parents=True, exist_ok=True) + + def get_or_create_session(self, source: ChannelSource) -> ChannelSession: + session_key = build_session_key(source) + session_dir = self.sessions_dir / session_key + session_dir.mkdir(parents=True, exist_ok=True) + + metadata_path = session_dir / "session.json" + transcript_path = session_dir / "transcript.jsonl" + attachments_dir = session_dir / "attachments" + workspace_dir = ( + self.workspace_root / session_key + if self.workspace_root is not None + else session_dir / "workspace" + ) + attachments_dir.mkdir(parents=True, exist_ok=True) + workspace_dir.mkdir(parents=True, exist_ok=True) + + now = _utcnow_iso() + if metadata_path.exists(): + with open(metadata_path, "r", encoding="utf-8") as handle: + data = json.load(handle) + session = ChannelSession.from_dict(data) + session.source = source + session.updated_at = now + else: + session = ChannelSession( + session_key=session_key, + source=source, + session_dir=str(session_dir), + workspace_dir=str(workspace_dir), + attachments_dir=str(attachments_dir), + transcript_path=str(transcript_path), + metadata_path=str(metadata_path), + created_at=now, + updated_at=now, + ) + + self._write_session_metadata(session) + return session + + def append_user_message(self, session: ChannelSession, message: ChannelMessage) -> None: + self._append_transcript_entry( + session, + { + "entry_id": uuid.uuid4().hex, + "role": "user", + "content": message.text, + "platform_message_id": message.message_id, + "reply_to_message_id": message.reply_to_message_id, + "reply_to_text": message.reply_to_text, + "mentions_bot": message.mentions_bot, + "attachments": [attachment.to_context_dict() for attachment in message.attachments], + "source": message.source.to_dict(), + "metadata": message.metadata, + "timestamp": message.received_at.isoformat(), + }, + ) + + def append_assistant_message( + self, + session: ChannelSession, + *, + content: str, + platform_message_id: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> None: + self._append_transcript_entry( + session, + { + "entry_id": uuid.uuid4().hex, + "role": "assistant", + "content": content, + "platform_message_id": platform_message_id, + "metadata": metadata or {}, + "timestamp": _utcnow_iso(), + }, + ) + + def load_history(self, session: ChannelSession, max_turns: int) -> List[Dict[str, str]]: + entries = self._read_transcript_entries(session) + if not entries: + return [] + + selected: List[Dict[str, str]] = [] + user_messages = 0 + for entry in reversed(entries): + role = entry.get("role") + if role not in {"user", "assistant"}: + continue + if role == "assistant" and not _assistant_entry_visible_in_history(entry): + continue + content = str(entry.get("content", "")).strip() + if not content: + continue + selected.append({"role": role, "content": content}) + if role == "user": + user_messages += 1 + if user_messages >= max_turns: + break + selected.reverse() + return selected + + def is_reply_to_assistant(self, session: ChannelSession, message_id: Optional[str]) -> bool: + if not message_id: + return False + for entry in reversed(self._read_transcript_entries(session)): + if entry.get("platform_message_id") == message_id: + return entry.get("role") == "assistant" and _assistant_entry_visible_in_history(entry) + return False + + def list_sessions(self) -> List[ChannelSession]: + sessions: List[ChannelSession] = [] + for metadata_path in sorted(self.sessions_dir.glob("*/session.json")): + try: + with open(metadata_path, "r", encoding="utf-8") as handle: + sessions.append(ChannelSession.from_dict(json.load(handle))) + except Exception as exc: + logger.warning("Failed to load session metadata %s: %s", metadata_path, exc) + return sessions + + def _append_transcript_entry(self, session: ChannelSession, entry: Dict[str, Any]) -> None: + transcript_path = Path(session.transcript_path) + transcript_path.parent.mkdir(parents=True, exist_ok=True) + with open(transcript_path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(entry, ensure_ascii=False) + "\n") + session.updated_at = _utcnow_iso() + self._write_session_metadata(session) + + def _write_session_metadata(self, session: ChannelSession) -> None: + with open(session.metadata_path, "w", encoding="utf-8") as handle: + json.dump(session.to_dict(), handle, ensure_ascii=False, indent=2) + + def _read_transcript_entries(self, session: ChannelSession) -> List[Dict[str, Any]]: + transcript_path = Path(session.transcript_path) + if not transcript_path.exists(): + return [] + entries: List[Dict[str, Any]] = [] + with open(transcript_path, "r", encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + logger.warning("Skipping malformed transcript line in %s", transcript_path) + return entries + + +def build_session_key(source: ChannelSource) -> str: + parts = [source.platform.value, _sanitize(source.chat_id)] + if source.thread_id: + parts.append(_sanitize(source.thread_id)) + return "__".join(part for part in parts if part) + + +def _sanitize(value: str) -> str: + value = re.sub(r"[^a-zA-Z0-9._-]+", "-", str(value).strip()) + value = value.strip("-._") + return value or "unknown" + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _assistant_entry_visible_in_history(entry: Dict[str, Any]) -> bool: + metadata = entry.get("metadata") + if not isinstance(metadata, dict): + return True + return metadata.get("send_success") is not False diff --git a/openspace/communication/types.py b/openspace/communication/types.py new file mode 100644 index 0000000..34ef037 --- /dev/null +++ b/openspace/communication/types.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Dict, List, Optional + + +class ChannelPlatform(str, Enum): + WHATSAPP = "whatsapp" + FEISHU = "feishu" + + +class AttachmentKind(str, Enum): + IMAGE = "image" + DOCUMENT = "document" + FILE = "file" + + +@dataclass(slots=True) +class ChannelAttachment: + kind: AttachmentKind + path: str + name: str = "" + mime_type: str = "" + size_bytes: Optional[int] = None + source_url: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_context_dict(self) -> Dict[str, Any]: + return { + "kind": self.kind.value, + "path": self.path, + "name": self.name, + "mime_type": self.mime_type, + "size_bytes": self.size_bytes, + "source_url": self.source_url, + "metadata": self.metadata, + } + + +@dataclass(slots=True) +class ChannelSource: + platform: ChannelPlatform + chat_id: str + chat_type: str = "dm" + user_id: Optional[str] = None + user_name: Optional[str] = None + chat_name: Optional[str] = None + thread_id: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "platform": self.platform.value, + "chat_id": self.chat_id, + "chat_type": self.chat_type, + "user_id": self.user_id, + "user_name": self.user_name, + "chat_name": self.chat_name, + "thread_id": self.thread_id, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ChannelSource": + return cls( + platform=ChannelPlatform(str(data["platform"])), + chat_id=str(data["chat_id"]), + chat_type=str(data.get("chat_type", "dm")), + user_id=_optional_str(data.get("user_id")), + user_name=_optional_str(data.get("user_name")), + chat_name=_optional_str(data.get("chat_name")), + thread_id=_optional_str(data.get("thread_id")), + ) + + +@dataclass(slots=True) +class ChannelMessage: + source: ChannelSource + text: str + message_id: str + attachments: List[ChannelAttachment] = field(default_factory=list) + reply_to_message_id: Optional[str] = None + reply_to_text: Optional[str] = None + mentions_bot: bool = False + metadata: Dict[str, Any] = field(default_factory=dict) + received_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + def to_channel_context(self, session_key: str) -> Dict[str, Any]: + return { + "platform": self.source.platform.value, + "chat_id": self.source.chat_id, + "chat_type": self.source.chat_type, + "chat_name": self.source.chat_name, + "thread_id": self.source.thread_id, + "user_id": self.source.user_id, + "user_name": self.source.user_name, + "session_key": session_key, + "message_id": self.message_id, + "reply_to_message_id": self.reply_to_message_id, + "reply_to_text": self.reply_to_text, + "attachments": [attachment.to_context_dict() for attachment in self.attachments], + } + + +@dataclass(slots=True) +class ChannelReply: + content: str + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class SendResult: + success: bool + message_id: Optional[str] = None + error: Optional[str] = None + raw_response: Any = None + + +@dataclass(slots=True) +class ChannelSession: + session_key: str + source: ChannelSource + session_dir: str + workspace_dir: str + attachments_dir: str + transcript_path: str + metadata_path: str + created_at: str + updated_at: str + + def to_dict(self) -> Dict[str, Any]: + return { + "session_key": self.session_key, + "source": self.source.to_dict(), + "session_dir": self.session_dir, + "workspace_dir": self.workspace_dir, + "attachments_dir": self.attachments_dir, + "transcript_path": self.transcript_path, + "metadata_path": self.metadata_path, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ChannelSession": + return cls( + session_key=str(data["session_key"]), + source=ChannelSource.from_dict(data["source"]), + session_dir=str(data["session_dir"]), + workspace_dir=str(data["workspace_dir"]), + attachments_dir=str(data["attachments_dir"]), + transcript_path=str(data["transcript_path"]), + metadata_path=str(data["metadata_path"]), + created_at=str(data["created_at"]), + updated_at=str(data["updated_at"]), + ) + + +def _optional_str(value: Any) -> Optional[str]: + if value is None: + return None + value = str(value).strip() + return value or None diff --git a/openspace/config/README.md b/openspace/config/README.md index 74327ff..a65aac3 100644 --- a/openspace/config/README.md +++ b/openspace/config/README.md @@ -1,40 +1,53 @@ # 🔧 Configuration Guide -All configuration applies to both Path A (host agent) and Path B (standalone). Configure once before the first run. - -## 1. API Keys (`.env`) +## 1. LLM Credentials (`.env`) > [!NOTE] -> Create a `.env` file and add your API keys (refer to [`.env.example`](../../.env.example)). When used via host agent (Path A), LLM keys are auto-detected from your agent's config — `.env` is mainly needed for standalone mode. +> Create `openspace/.env` from [`.env.example`](../../.env.example) and set at least one LLM API key. + +Resolution priority (first match wins): + +| Priority | Source | Example | +|----------|--------|---------| +| **Tier 1** | `OPENSPACE_LLM_*` env vars | `OPENSPACE_LLM_API_KEY=sk-xxx` | +| **Tier 2** | Provider-native env vars | `OPENROUTER_API_KEY=sk-or-xxx` | +| **Tier 3** | Host agent config | `~/.nanobot/config.json` / `~/.openclaw/openclaw.json` | + +> [!IMPORTANT] +> Tier 2 blocks Tier 3 — if `.env` has a provider key, host agent config is skipped. + +```bash +# Provider-native — litellm reads automatically +OPENROUTER_API_KEY=sk-or-v1-xxx + +# Or: OpenSpace-native — higher priority, same effect +OPENSPACE_LLM_API_KEY=sk-or-v1-xxx +``` ## 2. Environment Variables -Set via `.env`, MCP config `env` block, or system environment. OpenSpace reads these at startup. +Set via `.env`, MCP config `env` block, or system environment. -| Variable | Required | Description | -|----------|----------|-------------| -| `OPENSPACE_HOST_SKILL_DIRS` | Path A only | Your agent's skill directories (comma-separated). Auto-registered on startup. | -| `OPENSPACE_WORKSPACE` | Recommended | OpenSpace project root. Used for recording logs and workspace resolution. | -| `OPENSPACE_API_KEY` | No | Cloud API key (`sk-xxx`). Register at https://open-space.cloud. | -| `OPENSPACE_MODEL` | No | LLM model override (default: auto-detected or `openrouter/anthropic/claude-sonnet-4.5`). | -| `OPENSPACE_MAX_ITERATIONS` | No | Max agent iterations per task (default: `20`). | -| `OPENSPACE_BACKEND_SCOPE` | No | Enabled backends, comma-separated (default: all — `shell,gui,mcp,web,system`). | - -### Advanced env overrides (rarely needed) - -| Variable | Description | -|----------|-------------| -| `OPENSPACE_LLM_API_KEY` | LLM API key (auto-detected from host agent in Path A) | -| `OPENSPACE_LLM_API_BASE` | LLM API base URL | -| `OPENSPACE_LLM_EXTRA_HEADERS` | Extra HTTP headers for LLM requests (JSON string) | -| `OPENSPACE_LLM_CONFIG` | Arbitrary litellm kwargs (JSON string) | -| `OPENSPACE_API_BASE` | Cloud API base URL (default `https://open-space.cloud/api/v1`) | -| `OPENSPACE_CONFIG_PATH` | Custom grounding config JSON (deep-merged with defaults) | -| `OPENSPACE_SHELL_CONDA_ENV` | Conda environment for shell backend | -| `OPENSPACE_SHELL_WORKING_DIR` | Working directory for shell backend | -| `OPENSPACE_MCP_SERVERS_JSON` | MCP server definitions (JSON string, merged into `mcpServers`) | -| `OPENSPACE_ENABLE_RECORDING` | Record execution traces (default: `true`) | -| `OPENSPACE_LOG_LEVEL` | `DEBUG` / `INFO` / `WARNING` / `ERROR` | +| Variable | Description | Default | +|----------|-------------|---------| +| `OPENSPACE_MODEL` | LLM model | `openrouter/anthropic/claude-sonnet-4.5` | +| `OPENSPACE_LLM_API_KEY` | LLM API key (Tier 1 override) | — | +| `OPENSPACE_LLM_API_BASE` | LLM API base URL | — | +| `OLLAMA_API_BASE` | Local Ollama endpoint for `ollama/*` models | `http://127.0.0.1:11434` | +| `OLLAMA_API_KEY` | Placeholder key for Ollama-compatible clients | `ollama` | +| `OPENSPACE_LLM_EXTRA_HEADERS` | Extra LLM headers (JSON) | — | +| `OPENSPACE_LLM_CONFIG` | Arbitrary litellm kwargs (JSON) | — | +| `OPENSPACE_API_KEY` | Cloud API key ([open-space.cloud](https://open-space.cloud)) | — | +| `OPENSPACE_MAX_ITERATIONS` | Max agent iterations per task | `20` | +| `OPENSPACE_BACKEND_SCOPE` | Enabled backends (comma-separated) | `shell,gui,mcp,web,system` | +| `OPENSPACE_HOST_SKILL_DIRS` | Agent skill directories (comma-separated) | — | +| `OPENSPACE_WORKSPACE` | Project root for logs/workspace | — | +| `OPENSPACE_SHELL_CONDA_ENV` | Conda env for shell backend | — | +| `OPENSPACE_SHELL_WORKING_DIR` | Working dir for shell backend | — | +| `OPENSPACE_CONFIG_PATH` | Custom grounding config JSON | — | +| `OPENSPACE_MCP_SERVERS_JSON` | MCP server definitions (JSON) | — | +| `OPENSPACE_ENABLE_RECORDING` | Record execution traces | `true` | +| `OPENSPACE_LOG_LEVEL` | Log level | `INFO` | ## 3. MCP Servers (`config_mcp.json`) @@ -67,7 +80,7 @@ Shell and GUI backends support two execution modes, set via `"mode"` in `config_ | **How** | `asyncio.subprocess` in-process | HTTP → Flask → subprocess | > [!TIP] -> **Use local mode** for most use cases. For server mode setup (how to enable, platform-specific deps, remote VM control), see [`../local_server/README.md`](../local_server/README.md). +> **Use local mode** for most use cases. For server mode setup, see [`../local_server/README.md`](../local_server/README.md). ## 5. Config Files (`openspace/config/`) @@ -80,6 +93,7 @@ Layered system — later files override earlier ones: | `config_mcp.json` | MCP servers OpenSpace connects to as a client | | `config_security.json` | Security policies, blocked commands, sandboxing | | `config_dev.json` | Dev overrides — copy from `config_dev.json.example` (highest priority) | +| `config_communication.json` | Communication gateway settings for WhatsApp and Feishu. Use `agent` for per-message OpenSpace execution and `sessions` for queue/history limits. LLM model stays in `openspace/.env`. | ### Agent config (`config_agents.json`) @@ -113,3 +127,40 @@ Layered system — later files override earlier ones: | `sandbox_enabled` | Enable sandboxing for all operations | `false` | | Per-backend overrides | Shell, MCP, GUI, Web each have independent security policies | Inherit global | +## 6. Communication Gateway + +The tracked communication config is safe-by-default: loopback-only, channels disabled, and deny-by-default access control. Copy the example config, fill in credentials and `allowed_users`, then explicitly enable the channels you want. The gateway model is not configured here; it inherits `OPENSPACE_MODEL` from `openspace/.env`. + +```bash +cp openspace/config/config_communication.json.example openspace/config/config_communication.json +``` + +Install the Feishu SDK extra when you need Feishu support: + +```bash +pip install -e '.[communication]' +``` + +Start the gateway with either entrypoint: + +```bash +openspace communication run --config openspace/config/config_communication.json +openspace-gateway --config openspace/config/config_communication.json +``` + +Check health: + +```bash +openspace communication health --config openspace/config/config_communication.json +``` + +Notes: + +- The tracked `config_communication.json` now stays local-only and deny-by-default. Keep credentials out of git and populate them from a private working copy or environment variables. +- Set `server.host` to `0.0.0.0` only when Feishu needs to reach the webhook from outside the machine, and pair that with a populated allowlist plus webhook verification secrets. +- Feishu now supports both `webhook` and `websocket` modes. `websocket` matches nanobot's long-connection setup and does not require a public webhook URL. +- WhatsApp requires Node.js and npm. The bundled bridge installs its dependencies on first start when `auto_install_dependencies` is enabled. +- Set `feishu.bot_open_id` if you want strict group mention gating and automatic bot identity discovery is unavailable in your deployment. +- Group chats are gated by `group_policy`. `reply_or_mention` is the default and only accepts messages that mention the bot or reply to a prior assistant message. +- `allowed_users` is enforced when `allow_all_users` is `false`. The secure default is deny-by-default until you populate the allowlist. +- Attachment caching is limited by `sessions.max_attachment_bytes` and `sessions.max_session_attachment_bytes` to bound disk usage per file and per session. diff --git a/openspace/config/config_communication.json.example b/openspace/config/config_communication.json.example new file mode 100644 index 0000000..32974d2 --- /dev/null +++ b/openspace/config/config_communication.json.example @@ -0,0 +1,65 @@ +{ + "data_dir": "./logs/communication", + "server": { + "host": "127.0.0.1", + "port": 8765, + "health_path": "/health" + }, + "agent": { + "max_iterations": 20, + "enable_recording": true, + "recording_backends": [ + "shell" + ], + "backend_scope": null, + "grounding_config_path": null, + "workspace_root": null, + "llm_timeout": 120.0 + }, + "sessions": { + "history_max_turns": 12, + "max_parallel_sessions": 2, + "idle_ttl_seconds": 900, + "per_session_queue_size": 32, + "whatsapp_poll_interval_seconds": 1.0, + "max_attachment_bytes": 26214400, + "max_session_attachment_bytes": 104857600 + }, + "whatsapp": { + "enabled": false, + "allow_all_users": false, + "allowed_users": [ + "15551234567" + ], + "allow_dm": true, + "allow_groups": true, + "group_policy": "reply_or_mention", + "reply_prefix": "OpenSpace\n────────────\n", + "bridge": { + "host": "127.0.0.1", + "port": 3000, + "script_path": null, + "session_dir": null, + "mode": "self-chat", + "auto_install_dependencies": true + } + }, + "feishu": { + "enabled": false, + "allow_all_users": false, + "allowed_users": [ + "ou_xxxxxxxxxxxxx" + ], + "allow_dm": true, + "allow_groups": true, + "group_policy": "reply_or_mention", + "app_id": "cli_xxxxxxxxxxxxx", + "app_secret": "xxxxxxxxxxxxx", + "domain": "feishu", + "connection_mode": "webhook", + "verification_token": "", + "encrypt_key": "", + "bot_open_id": "", + "webhook_path": "/feishu/webhook" + } +} diff --git a/openspace/dashboard_server.py b/openspace/dashboard_server.py index 96d3e50..f6916b6 100644 --- a/openspace/dashboard_server.py +++ b/openspace/dashboard_server.py @@ -419,6 +419,18 @@ def _build_lineage_payload(skill_id: str, store: SkillStore) -> Dict[str, Any]: } +def _workflow_id(workflow_dir: Path) -> str: + """Stable short ID for a workflow directory, unique across roots. + + Uses a hash suffix derived from the resolved path to avoid collisions + when directory names contain the separator character. + """ + import hashlib + resolved = str(workflow_dir.resolve()) + path_hash = hashlib.sha256(resolved.encode()).hexdigest()[:8] + return f"{workflow_dir.name}_{path_hash}" + + def _discover_workflow_dirs() -> List[Path]: discovered: Dict[str, Path] = {} for root in WORKFLOW_ROOTS: @@ -439,14 +451,14 @@ def _scan_workflow_tree(directory: Path, discovered: Dict[str, Path], *, _depth: if not child.is_dir(): continue if (child / "metadata.json").exists() or (child / "traj.jsonl").exists(): - discovered.setdefault(child.name, child) + discovered.setdefault(str(child.resolve()), child) else: _scan_workflow_tree(child, discovered, _depth=_depth + 1, _max_depth=_max_depth) def _get_workflow_dir(workflow_id: str) -> Optional[Path]: for path in _discover_workflow_dirs(): - if path.name == workflow_id: + if _workflow_id(path) == workflow_id: return path return None @@ -464,7 +476,7 @@ def _build_workflow_summary(workflow_dir: Path) -> Dict[str, Any]: for candidate in video_candidates: if candidate.exists(): rel = candidate.relative_to(workflow_dir).as_posix() - video_url = url_for("workflow_artifact", workflow_id=workflow_dir.name, artifact_path=rel) + video_url = url_for("workflow_artifact", workflow_id=_workflow_id(workflow_dir), artifact_path=rel) break outcome = metadata.get("execution_outcome") or {} @@ -514,7 +526,7 @@ def _build_workflow_summary(workflow_dir: Path) -> Dict[str, Any]: iterations = len(trajectory) return { - "id": workflow_dir.name, + "id": _workflow_id(workflow_dir), "path": str(workflow_dir), "task_id": metadata.get("task_id") or metadata.get("task_name") or workflow_dir.name, "task_name": metadata.get("task_name") or metadata.get("task_id") or workflow_dir.name, diff --git a/openspace/grounding/backends/gui/provider.py b/openspace/grounding/backends/gui/provider.py index ec551bf..3f9389e 100644 --- a/openspace/grounding/backends/gui/provider.py +++ b/openspace/grounding/backends/gui/provider.py @@ -4,7 +4,7 @@ from openspace.grounding.core.provider import Provider from openspace.grounding.core.session import BaseSession from openspace.config import get_config from openspace.config.utils import get_config_value -from openspace.platform import get_local_server_config +from openspace.platforms import get_local_server_config from openspace.utils.logging import Logger from .transport.connector import GUIConnector from .transport.local_connector import LocalGUIConnector diff --git a/openspace/grounding/backends/mcp/transport/connectors/http.py b/openspace/grounding/backends/mcp/transport/connectors/http.py index 099f112..012440f 100644 --- a/openspace/grounding/backends/mcp/transport/connectors/http.py +++ b/openspace/grounding/backends/mcp/transport/connectors/http.py @@ -30,6 +30,14 @@ from openspace.grounding.backends.mcp.transport.connectors.base import MCPBaseCo logger = Logger.get_logger(__name__) +def _build_sse_candidate_urls(base_url: str) -> list[str]: + """Try the common FastMCP `/sse` endpoint before the raw base URL.""" + normalized = base_url.rstrip("/") + if normalized.endswith("/sse"): + return [normalized] + return [f"{normalized}/sse", normalized] + + class HttpConnector(MCPBaseConnector): """Connector for MCP implementations using HTTP transport. @@ -210,69 +218,72 @@ class HttpConnector(MCPBaseConnector): except (asyncio.TimeoutError, Exception): pass - # Try SSE fallback - try: - logger.debug(f"Attempting SSE fallback connection to: {self.base_url}") - connection_manager = SseConnectionManager( - self.base_url, self.headers, self.timeout, self.sse_read_timeout - ) - - # Test the connection by starting it with built-in timeout - read_stream, write_stream = await connection_manager.start(timeout=self.timeout) - - # Create and verify ClientSession - test_client = ClientSession(read_stream, write_stream, sampling_callback=None) - - # Add timeout to __aenter__ - use asyncio.wait_for instead of anyio.fail_after - # to avoid cancel scope conflicts with background tasks + # Try SSE fallback. FastMCP commonly exposes legacy SSE on `/sse`, + # but some callers may already pass the full endpoint. + for sse_url in _build_sse_candidate_urls(self.base_url): + connection_manager = None try: - await asyncio.wait_for(test_client.__aenter__(), timeout=self.timeout) - except asyncio.TimeoutError: - raise TimeoutError(f"ClientSession enter timed out after {self.timeout}s") + logger.debug(f"Attempting SSE fallback connection to: {sse_url}") + connection_manager = SseConnectionManager( + sse_url, self.headers, self.timeout, self.sse_read_timeout + ) - try: + # Test the connection by starting it with built-in timeout + read_stream, write_stream = await connection_manager.start(timeout=self.timeout) + + # Create and verify ClientSession + test_client = ClientSession(read_stream, write_stream, sampling_callback=None) + + # Add timeout to __aenter__ - use asyncio.wait_for instead of anyio.fail_after + # to avoid cancel scope conflicts with background tasks try: - await asyncio.wait_for(test_client.initialize(), timeout=self.timeout) + await asyncio.wait_for(test_client.__aenter__(), timeout=self.timeout) except asyncio.TimeoutError: - raise TimeoutError(f"initialize() timed out after {self.timeout}s") - - try: - await asyncio.wait_for(test_client.list_tools(), timeout=self.timeout) - except asyncio.TimeoutError: - raise TimeoutError(f"list_tools() timed out after {self.timeout}s") - - # SUCCESS! Keep the client session (don't close it, closing destroys the streams) - # Store it directly as the client_session for later use - self.transport_type = "SSE" - self._connection_manager = connection_manager - self._connection = connection_manager.get_streams() - self.client_session = test_client # Reuse the working session - logger.debug("SSE transport selected") - return - except TimeoutError: - try: - await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2) - except (asyncio.TimeoutError, Exception): - pass - raise - except Exception as init_error: - # Clean up the test client only on error - try: - await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2) - except (asyncio.TimeoutError, Exception): - pass - raise init_error + raise TimeoutError(f"ClientSession enter timed out after {self.timeout}s") - except Exception as e: - sse_error = e - logger.debug(f"SSE failed: {e}") - - # Clean up the failed connection manager - if connection_manager: try: - await asyncio.wait_for(connection_manager.stop(), timeout=2) - except (asyncio.TimeoutError, Exception): - pass + try: + await asyncio.wait_for(test_client.initialize(), timeout=self.timeout) + except asyncio.TimeoutError: + raise TimeoutError(f"initialize() timed out after {self.timeout}s") + + try: + await asyncio.wait_for(test_client.list_tools(), timeout=self.timeout) + except asyncio.TimeoutError: + raise TimeoutError(f"list_tools() timed out after {self.timeout}s") + + # SUCCESS! Keep the client session (don't close it, closing destroys the streams) + # Store it directly as the client_session for later use + self.transport_type = "SSE" + self._connection_manager = connection_manager + self._connection = connection_manager.get_streams() + self.client_session = test_client # Reuse the working session + logger.debug("SSE transport selected") + return + except TimeoutError: + try: + await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2) + except (asyncio.TimeoutError, Exception): + pass + raise + except Exception as init_error: + # Clean up the test client only on error + try: + await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2) + except (asyncio.TimeoutError, Exception): + pass + raise init_error + + except Exception as e: + sse_error = e + logger.debug(f"SSE failed for {sse_url}: {e}") + + # Clean up the failed connection manager + if connection_manager: + try: + await asyncio.wait_for(connection_manager.stop(), timeout=2) + except (asyncio.TimeoutError, Exception): + pass # Both MCP transports failed, try simple JSON-RPC HTTP as last resort # This is useful for custom MCP servers that don't implement proper MCP transports diff --git a/openspace/grounding/backends/shell/provider.py b/openspace/grounding/backends/shell/provider.py index 1122ef6..2884fbf 100644 --- a/openspace/grounding/backends/shell/provider.py +++ b/openspace/grounding/backends/shell/provider.py @@ -5,7 +5,7 @@ from .transport.connector import ShellConnector from .transport.local_connector import LocalShellConnector from openspace.config import get_config from openspace.config.utils import get_config_value -from openspace.platform.config import get_local_server_config +from openspace.platforms.config import get_local_server_config from openspace.utils.logging import Logger logger = Logger.get_logger(__name__) diff --git a/openspace/grounding/backends/shell/session.py b/openspace/grounding/backends/shell/session.py index 38879ff..7dad2e2 100644 --- a/openspace/grounding/backends/shell/session.py +++ b/openspace/grounding/backends/shell/session.py @@ -375,7 +375,7 @@ If you already have the exact command/script to run, use run_shell instead.""" if base_url is not None: try: - from openspace.platform import SystemInfoClient + from openspace.platforms import SystemInfoClient async with SystemInfoClient(base_url=base_url, timeout=5) as client: info = await client.get_system_info(use_cache=False) diff --git a/openspace/grounding/core/grounding_client.py b/openspace/grounding/core/grounding_client.py index 4f97a7f..ad6c51c 100644 --- a/openspace/grounding/core/grounding_client.py +++ b/openspace/grounding/core/grounding_client.py @@ -337,13 +337,13 @@ class GroundingClient: def get_session_info(self, name: str) -> SessionInfo: """Get session monitoring info""" if name not in self._session_info: - raise ErrorCode.SESSION_NOT_FOUND(name) + raise GroundingError(f"Session not found: {name}", code=ErrorCode.SESSION_NOT_FOUND) return self._session_info[name] def get_session(self, name: str) -> BaseSession: """Get session""" if name not in self._sessions: - raise ErrorCode.SESSION_NOT_FOUND(name) + raise GroundingError(f"Session not found: {name}", code=ErrorCode.SESSION_NOT_FOUND) return self._sessions[name] @@ -479,7 +479,7 @@ class GroundingClient: # Session-level if session_name: if session_name not in self._sessions: - raise ErrorCode.SESSION_NOT_FOUND(session_name) + raise GroundingError(f"Session not found: {session_name}", code=ErrorCode.SESSION_NOT_FOUND) backend_type = self._session_info[session_name].backend_type return await self._fetch_tools( backend_type, @@ -531,7 +531,7 @@ class GroundingClient: use_cache: bool = False ) -> list[BaseTool]: if session_name not in self._session_info: - raise ErrorCode.SESSION_NOT_FOUND(session_name) + raise GroundingError(f"Session not found: {session_name}", code=ErrorCode.SESSION_NOT_FOUND) backend = self._session_info[session_name].backend_type return await self.list_tools(backend, session_name, use_cache) @@ -838,7 +838,7 @@ class GroundingClient: runtime_backend = backend else: if runtime_session not in self._session_info: - raise ErrorCode.SESSION_NOT_FOUND(runtime_session) + raise GroundingError(f"Session not found: {runtime_session}", code=ErrorCode.SESSION_NOT_FOUND) runtime_backend = self._session_info[ runtime_session ].backend_type diff --git a/openspace/host_detection/__init__.py b/openspace/host_detection/__init__.py index 820dbfc..cb31b95 100644 --- a/openspace/host_detection/__init__.py +++ b/openspace/host_detection/__init__.py @@ -21,7 +21,11 @@ Supported host agents: import logging from typing import Dict, Optional -from openspace.host_detection.resolver import build_llm_kwargs, build_grounding_config_path +from openspace.host_detection.resolver import ( + build_grounding_config_path, + build_llm_kwargs, + load_runtime_env, +) from openspace.host_detection.nanobot import ( get_openai_api_key as _nanobot_get_openai_api_key, read_nanobot_mcp_env, @@ -31,6 +35,7 @@ from openspace.host_detection.openclaw import ( get_openclaw_openai_api_key as _openclaw_get_openai_api_key, is_openclaw_host, read_openclaw_skill_env, + try_read_openclaw_config, ) logger = logging.getLogger("openspace.host_detection") @@ -80,12 +85,12 @@ def get_openai_api_key() -> Optional[str]: __all__ = [ "build_llm_kwargs", "build_grounding_config_path", + "load_runtime_env", "get_openai_api_key", "read_host_mcp_env", - # legacy re-exports "read_nanobot_mcp_env", "try_read_nanobot_config", - # openclaw-specific (for direct use if needed) "is_openclaw_host", "read_openclaw_skill_env", + "try_read_openclaw_config", ] diff --git a/openspace/host_detection/nanobot.py b/openspace/host_detection/nanobot.py index c06c743..c766cc4 100644 --- a/openspace/host_detection/nanobot.py +++ b/openspace/host_detection/nanobot.py @@ -12,6 +12,7 @@ from __future__ import annotations import json import logging +import os from pathlib import Path from typing import Any, Dict, List, Optional @@ -31,23 +32,35 @@ PROVIDER_REGISTRY: List[tuple] = [ ("zhipu", ("zhipu", "glm", "zai"), ""), ("dashscope", ("qwen", "dashscope"), ""), ("moonshot", ("moonshot", "kimi"), "https://api.moonshot.ai/v1"), - ("minimax", ("minimax",), "https://api.minimax.io/v1"), + ("minimax", ("minimax",), "https://api.minimaxi.com/v1"), ("groq", ("groq",), ""), ] -NANOBOT_CONFIG_PATH = Path.home() / ".nanobot" / "config.json" + +def _resolve_nanobot_config_path() -> Path: + """Resolve the nanobot config path from env overrides or defaults.""" + explicit = os.environ.get("NANOBOT_CONFIG_PATH", "").strip() + if explicit: + return Path(explicit).expanduser() + + state_dir = os.environ.get("NANOBOT_STATE_DIR", "").strip() + if state_dir: + return Path(state_dir).expanduser() / "config.json" + + return Path.home() / ".nanobot" / "config.json" def _load_nanobot_config() -> Optional[Dict[str, Any]]: - """Load and parse ``~/.nanobot/config.json``. Returns None on failure.""" - if not NANOBOT_CONFIG_PATH.is_file(): + """Load and parse nanobot config.json. Returns None on failure.""" + config_path = _resolve_nanobot_config_path() + if not config_path.is_file(): return None try: - with open(NANOBOT_CONFIG_PATH, encoding="utf-8") as f: + with open(config_path, encoding="utf-8") as f: data = json.load(f) return data if isinstance(data, dict) else None except (json.JSONDecodeError, OSError) as e: - logger.warning("Failed to read nanobot config %s: %s", NANOBOT_CONFIG_PATH, e) + logger.warning("Failed to read nanobot config %s: %s", config_path, e) return None @@ -154,10 +167,11 @@ def try_read_nanobot_config(model: str) -> Optional[Dict[str, Any]]: result["_forced_provider"] = forced_provider if result: + config_path = _resolve_nanobot_config_path() logger.info( "Auto-detected LLM credentials from nanobot config (%s), " "provider matched for model=%r", - NANOBOT_CONFIG_PATH, match_model, + config_path, match_model, ) return result diff --git a/openspace/host_detection/openclaw.py b/openspace/host_detection/openclaw.py index 1107b5c..6a109e0 100644 --- a/openspace/host_detection/openclaw.py +++ b/openspace/host_detection/openclaw.py @@ -1,7 +1,8 @@ """OpenClaw host-agent config reader. Reads ``~/.openclaw/openclaw.json`` to auto-detect: - - LLM provider credentials (via ``auth-profiles`` — not yet implemented) + - LLM provider credentials from env-style config blocks + (``skills.entries.openspace.env`` and ``env.vars``) - Skill-level env block (``skills.entries.openspace.env``) - OpenAI API key for embedding generation @@ -17,20 +18,74 @@ from __future__ import annotations import json import logging +import os from pathlib import Path from typing import Any, Dict, Optional +from openspace.host_detection.nanobot import PROVIDER_REGISTRY + logger = logging.getLogger("openspace.host_detection") _STATE_DIRNAMES = [".openclaw", ".clawdbot", ".moldbot", ".moltbot"] _CONFIG_FILENAMES = ["openclaw.json", "clawdbot.json", "moldbot.json", "moltbot.json"] +_PROVIDER_ENV_VARS: Dict[str, Dict[str, tuple[str, ...]]] = { + "openrouter": { + "api_key": ("OPENROUTER_API_KEY", "OR_API_KEY"), + "api_base": ("OPENROUTER_API_BASE",), + }, + "aihubmix": { + "api_key": ("AIHUBMIX_API_KEY",), + "api_base": ("AIHUBMIX_API_BASE",), + }, + "siliconflow": { + "api_key": ("SILICONFLOW_API_KEY",), + "api_base": ("SILICONFLOW_API_BASE",), + }, + "volcengine": { + "api_key": ("VOLCENGINE_API_KEY", "ARK_API_KEY"), + "api_base": ("VOLCENGINE_API_BASE", "ARK_API_BASE"), + }, + "anthropic": { + "api_key": ("ANTHROPIC_API_KEY",), + "api_base": ("ANTHROPIC_API_BASE",), + }, + "openai": { + "api_key": ("OPENAI_API_KEY",), + "api_base": ("OPENAI_BASE_URL", "OPENAI_API_BASE"), + }, + "deepseek": { + "api_key": ("DEEPSEEK_API_KEY",), + "api_base": ("DEEPSEEK_API_BASE",), + }, + "gemini": { + "api_key": ("GEMINI_API_KEY", "GOOGLE_API_KEY"), + "api_base": ("GEMINI_API_BASE", "GOOGLE_API_BASE"), + }, + "zhipu": { + "api_key": ("ZHIPU_API_KEY",), + "api_base": ("ZHIPU_API_BASE",), + }, + "dashscope": { + "api_key": ("DASHSCOPE_API_KEY",), + "api_base": ("DASHSCOPE_API_BASE",), + }, + "moonshot": { + "api_key": ("MOONSHOT_API_KEY",), + "api_base": ("MOONSHOT_API_BASE",), + }, + "minimax": { + "api_key": ("MINIMAX_API_KEY",), + "api_base": ("MINIMAX_API_BASE",), + }, + "groq": { + "api_key": ("GROQ_API_KEY",), + "api_base": ("GROQ_API_BASE",), + }, +} def _resolve_openclaw_config_path() -> Optional[Path]: """Find the OpenClaw config file on disk.""" - import os - - # 1. Explicit env override explicit = os.environ.get("OPENCLAW_CONFIG_PATH", "").strip() if explicit: p = Path(explicit).expanduser() @@ -38,7 +93,6 @@ def _resolve_openclaw_config_path() -> Optional[Path]: return p return None - # 2. State dir override state_dir = os.environ.get("OPENCLAW_STATE_DIR", "").strip() if state_dir: for fname in _CONFIG_FILENAMES: @@ -46,7 +100,6 @@ def _resolve_openclaw_config_path() -> Optional[Path]: if p.is_file(): return p - # 3. Default locations home = Path.home() for dirname in _STATE_DIRNAMES: for fname in _CONFIG_FILENAMES: @@ -71,6 +124,119 @@ def _load_openclaw_config() -> Optional[Dict[str, Any]]: return None +def _coerce_env_value(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def _pick_env(env_block: Dict[str, Any], names: tuple[str, ...]) -> str: + for name in names: + value = _coerce_env_value(env_block.get(name)) + if value: + return value + return "" + + +def _get_openclaw_env(skill_name: str = "openspace") -> Dict[str, Any]: + """Merge OpenClaw top-level env vars with skill-level env overrides.""" + merged: Dict[str, Any] = {} + data = _load_openclaw_config() + if data and isinstance(data, dict): + env_section = data.get("env", {}) + if isinstance(env_section, dict): + vars_block = env_section.get("vars", {}) + if isinstance(vars_block, dict): + merged.update(vars_block) + merged.update(read_openclaw_skill_env(skill_name)) + return merged + + +def _extract_explicit_llm_kwargs(env_block: Dict[str, Any]) -> Dict[str, Any]: + """Read OpenSpace-native LLM overrides from an env-like dict.""" + result: Dict[str, Any] = {} + + api_key = _coerce_env_value(env_block.get("OPENSPACE_LLM_API_KEY")) + if api_key: + result["api_key"] = api_key + + api_base = _coerce_env_value(env_block.get("OPENSPACE_LLM_API_BASE")) + if api_base: + result["api_base"] = api_base + + extra_headers_raw = _coerce_env_value(env_block.get("OPENSPACE_LLM_EXTRA_HEADERS")) + if extra_headers_raw: + try: + headers = json.loads(extra_headers_raw) + if isinstance(headers, dict): + result["extra_headers"] = headers + except json.JSONDecodeError: + logger.warning( + "Invalid JSON in OpenClaw OPENSPACE_LLM_EXTRA_HEADERS: %r", + extra_headers_raw, + ) + + llm_config_raw = _coerce_env_value(env_block.get("OPENSPACE_LLM_CONFIG")) + if llm_config_raw: + try: + llm_config = json.loads(llm_config_raw) + if isinstance(llm_config, dict): + result.update(llm_config) + except json.JSONDecodeError: + logger.warning( + "Invalid JSON in OpenClaw OPENSPACE_LLM_CONFIG: %r", + llm_config_raw, + ) + + return result + + +def _extract_provider_env( + env_block: Dict[str, Any], + provider: str, + default_base: str = "", +) -> Optional[Dict[str, Any]]: + spec = _PROVIDER_ENV_VARS.get(provider) + if not spec: + return None + + api_key = _pick_env(env_block, spec["api_key"]) + if not api_key: + return None + + result: Dict[str, Any] = {"api_key": api_key} + api_base = _pick_env(env_block, spec.get("api_base", ())) or default_base + if api_base: + result["api_base"] = api_base + return result + + +def _match_provider_env(model: str, env_block: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Resolve provider-native env vars from OpenClaw config for a model.""" + model_lower = model.lower() + model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" + normalized_prefix = model_prefix.replace("-", "_") + + for name, _keywords, default_base in PROVIDER_REGISTRY: + if model_prefix and normalized_prefix == name: + result = _extract_provider_env(env_block, name, default_base) + if result: + return result + + for name, keywords, default_base in PROVIDER_REGISTRY: + if any(keyword in model_lower for keyword in keywords): + result = _extract_provider_env(env_block, name, default_base) + if result: + return result + + for name, _keywords, default_base in PROVIDER_REGISTRY: + result = _extract_provider_env(env_block, name, default_base) + if result: + return result + + return None + + def read_openclaw_skill_env(skill_name: str = "openspace") -> Dict[str, str]: """Read ``skills.entries..env`` from OpenClaw config. @@ -104,34 +270,46 @@ def get_openclaw_openai_api_key() -> Optional[str]: Returns the key string, or None. """ - # Try skill-level env - env = read_openclaw_skill_env("openspace") - key = env.get("OPENAI_API_KEY", "").strip() + env = _get_openclaw_env("openspace") + key = _coerce_env_value(env.get("OPENAI_API_KEY")) if key: logger.debug("Using OpenAI API key from OpenClaw skill env config") return key - # Try top-level config env.vars - data = _load_openclaw_config() - if data: - env_section = data.get("env", {}) - if isinstance(env_section, dict): - vars_block = env_section.get("vars", {}) - if isinstance(vars_block, dict): - key = vars_block.get("OPENAI_API_KEY", "").strip() - if key: - logger.debug("Using OpenAI API key from OpenClaw env.vars config") - return key - return None def is_openclaw_host() -> bool: """Detect if the current environment is running under OpenClaw.""" - import os - # Check OpenClaw-specific env vars if os.environ.get("OPENCLAW_STATE_DIR") or os.environ.get("OPENCLAW_CONFIG_PATH"): return True - # Check if config exists return _resolve_openclaw_config_path() is not None + +def try_read_openclaw_config(model: str) -> Optional[Dict[str, Any]]: + """Read LLM credentials from OpenClaw's env-style config blocks.""" + env_block = _get_openclaw_env("openspace") + if not env_block: + return None + + explicit_kwargs = _extract_explicit_llm_kwargs(env_block) + provider_kwargs = _match_provider_env(model or "", env_block) + + if not explicit_kwargs and not provider_kwargs: + return None + + result: Dict[str, Any] = {} + if provider_kwargs: + result.update(provider_kwargs) + if explicit_kwargs: + result.update(explicit_kwargs) + + config_path = _resolve_openclaw_config_path() + logger.info( + "Auto-detected LLM credentials from OpenClaw config (%s), provider matched for model=%r", + config_path, + model, + ) + return result + + diff --git a/openspace/host_detection/resolver.py b/openspace/host_detection/resolver.py index 3fb3611..54115e5 100644 --- a/openspace/host_detection/resolver.py +++ b/openspace/host_detection/resolver.py @@ -10,10 +10,120 @@ import json import logging import os import tempfile +from pathlib import Path from typing import Any, Dict, Optional logger = logging.getLogger("openspace.host_detection") +_DEFAULT_MODEL = "openrouter/anthropic/claude-sonnet-4.5" + +_PROVIDER_NATIVE_ENV_VARS: Dict[str, tuple[str, ...]] = { + "openrouter": ("OPENROUTER_API_KEY", "OR_API_KEY"), + "aihubmix": ("AIHUBMIX_API_KEY",), + "siliconflow": ("SILICONFLOW_API_KEY",), + "volcengine": ("VOLCENGINE_API_KEY", "ARK_API_KEY"), + "anthropic": ("ANTHROPIC_API_KEY",), + "openai": ("OPENAI_API_KEY",), + "deepseek": ("DEEPSEEK_API_KEY",), + "gemini": ("GEMINI_API_KEY", "GOOGLE_API_KEY"), + "zhipu": ("ZHIPU_API_KEY",), + "dashscope": ("DASHSCOPE_API_KEY",), + "moonshot": ("MOONSHOT_API_KEY",), + "minimax": ("MINIMAX_API_KEY",), + "groq": ("GROQ_API_KEY",), +} + +_env_loaded = False + + +def _load_env_once() -> None: + """Load .env files once per process. + + Search order (first-loaded wins for each key): + 1. ``openspace/.env`` (package root — works regardless of CWD) + 2. ``CWD/.env`` (project-level fallback) + + Uses ``override=False`` so env vars already in the process (e.g. set + by the host agent or the shell) are never overwritten. + """ + global _env_loaded + if _env_loaded: + return + _env_loaded = True + + try: + from dotenv import load_dotenv + except ImportError: + return + + pkg_env = Path(__file__).resolve().parent.parent / ".env" + if pkg_env.is_file(): + load_dotenv(pkg_env) + load_dotenv() + + +def load_runtime_env() -> None: + """Public wrapper for one-time runtime .env loading.""" + _load_env_once() + + +def _pick_first_env(names: tuple[str, ...]) -> str: + for name in names: + value = os.environ.get(name, "").strip() + if value: + return value + return "" + + +def _ensure_local_no_proxy() -> None: + required_hosts = ("127.0.0.1", "localhost") + for env_name in ("NO_PROXY", "no_proxy"): + current = os.environ.get(env_name, "") + entries = [entry.strip() for entry in current.split(",") if entry.strip()] + updated = False + for host in required_hosts: + if host not in entries: + entries.append(host) + updated = True + if updated: + os.environ[env_name] = ",".join(entries) + + +def _infer_provider_name(model: str) -> Optional[str]: + """Infer the provider name from a model string using PROVIDER_REGISTRY.""" + from openspace.host_detection.nanobot import PROVIDER_REGISTRY + + model_lower = (model or "").lower() + model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" + normalized_prefix = model_prefix.replace("-", "_") + + for name, _keywords, _default_base in PROVIDER_REGISTRY: + if model_prefix and normalized_prefix == name: + return name + + for name, keywords, _default_base in PROVIDER_REGISTRY: + if any(keyword in model_lower for keyword in keywords): + return name + + return None + + +def _has_provider_native_env(model: str) -> bool: + """Check if a provider-native API key (e.g. OPENROUTER_API_KEY) exists. + + When True, the key from .env or the process environment is sufficient + for litellm to authenticate — no need to read nanobot / host config. + """ + provider = _infer_provider_name(model) + if not provider: + return False + + env_names = _PROVIDER_NATIVE_ENV_VARS.get(provider) + if not env_names: + return False + + return bool(_pick_first_env(env_names)) + def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]: """Build litellm kwargs and resolve model for OpenSpace's LLM client. @@ -27,39 +137,63 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]: OPENSPACE_LLM_EXTRA_HEADERS → litellm ``extra_headers`` (JSON string) OPENSPACE_LLM_CONFIG → arbitrary litellm kwargs (JSON string) - Tier 2 — Auto-detect from host agent config file:: + Tier 2 — Provider-native env vars already present in the process + (including values loaded from ``openspace/.env``):: - ~/.nanobot/config.json → providers.{matched}.apiKey / apiBase + OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / ... - Tier 3 — Provider-native env vars inherited from the parent process - (e.g. ``OPENROUTER_API_KEY``). Read by litellm automatically. + These take precedence over host-agent config so local/standalone + launches are not hijacked by unrelated host config files. + + Tier 3 — Host-agent config file fallback (only when Tier 1+2 absent):: + + nanobot → ``~/.nanobot/config.json`` + openclaw → ``~/.openclaw/openclaw.json`` Returns: ``(resolved_model, llm_kwargs_dict)`` """ - from openspace.host_detection.nanobot import try_read_nanobot_config + _load_env_once() kwargs: Dict[str, Any] = {} resolved_model = model source = "inherited env" - # --- Tier 2: auto-detect from host config (filled first, may be overridden) --- - host_config = try_read_nanobot_config(model) + has_explicit_llm_override = bool( + os.environ.get("OPENSPACE_LLM_API_BASE") + or os.environ.get("OPENSPACE_LLM_API_KEY") + ) + provider_native_env_used = _has_provider_native_env( + resolved_model or _DEFAULT_MODEL + ) + + # --- Tier 3: host config fallback (only when no local keys) --- + host_config = None + host_source = None + if not has_explicit_llm_override and not provider_native_env_used: + from openspace.host_detection.nanobot import try_read_nanobot_config + host_config = try_read_nanobot_config(model) + if host_config: + host_source = "nanobot config" + else: + from openspace.host_detection.openclaw import try_read_openclaw_config + host_config = try_read_openclaw_config(model) + if host_config: + host_source = "openclaw config" + if host_config: host_model = host_config.pop("_model", None) forced_provider = host_config.pop("_forced_provider", None) if not resolved_model and host_model: resolved_model = host_model - # If the host config forces a gateway provider (e.g. openrouter) - # and the model name doesn't already carry that prefix, prepend - # it so that litellm uses the correct request format (OpenAI- - # compatible for gateways vs native for direct providers). + _GATEWAY_PROVIDERS = {"openrouter", "aihubmix", "siliconflow"} if ( forced_provider and forced_provider in _GATEWAY_PROVIDERS and resolved_model and not resolved_model.lower().startswith(f"{forced_provider}/") + and not (model and has_explicit_llm_override) ): resolved_model = f"{forced_provider}/{resolved_model}" logger.info( @@ -67,7 +201,7 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]: resolved_model, forced_provider, ) kwargs.update(host_config) - source = "nanobot config" + source = host_source or "host config" # --- Tier 1: explicit env vars override everything --- api_key = os.environ.get("OPENSPACE_LLM_API_KEY") @@ -100,7 +234,38 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]: # Default model fallback if not resolved_model: - resolved_model = "openrouter/anthropic/claude-sonnet-4.5" + resolved_model = _DEFAULT_MODEL + + # Ollama models must use the Ollama-native API base, even when unrelated + # OPENSPACE_LLM_* env vars are present for a different provider. + if resolved_model.lower().startswith("ollama/"): + ollama_base = os.environ.get("OLLAMA_API_BASE", "").strip() or "http://127.0.0.1:11434" + _ensure_local_no_proxy() + kwargs["api_base"] = ollama_base.rstrip("/") + kwargs["api_key"] = os.environ.get("OLLAMA_API_KEY", "").strip() or kwargs.get("api_key") or "ollama" + kwargs.pop("extra_headers", None) + source = "ollama runtime" + + # Provider-specific adjustments for litellm routing + if resolved_model and "minimax" in resolved_model.lower(): + final_key = kwargs.get("api_key") + final_base = kwargs.get("api_base", "") + + if final_key: + os.environ.setdefault("MINIMAX_API_KEY", final_key) + if final_base: + os.environ.setdefault("MINIMAX_API_BASE", final_base) + + if ( + resolved_model.lower().startswith("minimax/") + and "minimaxi.com" in final_base + ): + original = resolved_model + resolved_model = "openai/" + resolved_model.split("/", 1)[1] + logger.info( + "Switched model prefix for minimaxi.com compat: %s -> %s", + original, resolved_model, + ) if kwargs: safe = { @@ -108,6 +273,11 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]: for k, v in kwargs.items() } logger.info("LLM kwargs resolved (source=%s): %s", source, safe) + elif provider_native_env_used: + logger.info( + "LLM credentials resolved from provider-native env for model=%r", + resolved_model, + ) return resolved_model, kwargs @@ -125,6 +295,8 @@ def build_grounding_config_path() -> Optional[str]: Returns: Path to the resolved config file, or None. """ + _load_env_once() + config_json_raw = os.environ.get("OPENSPACE_CONFIG_JSON", "").strip() overrides: Dict[str, Any] = {} if config_json_raw: @@ -180,4 +352,3 @@ def build_grounding_config_path() -> Optional[str]: logger.warning("Failed to write config overrides: %s", e) return os.environ.get("OPENSPACE_CONFIG_PATH") - diff --git a/openspace/host_skills/README.md b/openspace/host_skills/README.md index af38afc..5faf1fd 100644 --- a/openspace/host_skills/README.md +++ b/openspace/host_skills/README.md @@ -2,6 +2,17 @@ This guide covers **agent-specific setup** for integrating OpenSpace. For installation and general concepts, see the [main README](../../README.md#-quick-start). +**Quick recommendation:** +- Use **stdio** if you want the simplest setup. +- For **nanobot**, prefer **SSE** if you want OpenSpace to run as a standalone server. +- For **openclaw**, prefer **streamable-http** for remote HTTP transport. + +**Common remote endpoints:** +- Start `openspace-mcp --transport sse --host 127.0.0.1 --port 8080` and use `http://127.0.0.1:8080/sse` +- Start `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081` and use `http://127.0.0.1:8081/mcp` + +The endpoint is common; the **host config syntax is not**. nanobot uses `tools.mcpServers`, while openclaw uses `openclaw mcp set`. + **Pick your agent:** | Agent | Setup Guide | @@ -21,7 +32,7 @@ cp -r host_skills/skill-discovery/ /path/to/nanobot/nanobot/skills/ cp -r host_skills/delegate-task/ /path/to/nanobot/nanobot/skills/ ``` -### 2. Add MCP server to `~/.nanobot/config.json` +### 2. Option A: stdio (simplest) ```json { @@ -44,44 +55,75 @@ cp -r host_skills/delegate-task/ /path/to/nanobot/nanobot/skills/ > [!TIP] > LLM credentials are auto-detected from nanobot's `providers.*` config — no need to set `OPENSPACE_LLM_API_KEY`. ---- - -## Setup for openclaw - -openclaw ships with a built-in `skills/openspace/` skill — **no need to copy host_skills**. - -> [!NOTE] -> openclaw's built-in skill merges skill-discovery + delegate-task into a single SKILL.md with scenario sub-pages. It uses `mcporter call openspace.` syntax. The underlying MCP tools are identical. - -### 1. Register MCP server - -openclaw uses [mcporter](https://github.com/steipete/mcporter) as its MCP runtime: - -```bash -mcporter config add openspace --command "openspace-mcp" -``` - -### 2. Configure env vars - -Set in `~/.openclaw/openclaw.json`: +### 3. Option B: remote HTTP transport ```json { - "skills": { - "entries": { + "tools": { + "mcpServers": { "openspace": { - "env": { - "OPENSPACE_HOST_SKILL_DIRS": "/path/to/openclaw/skills", - "OPENSPACE_WORKSPACE": "/path/to/OpenSpace", - "OPENSPACE_API_KEY": "sk-xxx" - } + "type": "sse", + "url": "http://127.0.0.1:8080/sse", + "toolTimeout": 1200 } } } } ``` -Or set as system env vars (e.g. `~/.openclaw/.env`). +Or: + +```json +{ + "tools": { + "mcpServers": { + "openspace": { + "type": "streamableHttp", + "url": "http://127.0.0.1:8081/mcp", + "toolTimeout": 1200 + } + } + } +} +``` + +`toolTimeout` still matters here. Changing transport to `sse` or `streamableHttp` does **not** remove nanobot's per-call timeout for slow MCP tools. + +--- + +## Setup for openclaw + +### 1. Copy host skills + +```bash +cp -r host_skills/skill-discovery/ /path/to/openclaw/skills/ +cp -r host_skills/delegate-task/ /path/to/openclaw/skills/ +``` + +### 2. Option A: stdio via mcporter + +openclaw uses [mcporter](https://github.com/steipete/mcporter) as its MCP runtime. Register the server and pass env vars in one command: + +```bash +mcporter config add openspace --command "openspace-mcp" \ + --env OPENSPACE_HOST_SKILL_DIRS=/path/to/openclaw/skills \ + --env OPENSPACE_WORKSPACE=/path/to/OpenSpace \ + --env OPENSPACE_API_KEY=sk-xxx +``` + +### 3. Option B: remote HTTP transport + +```bash +openclaw mcp set openspace '{"url":"http://127.0.0.1:8081/mcp","transport":"streamable-http","connectionTimeoutMs":10000}' +``` + +If you specifically want legacy SSE instead, OpenClaw also supports: + +```bash +openclaw mcp set openspace '{"url":"http://127.0.0.1:8080","connectionTimeoutMs":10000}' +``` + +`connectionTimeoutMs` controls connection establishment for the remote server. It does **not** guarantee unlimited runtime for a long-running MCP tool call. --- @@ -110,7 +152,7 @@ All tools default to `"all"` (local + cloud) and **automatically fall back** to ``` Your Agent (nanobot / openclaw / ...) │ - │ MCP protocol (stdio) + │ MCP protocol (stdio | HTTP/SSE | streamable-http) ▼ openspace-mcp ← 4 tools exposed ├── execute_task ← multi-step grounding agent loop @@ -129,4 +171,4 @@ The two host skills teach the agent **when and how** to call these tools: Skills auto-evolve inside `execute_task` (**FIX** / **DERIVED** / **CAPTURED**). After every call, your agent reports results to the user via its messaging tool. > [!NOTE] -> For full parameter tables, examples, and decision trees, see each skill's SKILL.md directly. \ No newline at end of file +> For full parameter tables, examples, and decision trees, see each skill's SKILL.md directly. diff --git a/openspace/host_skills/delegate-task/SKILL.md b/openspace/host_skills/delegate-task/SKILL.md index ce94061..04b2b90 100644 --- a/openspace/host_skills/delegate-task/SKILL.md +++ b/openspace/host_skills/delegate-task/SKILL.md @@ -5,7 +5,7 @@ description: Delegate tasks to OpenSpace — a full-stack autonomous worker for # Delegate Tasks to OpenSpace -OpenSpace is connected as an MCP server. You have 4 tools available: `execute_task`, `search_skills`, `fix_skill`, `upload_skill`. +OpenSpace is connected as an MCP server. Whether the host uses `stdio`, `sse`, or `streamable-http`, you have the same 4 tools available: `execute_task`, `search_skills`, `fix_skill`, `upload_skill`. ## When to use @@ -127,5 +127,6 @@ upload_skill( ## Notes - `execute_task` may take minutes — this is expected for multi-step tasks. +- If `execute_task` times out, first check the host's MCP timeout settings. Changing from `stdio` to HTTP (`sse` or `streamable-http`) does not remove host-side per-call time limits. - `upload_skill` requires a cloud API key; if it fails, the evolved skill is still saved locally. - After every OpenSpace call, **tell the user** what happened: task result, any evolved skills, and your upload decision. diff --git a/openspace/llm/client.py b/openspace/llm/client.py index 032f2a9..3b49181 100644 --- a/openspace/llm/client.py +++ b/openspace/llm/client.py @@ -2,21 +2,16 @@ import litellm import json import asyncio import time -from pathlib import Path from typing import List, Sequence, Union, Dict, Optional -from dotenv import load_dotenv from openai.types.chat import ChatCompletionToolParam from openspace.grounding.core.types import ToolSchema, ToolResult, ToolStatus from openspace.grounding.core.tool import BaseTool from openspace.utils.logging import Logger -# Load .env from openspace package root (works regardless of CWD), -# then fall back to CWD/.env. override=False (default) means first-loaded wins. -_PKG_ENV = Path(__file__).resolve().parent.parent / ".env" # openspace/.env -if _PKG_ENV.is_file(): - load_dotenv(_PKG_ENV) -load_dotenv() # also try CWD/.env for any remaining vars +# .env loading is centralized in host_detection.resolver.load_runtime_env(). +# CLI/MCP entrypoints call it before reading startup env vars, and the +# resolver helpers also call it defensively. # Disable LiteLLM verbose logging to prevent stdout blocking with large tool schemas litellm.set_verbose = False @@ -175,9 +170,10 @@ def _infer_backend_from_tool_name(tool_name: str) -> Optional[str]: if not tool_name or not isinstance(tool_name, str): return None name = tool_name.strip() - # Dedup format: "server__toolname" -> use suffix + # Dedup format: "server__toolname" -> use suffix. + # Use rsplit to handle server names that themselves contain "__". if "__" in name: - name = name.split("__", 1)[-1] + name = name.rsplit("__", 1)[-1] shell_tools = {"shell_agent", "read_file", "write_file", "list_dir", "run_shell"} if name in shell_tools: return "shell" @@ -190,6 +186,37 @@ def _infer_backend_from_tool_name(tool_name: str) -> Optional[str]: return None +def _resolve_tool_call_target( + tool_name: str, + tool_map: Dict[str, BaseTool], +) -> tuple[Optional[BaseTool], List[str]]: + """Resolve a returned tool name to a concrete tool object. + + The LLM is expected to return the deduped tool key from ``tool_map``. + Some providers occasionally return the short schema name instead. In that + case we only recover when exactly one tool shares that schema name; if + multiple tools match, the call is ambiguous and should not be executed. + """ + tool_obj = tool_map.get(tool_name) + if tool_obj is not None or not tool_name: + return tool_obj, [] + + fallback_matches = [ + (llm_name, tool) + for llm_name, tool in tool_map.items() + if getattr(getattr(tool, "schema", None), "name", None) == tool_name + ] + if len(fallback_matches) == 1: + resolved_name, resolved_tool = fallback_matches[0] + logger.info( + f"[TOOL_FALLBACK] Resolved short tool name '{tool_name}' to '{resolved_name}'" + ) + return resolved_tool, [] + if len(fallback_matches) > 1: + return None, [llm_name for llm_name, _tool in fallback_matches] + return None, [] + + DEFAULT_SUMMARIZE_THRESHOLD_CHARS = 200000 # ~50K tokens, lowered from 400K to prevent context overflow MAX_TOOL_RESULT_CHARS = 200000 # Fallback truncation limit when summarization fails (~50K tokens) @@ -198,7 +225,8 @@ async def _summarize_tool_result( tool_name: str, task: str = "", model: str = "openrouter/anthropic/claude-sonnet-4.5", - timeout: float = 120.0 + timeout: float = 120.0, + litellm_kwargs: Optional[Dict] = None, ) -> str: """Use LLM to summarize large tool results.""" try: @@ -234,11 +262,13 @@ Content: Concise summary:""" + _extra = litellm_kwargs or {} response = await asyncio.wait_for( litellm.acompletion( model=model, messages=[{"role": "user", "content": prompt}], - timeout=timeout + timeout=timeout, + **_extra, ), timeout=timeout + 5 ) @@ -265,7 +295,8 @@ async def _tool_result_to_message_async( task: str = "", summarize_threshold: int = DEFAULT_SUMMARIZE_THRESHOLD_CHARS, summarize_model: str = "openrouter/anthropic/claude-sonnet-4.5", - enable_summarization: bool = True + enable_summarization: bool = True, + litellm_kwargs: Optional[Dict] = None, ) -> Dict: """Convert ToolResult to LLMClient usable message format with LLM summarization for large results. @@ -294,7 +325,7 @@ async def _tool_result_to_message_async( # Use LLM summarization if content exceeds threshold if original_len > summarize_threshold and enable_summarization: - summary = await _summarize_tool_result(text_content, tool_name, task, summarize_model) + summary = await _summarize_tool_result(text_content, tool_name, task, summarize_model, litellm_kwargs=litellm_kwargs) if summary: text_content = summary elif original_len > MAX_TOOL_RESULT_CHARS: @@ -406,6 +437,95 @@ class LLMClient: self._logger = Logger.get_logger(__name__) self._last_call_time = 0.0 + @staticmethod + def _merge_consecutive_system_messages(messages: List[Dict]) -> List[Dict]: + """Merge consecutive system messages into one. + + Providers like MiniMax reject requests that contain multiple consecutive + messages with the same role (error 2013 "invalid chat setting"). + Merging is safe for all providers — it simply concatenates the content. + """ + if not messages: + return messages + merged: List[Dict] = [] + for msg in messages: + if ( + merged + and msg.get("role") == "system" + and merged[-1].get("role") == "system" + ): + merged[-1] = { + "role": "system", + "content": merged[-1].get("content", "") + "\n\n" + msg.get("content", ""), + } + else: + merged.append(msg.copy()) + return merged + + @staticmethod + def _is_minimax_model(model: str) -> bool: + return isinstance(model, str) and "minimax" in model.lower() + + @classmethod + def _rewrite_nonleading_system_messages_for_minimax( + cls, + messages: List[Dict], + ) -> List[Dict]: + """Rewrite non-leading system messages into internal user notes for MiniMax.""" + rewritten: List[Dict] = [] + rewritten_count = 0 + + for msg in messages: + msg_copy = msg.copy() + if msg_copy.get("role") == "system" and rewritten: + content = msg_copy.get("content", "") + if isinstance(content, str): + msg_copy["content"] = ( + "[INTERNAL ORCHESTRATION NOTE]\n" + "This note was originally injected as a system message by the " + "agent runtime. Treat it as workflow guidance, not as a new " + "end-user request.\n\n" + f"{content}" + ) + msg_copy["role"] = "user" + rewritten_count += 1 + rewritten.append(msg_copy) + + if rewritten_count: + logger.info( + "Rewrote %d non-leading system message(s) for MiniMax compatibility", + rewritten_count, + ) + + return rewritten + + @classmethod + def _normalize_messages_for_model(cls, messages: List[Dict], model: str) -> List[Dict]: + """Normalize message history only when a provider requires it.""" + if not cls._is_minimax_model(model): + return messages + + minimized_system_history = cls._merge_consecutive_system_messages(messages) + return cls._rewrite_nonleading_system_messages_for_minimax( + minimized_system_history + ) + + @staticmethod + def _serialize_response_field(value): + """Convert provider response fields into plain Python containers.""" + if hasattr(value, "model_dump"): + return value.model_dump(exclude_none=True) + if isinstance(value, list): + return [LLMClient._serialize_response_field(item) for item in value] + if isinstance(value, tuple): + return [LLMClient._serialize_response_field(item) for item in value] + if isinstance(value, dict): + return { + key: LLMClient._serialize_response_field(item) + for key, item in value.items() + } + return value + async def _rate_limit(self): """Apply rate limiting by adding delay between API calls""" if self.rate_limit_delay > 0: @@ -539,6 +659,7 @@ class LLMClient: "model": kwargs.get("model", self.model), **self.litellm_kwargs, } + request_model = completion_kwargs["model"] # Add thinking/reasoning_effort only if explicitly enabled and not using tools enable_thinking = kwargs.get("enable_thinking", self.enable_thinking) @@ -561,10 +682,16 @@ class LLMClient: if enable_thinking: completion_kwargs["reasoning_effort"] = kwargs.get("reasoning_effort", "medium") - # 4. Apply rate limiting + # 4. Normalize messages for providers with stricter role constraints. + current_messages = self._normalize_messages_for_model( + current_messages, + request_model, + ) + + # 5. Apply rate limiting await self._rate_limit() - # 5. Call LLM with retry (single round) + # 6. Call LLM with retry (single round) completion_kwargs["messages"] = current_messages response = await self._call_with_retry(**completion_kwargs) @@ -578,6 +705,11 @@ class LLMClient: "role": "assistant", "content": response_message.content or "", } + + for field_name in ("reasoning_details", "reasoning_content", "name"): + field_value = getattr(response_message, field_name, None) + if field_value: + assistant_message[field_name] = self._serialize_response_field(field_value) tool_calls = getattr(response_message, 'tool_calls', None) if tool_calls: @@ -604,14 +736,9 @@ class LLMClient: for tool_call in tool_calls: tool_name = tool_call.function.name - # Resolve tool instance: key might differ from model response (e.g. API returns - # "read_file" while we stored "server__read_file" for dedup), so fallback by schema.name - tool_obj = tool_map.get(tool_name) - if tool_obj is None and tool_name: - for _k, _t in tool_map.items(): - if getattr(getattr(_t, "schema", None), "name", None) == tool_name: - tool_obj = _t - break + # Resolve tool instance: some providers return the short schema + # name instead of the deduped LLM-visible tool key. + tool_obj, ambiguous_tool_names = _resolve_tool_call_target(tool_name, tool_map) backend = None server_name = None @@ -653,15 +780,24 @@ class LLMClient: except (json.JSONDecodeError, ValueError, TypeError) as e: self._logger.debug(f"Failed to parse tool arguments for {tool_name}: {e}") - if tool_name not in tool_map: - result = ToolResult( - status=ToolStatus.ERROR, - error=f"Tool '{tool_name}' not found" - ) + if tool_obj is None: + if ambiguous_tool_names: + result = ToolResult( + status=ToolStatus.ERROR, + error=( + f"Tool '{tool_name}' is ambiguous; matches: " + f"{', '.join(ambiguous_tool_names)}" + ) + ) + else: + result = ToolResult( + status=ToolStatus.ERROR, + error=f"Tool '{tool_name}' not found" + ) else: try: result = await _execute_tool_call( - tool=tool_map[tool_name], + tool=tool_obj, openai_tool_call={ "id": tool_call.id, "type": "function", @@ -697,7 +833,8 @@ class LLMClient: task=user_task, summarize_threshold=self.summarize_threshold_chars, summarize_model=self.model, - enable_summarization=self.enable_tool_result_summarization + enable_summarization=self.enable_tool_result_summarization, + litellm_kwargs=self.litellm_kwargs, ) current_messages.append(tool_message) @@ -722,6 +859,10 @@ class LLMClient: "content": summary_prompt } current_messages.append(summary_message) + current_messages = self._normalize_messages_for_model( + current_messages, + request_model, + ) # Apply rate limiting before summary call await self._rate_limit() @@ -729,7 +870,7 @@ class LLMClient: # Call LLM to generate summary (without tools) summary_kwargs = { **self.litellm_kwargs, - "model": self.model, + "model": request_model, "messages": current_messages, "tools": [], "tool_choice": "none", diff --git a/openspace/mcp_server.py b/openspace/mcp_server.py index bca7a34..bfacc5b 100644 --- a/openspace/mcp_server.py +++ b/openspace/mcp_server.py @@ -7,8 +7,9 @@ Exposes the following tools to MCP clients: upload_skill — Upload a local skill to cloud (pre-saved metadata, bot decides visibility) Usage: - python -m openspace.mcp_server # stdio (default) + python -m openspace.mcp_server # auto (TTY -> SSE, MCP host -> stdio) python -m openspace.mcp_server --transport sse # SSE on port 8080 + python -m openspace.mcp_server --transport streamable-http # Streamable HTTP on port 8080 python -m openspace.mcp_server --port 9090 # SSE on custom port Environment variables: see ``openspace/host_detection/`` and ``openspace/cloud/auth.py``. @@ -22,7 +23,6 @@ import json import logging import os import sys -import traceback from pathlib import Path from typing import Any, Dict, List, Optional @@ -75,12 +75,27 @@ class _MCPSafeStdout: def seekable(self): return False -_real_stdout = sys.stdout -sys.stdout = _MCPSafeStdout(_real_stdout, sys.stderr) + def __getattr__(self, name): + return getattr(self._stderr, name) _LOG_DIR = Path(__file__).resolve().parent.parent / "logs" _LOG_DIR.mkdir(parents=True, exist_ok=True) +_real_stdout = sys.stdout + +# Windows pipe buffers are small. When using stdio MCP transport, +# the parent process only reads stdout for MCP messages and does NOT +# drain stderr. Heavy log/print output during execute_task fills the stderr +# pipe buffer, blocking this process on write() → deadlock → timeout. +# Redirect stderr to a log file on Windows to prevent this. +if os.name == "nt": + _stderr_file = open( + _LOG_DIR / "mcp_stderr.log", "a", encoding="utf-8", buffering=1 + ) + sys.stderr = _stderr_file + +sys.stdout = _MCPSafeStdout(_real_stdout, sys.stderr) + logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", @@ -123,7 +138,13 @@ async def _get_openspace(): logger.info("Initializing OpenSpace engine ...") from openspace.tool_layer import OpenSpace, OpenSpaceConfig - from openspace.host_detection import build_llm_kwargs, build_grounding_config_path + from openspace.host_detection import ( + build_grounding_config_path, + build_llm_kwargs, + load_runtime_env, + ) + + load_runtime_env() env_model = os.environ.get("OPENSPACE_MODEL", "") workspace = os.environ.get("OPENSPACE_WORKSPACE") @@ -183,6 +204,61 @@ def _get_store(): return _standalone_store +def _get_local_skill_registry(): + """Build a lightweight SkillRegistry for local-only skill search. + + This avoids initializing the full OpenSpace engine when callers only + want to inspect local skills. It mirrors the skill directory discovery + order used by the full engine, but skips LLM / provider startup. + The registry is rebuilt per call so later local searches can see + newly added skills without requiring a process restart. + """ + from openspace.config import get_config + from openspace.skill_engine import SkillRegistry + + skill_paths: List[Path] = [] + + host_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "") + if host_dirs_raw: + for d in host_dirs_raw.split(","): + d = d.strip() + if not d: + continue + p = Path(d) + if p.exists(): + skill_paths.append(p) + else: + logger.warning("Host skill dir does not exist: %s", d) + + try: + skill_cfg = get_config().skills + except Exception as e: + logger.warning("Failed to load local skill config: %s", e) + skill_cfg = None + + if skill_cfg and skill_cfg.skill_dirs: + for d in skill_cfg.skill_dirs: + p = Path(d) + if p in skill_paths: + continue + if p.exists(): + skill_paths.append(p) + else: + logger.warning("Configured skill dir does not exist: %s", d) + + builtin_skills = Path(__file__).resolve().parent / "skills" + if builtin_skills.exists(): + skill_paths.append(builtin_skills) + + if not skill_paths: + logger.debug("No local skill directories found") + return None + + registry = SkillRegistry(skill_dirs=skill_paths) + registry.discover() + return registry + + def _get_cloud_client(): """Get a OpenSpaceClient instance (raises CloudError if not configured).""" from openspace.cloud.auth import get_openspace_auth @@ -259,16 +335,13 @@ def _read_upload_meta(skill_dir: Path) -> Dict[str, Any]: async def _auto_register_skill_dirs(skill_dirs: List[str]) -> int: """Register bot skill directories into OpenSpace's SkillRegistry + DB. - Called automatically by ``execute_task`` when ``skill_dirs`` is provided. - Already-registered directories are skipped (idempotent within a session). + Called automatically by ``execute_task`` on every invocation. Directories + are re-scanned each time so that skills created by the host bot since the last call are discovered immediately. """ global _registered_skill_dirs - new_dirs = [ - Path(d) for d in skill_dirs - if d not in _registered_skill_dirs and Path(d).is_dir() - ] - if not new_dirs: + valid_dirs = [Path(d) for d in skill_dirs if Path(d).is_dir()] + if not valid_dirs: return 0 openspace = await _get_openspace() @@ -277,19 +350,21 @@ async def _auto_register_skill_dirs(skill_dirs: List[str]) -> int: logger.warning("_auto_register_skill_dirs: SkillRegistry not initialized") return 0 - added = registry.discover_from_dirs(new_dirs) + added = registry.discover_from_dirs(valid_dirs) db_created = 0 if added: store = _get_store() db_created = await store.sync_from_registry(added) + is_first = any(d not in _registered_skill_dirs for d in skill_dirs) for d in skill_dirs: _registered_skill_dirs.add(d) if added: + action = "Auto-registered" if is_first else "Re-scanned & found" logger.info( - f"Auto-registered {len(added)} skill(s) from {len(new_dirs)} dir(s), " + f"{action} {len(added)} skill(s) from {len(valid_dirs)} dir(s), " f"{db_created} new DB record(s)" ) return len(added) @@ -299,63 +374,48 @@ async def _cloud_search_and_import(task: str, limit: int = 8) -> List[Dict[str, """Search cloud for skills relevant to *task* and auto-import top hits. This is **stage 1** of a two-stage pipeline: - Stage 1 (here): cloud BM25+embedding → pick top-N to import locally. + Stage 1 (here): server-side embedding search → pick top-N to import locally. Stage 2 (tool_layer): local BM25 + LLM → select from ALL local skills (including ones just imported) for injection. Stage 1 intentionally imports more than will be used (default: 8) so - that stage 2 has a larger pool to choose from. The two BM25 passes - are NOT redundant — stage 1 filters thousands of cloud candidates down + that stage 2 has a larger pool to choose from. Stage 1 relies on the + server's embedding search to filter thousands of cloud candidates down to a manageable import set; stage 2 makes the final task-specific choice. """ try: - from openspace.cloud.search import ( - SkillSearchEngine, build_cloud_candidates, - ) - from openspace.cloud.embedding import generate_embedding, resolve_embedding_api - - client = _get_cloud_client() - embedding_api_key, _ = resolve_embedding_api() - has_embedding = bool(embedding_api_key) - - items = await asyncio.to_thread( - client.fetch_metadata, include_embedding=has_embedding, limit=200, - ) - if not items: + normalized_task_query = task.strip() + if not normalized_task_query: return [] - candidates = build_cloud_candidates(items) - if not candidates: + cloud_client = _get_cloud_client() + cloud_search_results = await asyncio.to_thread( + cloud_client.search_record_embeddings, + query=normalized_task_query, + limit=min(limit * 2, 300), + ) + if not cloud_search_results: return [] - query_embedding: Optional[List[float]] = None - if has_embedding: - query_embedding = await asyncio.to_thread( - generate_embedding, task, - ) - - engine = SkillSearchEngine() - results = engine.search(task, candidates, query_embedding=query_embedding, limit=limit * 2) - - cloud_hits = [ - r for r in results - if r.get("source") == "cloud" - and r.get("visibility", "public") == "public" - and r.get("skill_id") + public_cloud_hits = [ + cloud_result for cloud_result in cloud_search_results + if cloud_result.get("visibility", "public") == "public" + and cloud_result.get("record_id") ][:limit] import_results: List[Dict[str, Any]] = [] - for hit in cloud_hits: + for cloud_hit in public_cloud_hits: try: - imp = await _do_import_cloud_skill(skill_id=hit["skill_id"]) + skill_id = cloud_hit["record_id"] + imp = await _do_import_cloud_skill(skill_id=skill_id) import_results.append({ - "skill_id": hit["skill_id"], - "name": hit.get("name", ""), + "skill_id": skill_id, + "name": cloud_hit.get("name", ""), "import_status": imp.get("status", "error"), "local_path": imp.get("local_path", ""), }) except Exception as e: - logger.warning(f"Cloud import failed for {hit['skill_id']}: {e}") + logger.warning(f"Cloud import failed for {skill_id}: {e}") if import_results: logger.info(f"Cloud search imported {len(import_results)} skill(s)") @@ -495,8 +555,9 @@ async def execute_task( workspace_dir: Working directory. Defaults to OPENSPACE_WORKSPACE env. max_iterations: Max agent iterations (default: 20). skill_dirs: Bot's skill directories to auto-register so OpenSpace - can select and track them. Already-registered dirs are - silently skipped. + can select and track them. Directories are re-scanned + on every call to discover skills created since the last + invocation. search_scope: Skill search scope before execution. "all" (default) — local + cloud; falls back to local if no API key is configured. @@ -505,10 +566,32 @@ async def execute_task( try: openspace = await _get_openspace() - # Auto-register bot skill directories + # Re-scan host skill directories (from env) to pick up skills + # created by the host bot since the last call. + host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "") + if host_skill_dirs_raw: + env_dirs = [d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()] + if env_dirs: + await _auto_register_skill_dirs(env_dirs) + + # Auto-register bot skill directories (from call parameter) if skill_dirs: await _auto_register_skill_dirs(skill_dirs) + # Determine where CAPTURED skills should be written. + # Prefer the explicit skill_dirs parameter (= calling host agent's dir), + # then fall back to the first env-based host skill dir. + capture_skill_dir: str | None = None + if skill_dirs: + capture_skill_dir = skill_dirs[0] + elif host_skill_dirs_raw: + first_env = next( + (d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()), + None, + ) + if first_env: + capture_skill_dir = first_env + # Cloud search + import (if requested) imported_skills: List[Dict[str, Any]] = [] if search_scope == "all": @@ -519,6 +602,7 @@ async def execute_task( task=task, workspace_dir=workspace_dir, max_iterations=max_iterations, + capture_skill_dir=capture_skill_dir, ) # Write .upload_meta.json for each evolved skill @@ -534,7 +618,7 @@ async def execute_task( except Exception as e: logger.error(f"execute_task failed: {e}", exc_info=True) - return _json_error(e, status="error", traceback=traceback.format_exc(limit=5)) + return _json_error(e, status="error") @mcp.tool() @@ -571,11 +655,22 @@ async def search_skills( if not q: return _json_ok({"results": [], "count": 0}) - # Resolve local skills + store + # Re-scan host skill directories so newly created skills are searchable. local_skills = None store = None - if source in ("all", "local"): + if source == "local": + registry = _get_local_skill_registry() + if registry: + local_skills = registry.list_skills() + elif source == "all": openspace = await _get_openspace() + + host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "") + if host_skill_dirs_raw: + env_dirs = [d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()] + if env_dirs: + await _auto_register_skill_dirs(env_dirs) + registry = openspace._skill_registry if registry: local_skills = registry.list_skills() @@ -744,7 +839,7 @@ async def fix_skill( except Exception as e: logger.error(f"fix_skill failed: {e}", exc_info=True) - return _json_error(e, status="error", traceback=traceback.format_exc(limit=5)) + return _json_error(e, status="error") @mcp.tool() @@ -815,20 +910,83 @@ async def upload_skill( except Exception as e: logger.error(f"upload_skill failed: {e}", exc_info=True) - return _json_error(e, status="error", traceback=traceback.format_exc(limit=5)) + return _json_error(e, status="error") def run_mcp_server() -> None: """Console-script entry point for ``openspace-mcp``.""" import argparse - parser = argparse.ArgumentParser(description="OpenSpace MCP Server") - parser.add_argument("--transport", choices=["stdio", "sse"], default="stdio") - parser.add_argument("--port", type=int, default=8080) - args = parser.parse_args() + def _port_flag_was_set(argv: list[str]) -> bool: + return any(arg == "--port" or arg.startswith("--port=") for arg in argv) - if args.transport == "sse": - mcp.run(transport="sse", sse_params={"port": args.port}) + def _parse_port_from_env(default: int = 8080) -> int: + raw_port = os.environ.get("OPENSPACE_MCP_PORT", "").strip() + if not raw_port: + return default + try: + return int(raw_port) + except ValueError: + logger.warning( + "Ignoring invalid OPENSPACE_MCP_PORT=%r; falling back to %d.", + raw_port, + default, + ) + return default + + def _parse_host_from_env(default: str = "127.0.0.1") -> str: + return os.environ.get("OPENSPACE_MCP_HOST", "").strip() or default + + def _resolve_transport(requested_transport: str, argv: list[str]) -> str: + if requested_transport in ("stdio", "sse", "streamable-http"): + return requested_transport + + env_transport = os.environ.get("OPENSPACE_MCP_TRANSPORT", "").strip().lower() + if env_transport: + if env_transport in ("stdio", "sse", "streamable-http"): + return env_transport + logger.warning( + "Ignoring invalid OPENSPACE_MCP_TRANSPORT=%r; expected 'stdio', 'sse', or 'streamable-http'.", + env_transport, + ) + + # Treat an explicit port override as an HTTP/SSE intent. This keeps the + # CLI behavior aligned with the usage examples above. + if _port_flag_was_set(argv): + return "sse" + + stdin_is_tty = hasattr(sys.stdin, "isatty") and sys.stdin.isatty() + stdout_is_tty = _real_stdout.isatty() + return "sse" if stdin_is_tty and stdout_is_tty else "stdio" + + argv = sys.argv[1:] + parser = argparse.ArgumentParser(description="OpenSpace MCP Server") + parser.add_argument( + "--transport", + choices=["auto", "stdio", "sse", "streamable-http"], + default="auto", + ) + parser.add_argument("--host", default=_parse_host_from_env()) + parser.add_argument("--port", type=int, default=_parse_port_from_env()) + args = parser.parse_args(argv) + + transport = _resolve_transport(args.transport, argv) + + if transport == "sse": + mcp.settings.host = args.host + mcp.settings.port = args.port + logger.info("Starting OpenSpace MCP server with SSE transport on port %s", args.port) + mcp.run(transport="sse") + elif transport == "streamable-http": + mcp.settings.host = args.host + mcp.settings.port = args.port + logger.info( + "Starting OpenSpace MCP server with streamable HTTP transport on %s:%s", + args.host, + args.port, + ) + mcp.run(transport="streamable-http") else: + logger.info("Starting OpenSpace MCP server with stdio transport") mcp.run(transport="stdio") diff --git a/openspace/platform/__init__.py b/openspace/platforms/__init__.py similarity index 100% rename from openspace/platform/__init__.py rename to openspace/platforms/__init__.py diff --git a/openspace/platform/config.py b/openspace/platforms/config.py similarity index 100% rename from openspace/platform/config.py rename to openspace/platforms/config.py diff --git a/openspace/platform/recording.py b/openspace/platforms/recording.py similarity index 100% rename from openspace/platform/recording.py rename to openspace/platforms/recording.py diff --git a/openspace/platform/screenshot.py b/openspace/platforms/screenshot.py similarity index 100% rename from openspace/platform/screenshot.py rename to openspace/platforms/screenshot.py diff --git a/openspace/platform/system_info.py b/openspace/platforms/system_info.py similarity index 100% rename from openspace/platform/system_info.py rename to openspace/platforms/system_info.py diff --git a/openspace/recording/manager.py b/openspace/recording/manager.py index d3e23ed..4bbb382 100644 --- a/openspace/recording/manager.py +++ b/openspace/recording/manager.py @@ -500,7 +500,7 @@ class RecordingManager: # create video client (internal management) if self.enable_video: - from openspace.platform import RecordingClient + from openspace.platforms import RecordingClient self._recording_client = RecordingClient(base_url=self.server_url) success = await self._recording_client.start_recording() if success: @@ -510,7 +510,7 @@ class RecordingManager: # create screenshot client (internal management) if self.enable_screenshot: - from openspace.platform import ScreenshotClient + from openspace.platforms import ScreenshotClient self._screenshot_client = ScreenshotClient(base_url=self.server_url) logger.debug("Screenshot client ready") @@ -539,7 +539,7 @@ class RecordingManager: async def _check_server_availability(self): """Check if local server is available""" try: - from openspace.platform import SystemInfoClient + from openspace.platforms import SystemInfoClient # Use context manager to ensure aiohttp session is closed, avoiding warning of unclosed session async with SystemInfoClient(base_url=self.server_url) as client: @@ -668,8 +668,9 @@ class RecordingManager: if not tool_name or not isinstance(tool_name, str): return None name = tool_name.strip() + # Use rsplit to handle server names that themselves contain "__". if "__" in name: - name = name.split("__", 1)[-1] + name = name.rsplit("__", 1)[-1] shell_tools = {"shell_agent", "read_file", "write_file", "list_dir", "run_shell"} if name in shell_tools: return "shell" diff --git a/openspace/recording/recorder.py b/openspace/recording/recorder.py index d1f7678..aaee792 100644 --- a/openspace/recording/recorder.py +++ b/openspace/recording/recorder.py @@ -23,7 +23,7 @@ class TrajectoryRecorder: Args: task_name: task name (optional, will be saved in metadata) log_dir: log directory - enable_screenshot: whether to save screenshots (through platform.ScreenshotClient) + enable_screenshot: whether to save screenshots (through platforms.ScreenshotClient) enable_video: whether to enable video recording (through platform.RecordingClient) server_url: local_server address (None = read from config/environment variables) """ @@ -91,7 +91,7 @@ class TrajectoryRecorder: parameters: tool parameters screenshot: screenshot bytes (if provided) extra: extra information (e.g. server field for MCP) - auto_screenshot: whether to automatically capture screenshot (through platform.ScreenshotClient) + auto_screenshot: whether to automatically capture screenshot (through platforms.ScreenshotClient) """ self.step_counter += 1 step_num = self.step_counter @@ -145,9 +145,9 @@ class TrajectoryRecorder: return step_info async def _capture_screenshot(self) -> Optional[bytes]: - """Capture screenshot automatically through platform.ScreenshotClient""" + """Capture screenshot automatically through platforms.ScreenshotClient""" try: - from openspace.platform import ScreenshotClient + from openspace.platforms import ScreenshotClient # Lazy initialization screenshot client if not hasattr(self, '_screenshot_client'): diff --git a/openspace/recording/video.py b/openspace/recording/video.py index 72aa273..65e7c1c 100644 --- a/openspace/recording/video.py +++ b/openspace/recording/video.py @@ -1,7 +1,7 @@ """ Video Recorder -Communicates with local_server through platform.RecordingClient +Communicates with local_server through platforms.RecordingClient Supports local and remote recording (through configuration LOCAL_SERVER_URL) """ @@ -9,7 +9,7 @@ from pathlib import Path from typing import Optional from openspace.utils.logging import Logger -from openspace.platform import RecordingClient +from openspace.platforms import RecordingClient logger = Logger.get_logger(__name__) diff --git a/openspace/skill_engine/analyzer.py b/openspace/skill_engine/analyzer.py index fa5b74f..a809bd7 100644 --- a/openspace/skill_engine/analyzer.py +++ b/openspace/skill_engine/analyzer.py @@ -84,13 +84,17 @@ def _correct_skill_ids( if prefix and k.split("__")[0] == prefix ] - best, best_dist = None, 4 # threshold: edit distance ≤ 3 + # Adaptive threshold: tighten when many candidates share the prefix + max_dist = 2 if len(candidates) > 20 else 4 # ≤1 or ≤3 + best, best_dist, ambiguous = None, max_dist, False for cand in candidates: d = _edit_distance(raw_id, cand) if d < best_dist: - best, best_dist = cand, d + best, best_dist, ambiguous = cand, d, False + elif d == best_dist and cand != best: + ambiguous = True # multiple candidates at same distance - if best is not None: + if best is not None and not ambiguous: logger.info( f"Corrected LLM skill ID: {raw_id!r} → {best!r} " f"(edit_distance={best_dist})" diff --git a/openspace/skill_engine/evolver.py b/openspace/skill_engine/evolver.py index aab70a1..a13826e 100644 --- a/openspace/skill_engine/evolver.py +++ b/openspace/skill_engine/evolver.py @@ -145,6 +145,11 @@ class EvolutionContext: # Available tools for agent loop (read_file, web_search, shell, MCP, etc.) available_tools: List["BaseTool"] = field(default_factory=list) + # For CAPTURED: preferred directory to write the new skill. + # Set from the calling host agent's skill directory so captured skills + # are written back to the correct host, not always to _skill_dirs[0]. + capture_dir: Optional[Path] = None + class SkillEvolver: """Execute skill evolution actions. @@ -201,6 +206,7 @@ class SkillEvolver: # evolved for each degraded tool. Keyed by tool_key. # Pruned when a tool leaves the problematic list (= recovered). self._addressed_degradations: Dict[str, Set[str]] = {} + self._degradation_lock = asyncio.Lock() # Track background tasks so they can be awaited on shutdown. self._background_tasks: Set[asyncio.Task] = set() @@ -219,7 +225,10 @@ class SkillEvolver: f"Waiting for {len(self._background_tasks)} background " f"evolution task(s) to finish..." ) - await asyncio.gather(*self._background_tasks, return_exceptions=True) + results = await asyncio.gather(*self._background_tasks, return_exceptions=True) + for r in results: + if isinstance(r, BaseException): + logger.warning(f"Background evolution task failed during shutdown: {r}") self._background_tasks.clear() async def evolve(self, ctx: EvolutionContext) -> Optional[SkillRecord]: @@ -255,13 +264,20 @@ class SkillEvolver: # Trigger 1: post-analysis async def process_analysis( - self, analysis: ExecutionAnalysis, + self, + analysis: ExecutionAnalysis, + capture_dir: Optional[Path] = None, ) -> List[SkillRecord]: """Process all evolution suggestions from a completed analysis. Called immediately after ``ExecutionAnalyzer.analyze_execution()``. Each suggestion becomes one evolution action, executed in parallel (throttled by semaphore). + + Args: + analysis: The completed execution analysis. + capture_dir: Preferred directory for CAPTURED skills (host agent's + skill dir). Falls back to ``registry._skill_dirs[0]`` when None. """ if not analysis.candidate_for_evolution: return [] @@ -269,7 +285,9 @@ class SkillEvolver: # Build contexts first (cheap, no LLM calls) contexts: List[EvolutionContext] = [] for suggestion in analysis.evolution_suggestions: - ctx = self._build_context_from_analysis(analysis, suggestion) + ctx = self._build_context_from_analysis( + analysis, suggestion, capture_dir=capture_dir, + ) if ctx is not None: contexts.append(ctx) @@ -306,6 +324,13 @@ class SkillEvolver: if not problematic_tools: return [] + async with self._degradation_lock: + return await self._process_tool_degradation_locked(problematic_tools) + + async def _process_tool_degradation_locked( + self, problematic_tools: List, + ) -> List[SkillRecord]: + """Inner body of process_tool_degradation, called under _degradation_lock.""" # Prune recovered tools: if a tool_key used to be tracked but is # no longer in the current problematic list, it recovered — clear # its addressed set so future re-degradation gets a fresh pass. @@ -652,10 +677,21 @@ class SkillEvolver: return bool(data.get("proceed", False)) except (json.JSONDecodeError, ValueError): pass - # Fallback: look for keywords - if any(w in response for w in ("\"proceed\": true", "proceed: true", "yes", "confirm")): + # Fallback: look for keywords. + # - yes/no use strict word boundaries to avoid false positives + # (e.g. "know" matching "no"). + # - confirm/reject/skip use stem-style matching so that common + # LLM variants like "confirmed", "rejected", "skipping" still + # parse correctly. + _wb = re.search # shorthand + if any(w in response for w in ("\"proceed\": true", "proceed: true")) \ + or _wb(r"\byes\b", response) \ + or _wb(r"\bconfirm\w*\b", response): return True - if any(w in response for w in ("\"proceed\": false", "proceed: false", "no", "reject", "skip")): + if any(w in response for w in ("\"proceed\": false", "proceed: false")) \ + or _wb(r"\bno\b", response) \ + or _wb(r"\breject\w*\b", response) \ + or _wb(r"\bskip\w*\b", response): return False # Default: skip — ambiguous response should not trigger costly evolution logger.debug("LLM confirmation response was ambiguous, defaulting to skip") @@ -926,13 +962,23 @@ class SkillEvolver: new_content = _set_frontmatter_field(new_content, "name", new_name) # Create new skill directory via create_skill (handles multi-file FULL) - skill_dirs = self._registry._skill_dirs - if not skill_dirs: - logger.warning("CAPTURED: no skill directories configured") - return None + # Priority chain for choosing the target skill root: + # 1. ctx.capture_dir — explicitly set from host agent's skill_dirs param + # 2. Infer from analysis — if this task used a skill from dir B, + # captured skills belong alongside it (same host agent context) + # 3. registry._skill_dirs[0] — ultimate fallback + base_dir: Optional[Path] = None + if ctx.capture_dir and ctx.capture_dir.is_dir(): + base_dir = ctx.capture_dir + else: + base_dir = self._infer_capture_dir_from_analysis(ctx) - # Directory name always matches the skill name - base_dir = skill_dirs[0] # Primary user skill directory + if base_dir is None: + skill_dirs = self._registry._skill_dirs + if not skill_dirs: + logger.warning("CAPTURED: no skill directories configured") + return None + base_dir = skill_dirs[0] target_dir = base_dir / new_name if target_dir.exists(): new_name = f"{new_name}-{uuid.uuid4().hex[:6]}" @@ -994,6 +1040,45 @@ class SkillEvolver: logger.info(f"CAPTURED: {new_name} [{new_id}]") return new_record + def _infer_capture_dir_from_analysis( + self, ctx: EvolutionContext, + ) -> Optional[Path]: + """Infer the best skill root for a CAPTURED skill from analysis context. + + When ``capture_dir`` is not explicitly set (no ``skill_dirs`` param + from the host agent), we look at which skills were used during the + task that triggered the capture. If a used skill lives under one + of the registered skill roots, that root is a reasonable home for + the new captured skill (same host agent context). + """ + if not ctx.recent_analyses: + return None + + registry_roots = self._registry._skill_dirs + if not registry_roots: + return None + + for analysis in ctx.recent_analyses: + for judgment in analysis.skill_judgments: + if not judgment.skill_applied: + continue + rec = self._store.load_record(judgment.skill_id) + if not rec or not rec.path: + continue + skill_path = Path(rec.path).parent # e.g. /A/foo/ + for root in registry_roots: + try: + skill_path.relative_to(root) + logger.debug( + "CAPTURED: inferred capture dir %s from " + "applied skill %s", root, judgment.skill_id, + ) + return root + except ValueError: + continue + + return None + async def _run_evolution_loop( self, prompt: str, @@ -1340,13 +1425,15 @@ class SkillEvolver: self, analysis: ExecutionAnalysis, suggestion: EvolutionSuggestion, + capture_dir: Optional[Path] = None, ) -> Optional[EvolutionContext]: """Build EvolutionContext from a single analysis suggestion. Loads all target skills referenced by ``suggestion.target_skill_ids``. For FIX: exactly 1 parent required. For DERIVED: 1+ parents (multi-parent = merge). - For CAPTURED: parents list is empty. + For CAPTURED: parents list is empty; ``capture_dir`` controls where + the new skill is written (defaults to registry's first skill root). """ records: List[SkillRecord] = [] contents: List[str] = [] @@ -1390,6 +1477,7 @@ class SkillEvolver: source_task_id=analysis.task_id, recent_analyses=[analysis], available_tools=self._available_tools, + capture_dir=capture_dir, ) def _load_skill_content(self, record: SkillRecord) -> str: diff --git a/openspace/skill_engine/registry.py b/openspace/skill_engine/registry.py index bb35dd8..114f6d8 100644 --- a/openspace/skill_engine/registry.py +++ b/openspace/skill_engine/registry.py @@ -299,8 +299,8 @@ class SkillRegistry: skill_dir: Path to a directory containing ``SKILL.md``. Returns: - :class:`SkillMeta` if newly registered, ``None`` if already - present, the directory is invalid, or the skill fails safety checks. + :class:`SkillMeta` if newly registered or already present, + ``None`` if the directory is invalid or the skill fails safety checks. """ skill_file = skill_dir / "SKILL.md" if not skill_file.exists(): @@ -321,7 +321,7 @@ class SkillRegistry: meta = self._parse_skill(skill_dir.name, skill_dir, skill_file, content) if meta.skill_id in self._skills: logger.debug(f"register_skill_dir: {meta.skill_id} already exists") - return None + return self._skills[meta.skill_id] self._skills[meta.skill_id] = meta self._content_cache[meta.skill_id] = content logger.info(f"Hot-registered skill: {meta.skill_id}") diff --git a/openspace/tool_layer.py b/openspace/tool_layer.py index 2424860..0d7052a 100644 --- a/openspace/tool_layer.py +++ b/openspace/tool_layer.py @@ -104,7 +104,6 @@ class OpenSpace: return logger.info("Initializing OpenSpace...") - try: self._llm_client = LLMClient( model=self.config.llm_model, @@ -307,18 +306,25 @@ class OpenSpace: workspace_dir: Optional[str] = None, max_iterations: Optional[int] = None, task_id: Optional[str] = None, + capture_skill_dir: Optional[str] = None, ) -> Dict[str, Any]: """ Execute a task with OpenSpace. Args: task: Task instruction - context: Additional context + context: Additional context. Communication callers may pass: + - conversation_history: prior user/assistant turns + - channel_context: platform/chat metadata and attachments + - session_key: stable external session identifier workspace_dir: Working directory max_iterations: Max iterations override task_id: External task ID for recording/logging. If None, generates a random one. This allows external callers (e.g., OSWorld) to specify their own task ID so recordings can be easily matched with benchmark results. + capture_skill_dir: Preferred directory for CAPTURED skills. In multi-host-agent + scenarios, this should be the calling host agent's skill directory so + newly captured skills are written to the correct location. """ if not self._initialized: raise RuntimeError( @@ -350,6 +356,9 @@ class OpenSpace: self._task_done.clear() self._last_evolved_skills = [] # Reset per-execution tracking start_time = asyncio.get_running_loop().time() + self._capture_skill_dir = capture_skill_dir + start_time = asyncio.get_event_loop().time() + # Use external task_id if provided, otherwise generate one if task_id is None: task_id = f"task_{uuid.uuid4().hex[:12]}" @@ -357,9 +366,11 @@ class OpenSpace: # Populated inside the try block; used by finally for analysis result: Dict[str, Any] = {} + execution_time = 0.0 + cancelled_exc: Optional[asyncio.CancelledError] = None try: - execution_context = context or {} + execution_context = dict(context) if context else {} execution_context["task_id"] = task_id execution_context["instruction"] = task @@ -507,6 +518,7 @@ class OpenSpace: f"Executing with GroundingAgent " f"(max {max_iterations} iterations, no skills)..." ) + execution_context["max_iterations"] = max_iterations result = await self._grounding_agent.process(execution_context) execution_time = asyncio.get_event_loop().time() - start_time @@ -530,6 +542,20 @@ class OpenSpace: logger.error(f"Task failed: {result.get('error', 'Unknown error')}") logger.info("="*60) + except asyncio.CancelledError as exc: + execution_time = asyncio.get_event_loop().time() - start_time + logger.warning("Task execution cancelled") + result = { + "status": "cancelled", + "error": "Task execution cancelled", + "response": "", + "execution_time": execution_time, + "task_id": task_id, + "iterations": 0, + "tool_executions": [], + } + cancelled_exc = exc + except Exception as e: execution_time = asyncio.get_event_loop().time() - start_time tb = traceback.format_exc(limit=10) @@ -568,14 +594,15 @@ class OpenSpace: except Exception as e: logger.warning(f"Failed to stop recording: {e}") - # Run execution analysis + evolution BEFORE building the return - # value, so evolved_skills is populated. - await self._maybe_analyze_execution( - task_id, recording_dir, result - ) + if cancelled_exc is None: + # Run execution analysis + evolution BEFORE building the return + # value, so evolved_skills is populated. + await self._maybe_analyze_execution( + task_id, recording_dir, result + ) - # Trigger quality evolution periodically - await self._maybe_evolve_quality() + # Trigger quality evolution periodically + await self._maybe_evolve_quality() final_result = { **result, @@ -587,8 +614,10 @@ class OpenSpace: self._running = False self._task_done.set() - - return final_result + + if cancelled_exc is not None: + raise cancelled_exc + return final_result # Skills helpers def _init_skill_registry(self) -> Optional[SkillRegistry]: @@ -784,7 +813,17 @@ class OpenSpace: for s in analysis.evolution_suggestions ) logger.info(f"[Skill Evolution] Suggestions: {evo_summary}") - evolved_records = await self._skill_evolver.process_analysis(analysis) + + capture_dir = None + if getattr(self, "_capture_skill_dir", None): + from pathlib import Path as _P + _cd = _P(self._capture_skill_dir) + if _cd.is_dir(): + capture_dir = _cd + + evolved_records = await self._skill_evolver.process_analysis( + analysis, capture_dir=capture_dir, + ) # Track evolved skills for the caller for rec in evolved_records: diff --git a/openspace/utils/logging.py b/openspace/utils/logging.py index fabb5b1..7382374 100644 --- a/openspace/utils/logging.py +++ b/openspace/utils/logging.py @@ -233,6 +233,23 @@ class Logger: cls._configured = True + @classmethod + def set_level(cls, level: str) -> None: + """Set log level by name (e.g. ``"DEBUG"``, ``"INFO"``, ``"WARNING"``).""" + resolved = getattr(logging, level.upper(), None) + if resolved is None or not isinstance(resolved, int): + raise ValueError(f"Unknown log level: {level!r}") + if not cls._configured: + cls.configure(level=resolved, attach_to_root=True) + return + + root_logger = logging.getLogger() + root_logger.setLevel(resolved) + for handler in root_logger.handlers: + handler.setLevel(resolved) + + cls._update_level(resolved) + @classmethod def set_debug(cls, debug_level: int = 2) -> None: """Dynamically switch debug level: 0 = WARNING, 1 = INFO, 2 = DEBUG.""" @@ -309,4 +326,4 @@ Logger.configure(attach_to_root=True) # Get openspace logger for internal logging logger = Logger.get_logger() -logger.debug("OpenSpace logging initialized") \ No newline at end of file +logger.debug("OpenSpace logging initialized") diff --git a/pyproject.toml b/pyproject.toml index 571ef8a..38eba3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ authors = [ ] dependencies = [ - "litellm>=1.70.0", + "litellm>=1.70.0,<1.82.7", # pinned to avoid PYSEC-2026-2 supply-chain compromise (1.82.7/1.82.8 were malicious) "python-dotenv>=1.0.0", "openai>=1.0.0", "jsonschema>=4.25.0", @@ -26,6 +26,7 @@ dependencies = [ "flask>=3.1.0", "pyautogui>=0.9.54", "pydantic>=2.12.0", + "aiohttp>=3.10.0", "requests>=2.32.0", ] @@ -57,8 +58,15 @@ dev = [ "mypy>=1.0.0", ] +communication = [ + "lark-oapi>=1.4.20", +] + all = [ - "openspace[macos,linux,windows,dev]", + "openspace[macos]; sys_platform == 'darwin'", + "openspace[linux]; sys_platform == 'linux'", + "openspace[windows]; sys_platform == 'win32'", + "openspace[communication,dev]", ] [project.urls] @@ -72,6 +80,7 @@ openspace-mcp = "openspace.mcp_server:run_mcp_server" openspace-download-skill = "openspace.cloud.cli.download_skill:main" openspace-upload-skill = "openspace.cloud.cli.upload_skill:main" openspace-dashboard = "openspace.dashboard_server:main" +openspace-gateway = "openspace.communication.gateway:run_main" [tool.setuptools] packages = {find = {where = ["."], include = ["openspace*"]}} @@ -80,6 +89,7 @@ packages = {find = {where = ["."], include = ["openspace*"]}} openspace = [ "config/*.json", "config/*.json.example", + "communication/bridges/whatsapp/*", "local_server/config.json", "local_server/README.md", ] diff --git a/requirements.txt b/requirements.txt index 3f8c7c4..6371308 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # OpenSpace core dependencies -litellm>=1.70.0 +litellm>=1.70.0,<1.82.7 # pinned to avoid PYSEC-2026-2 supply-chain compromise (1.82.7/1.82.8 were malicious) python-dotenv>=1.0.0 openai>=1.0.0 jsonschema>=4.25.0