diff --git a/.github/ISSUE_TEMPLATE/1.bug.yml b/.github/ISSUE_TEMPLATE/1.bug.yml index a4f10926..c3ef17fa 100644 --- a/.github/ISSUE_TEMPLATE/1.bug.yml +++ b/.github/ISSUE_TEMPLATE/1.bug.yml @@ -1,131 +1,46 @@ name: Bug report 🐛 -description: 项目运行中遇到的Bug或问题。 +description: Report a bug or unexpected behavior. +title: "[Bug] " labels: ['status: needs check'] body: - type: markdown attributes: value: | - ### ⚠️ 前置确认 - 1. 网络能够访问openai接口 - 2. python 已安装:版本在 3.7 ~ 3.10 之间 - 3. `git pull` 拉取最新代码 - 4. 执行`pip3 install -r requirements.txt`,检查依赖是否满足 - 5. 拓展功能请执行`pip3 install -r requirements-optional.txt`,检查依赖是否满足 - 6. [FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) 中无类似问题 + > 💡 English is recommended so global developers can help. 推荐使用英文提交,谢谢 ❤️ - type: checkboxes attributes: - label: 前置确认 + label: Self check options: - - label: 我确认我运行的是最新版本的代码,并且安装了所需的依赖,在[FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs)中也未找到类似问题。 + - label: I'm on the latest version and searched [existing issues](https://github.com/zhayujie/CowAgent/issues) (incl. closed) — no duplicate. required: true - - type: checkboxes + - type: textarea attributes: - label: ⚠️ 搜索issues中是否已存在类似问题 - description: > - 请在 [历史issue](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中清空输入框,搜索你的问题 - 或相关日志的关键词来查找是否存在类似问题。 - options: - - label: 我已经搜索过issues和disscussions,没有跟我遇到的问题相关的issue - required: true - - type: markdown - attributes: - value: | - 请在上方的`title`中填写你对你所遇到问题的简略总结,这将帮助其他人更好的找到相似问题,谢谢❤️。 - - type: dropdown - attributes: - label: 操作系统类型? - description: > - 请选择你运行程序的操作系统类型。 - options: - - Windows - - Linux - - MacOS - - Docker - - Railway - - Windows Subsystem for Linux (WSL) - - Other (请在问题中说明) - validations: - required: true - - type: dropdown - attributes: - label: 运行的python版本是? - description: | - 请选择你运行程序的`python`版本。 - 注意:在`python 3.7`中,有部分可选依赖无法安装。 - 经过长时间的观察,我们认为`python 3.8`是兼容性最好的版本。 - `python 3.7`~`python 3.10`以外版本的issue,将视情况直接关闭。 - options: - - python 3.7 - - python 3.8 - - python 3.9 - - python 3.10 - - other - validations: - required: true - - type: dropdown - attributes: - label: 使用的chatgpt-on-wechat版本是? - description: | - 请确保你使用的是 [releases](https://github.com/zhayujie/chatgpt-on-wechat/releases) 中的最新版本。 - 如果你使用git, 请使用`git branch`命令来查看分支。 - options: - - Latest Release - - Master (branch) - validations: - required: true - - type: dropdown - attributes: - label: 运行的`channel`类型是? - description: | - 请确保你正确配置了该`channel`所需的配置项,所有可选的配置项都写在了[该文件中](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py),请将所需配置项填写在根目录下的`config.json`文件中。 - options: - - wechatmp(公众号, 订阅号) - - wechatmp_service(公众号, 服务号) - - terminal - - other + label: Environment + description: "Version (`cow status`), OS, Python version, install method, model & channel." + placeholder: | + Version: v1.2.0 + OS: macOS / Linux / Windows / Docker + Python: 3.11 + Install: installer / Docker / source + Model & channel: deepseek-v4-flash, web validations: required: true - type: textarea attributes: - label: 复现步骤 🕹 - description: | - **⚠️ 不能复现将会关闭issue.** - - type: textarea - attributes: - label: 问题描述 😯 - description: 详细描述出现的问题,或提供有关截图。 - - type: textarea - attributes: - label: 终端日志 📒 - description: | - 在此处粘贴终端日志,可在主目录下`run.log`文件中找到,这会帮助我们更好的分析问题,注意隐去你的API key。 - 如果在配置文件中加入`"debug": true`,打印出的日志会更有帮助。 + label: What happened? + description: "Steps to reproduce, what you expected, and what happened instead. Screenshots welcome." + placeholder: | + 1. ... + 2. ... -
- 示例 - ```log - [DEBUG][2023-04-16 00:23:22][plugin_manager.py:157] - Plugin SUMMARY triggered by event Event.ON_HANDLE_CONTEXT - [DEBUG][2023-04-16 00:23:22][main.py:221] - [Summary] on_handle_context. content: $总结前100条消息 - [DEBUG][2023-04-16 00:23:24][main.py:240] - [Summary] limit: 100, duration: -1 seconds - [ERROR][2023-04-16 00:23:24][chat_channel.py:244] - Worker return exception: name 'start_date' is not defined - Traceback (most recent call last): - File "C:\ProgramData\Anaconda3\lib\concurrent\futures\thread.py", line 57, in run - result = self.fn(*self.args, **self.kwargs) - File "D:\project\chatgpt-on-wechat\channel\chat_channel.py", line 132, in _handle - reply = self._generate_reply(context) - File "D:\project\chatgpt-on-wechat\channel\chat_channel.py", line 142, in _generate_reply - e_context = PluginManager().emit_event(EventContext(Event.ON_HANDLE_CONTEXT, { - File "D:\project\chatgpt-on-wechat\plugins\plugin_manager.py", line 159, in emit_event - instance.handlers[e_context.event](e_context, *args, **kwargs) - File "D:\project\chatgpt-on-wechat\plugins\summary\main.py", line 255, in on_handle_context - records = self._get_records(session_id, start_time, limit) - File "D:\project\chatgpt-on-wechat\plugins\summary\main.py", line 96, in _get_records - c.execute("SELECT * FROM chat_records WHERE sessionid=? and timestamp>? ORDER BY timestamp DESC LIMIT ?", (session_id, start_date, limit)) - NameError: name 'start_date' is not defined - [INFO][2023-04-16 00:23:36][app.py:14] - signal 2 received, exiting... - ``` -
- value: | - ```log - <此处粘贴终端日志> - ``` \ No newline at end of file + Expected: ... + Actual: ... + validations: + required: true + - type: textarea + attributes: + label: Logs + description: "Relevant logs from `run.log` (set `\"debug\": true` for more detail). ⚠️ Redact your API keys." + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/2.feature.yml b/.github/ISSUE_TEMPLATE/2.feature.yml index bbf0888a..8b53ff92 100644 --- a/.github/ISSUE_TEMPLATE/2.feature.yml +++ b/.github/ISSUE_TEMPLATE/2.feature.yml @@ -1,28 +1,33 @@ name: Feature request 🚀 -description: 提出你对项目的新想法或建议。 +description: Suggest a new idea or improvement. +title: "[Feature] " labels: ['status: needs check'] body: - type: markdown attributes: value: | - 请在上方的`title`中填写简略总结,谢谢❤️。 + > 💡 English is recommended so global developers can help. 推荐使用英文提交,谢谢 ❤️ - type: checkboxes attributes: - label: ⚠️ 搜索是否存在类似issue - description: > - 请在 [历史issue](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中清空输入框,搜索关键词查找是否存在相似issue。 + label: Self check options: - - label: 我已经搜索过issues和disscussions,没有发现相似issue + - label: I searched [existing issues](https://github.com/zhayujie/CowAgent/issues) (incl. closed) — no duplicate. required: true - type: textarea attributes: - label: 总结 - description: 描述feature的功能。 + label: What's the problem? + description: "The pain point or what's not working for you right now." + validations: + required: true - type: textarea attributes: - label: 举例 - description: 提供聊天示例,草图或相关网址。 - - type: textarea + label: What would you like? + description: "How you'd expect it to work. Examples, sketches, or links welcome." + validations: + required: false + - type: checkboxes attributes: - label: 动机 - description: 描述你提出该feature的动机,比如没有这项feature对你的使用造成了怎样的影响。 请提供更详细的场景描述,这可能会帮助我们发现并提出更好的解决方案。 \ No newline at end of file + label: Contribution + options: + - label: I'd be interested in helping implement this. + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..a37a397e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: 📖 Documentation + url: https://docs.cowagent.ai + about: Setup guides, configuration, and FAQ. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..d8a2741d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ + + +## What does this PR do? + + + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Docs +- [ ] Refactor / chore + +## Checklist + +- [ ] I have read the [Contributing Guide](https://github.com/zhayujie/CowAgent/blob/master/CONTRIBUTING.md) +- [ ] I tested this change locally +- [ ] Code comments and docs are in English +- [ ] Linked related issue (if any): closes # diff --git a/.github/workflows/deploy-image-arm.yml b/.github/workflows/deploy-image-arm.yml index 9721addb..2beaeb40 100644 --- a/.github/workflows/deploy-image-arm.yml +++ b/.github/workflows/deploy-image-arm.yml @@ -19,7 +19,7 @@ env: jobs: build-and-push-image: - if: github.repository == 'zhayujie/chatgpt-on-wechat' + if: github.repository == 'zhayujie/CowAgent' runs-on: ubuntu-latest permissions: contents: read @@ -51,7 +51,12 @@ jobs: uses: docker/metadata-action@v4 with: images: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + ${{ env.REGISTRY }}/zhayujie/chatgpt-on-wechat + ${{ env.REGISTRY }}/zhayujie/cowagent + tags: | + type=raw,value=latest-arm64,enable={{is_default_branch}} + type=ref,event=branch,suffix=-arm64 + type=ref,event=tag,suffix=-arm64 - name: Build and push Docker image uses: docker/build-push-action@v3 @@ -60,7 +65,7 @@ jobs: push: true file: ./docker/Dockerfile.latest platforms: linux/arm64 - tags: ${{ steps.meta.outputs.tags }}-arm64 + tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - uses: actions/delete-package-versions@v4 diff --git a/.github/workflows/deploy-image.yml b/.github/workflows/deploy-image.yml index a30b77ff..d9ac0be1 100644 --- a/.github/workflows/deploy-image.yml +++ b/.github/workflows/deploy-image.yml @@ -16,10 +16,11 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} + DOCKERHUB_IMAGE: zhayujie/chatgpt-on-wechat jobs: build-and-push-image: - if: github.repository == 'zhayujie/chatgpt-on-wechat' + if: github.repository == 'zhayujie/CowAgent' runs-on: ubuntu-latest permissions: contents: read @@ -47,8 +48,14 @@ jobs: uses: docker/metadata-action@v4 with: images: | - ${{ env.IMAGE_NAME }} - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + zhayujie/chatgpt-on-wechat + zhayujie/cowagent + ${{ env.REGISTRY }}/zhayujie/chatgpt-on-wechat + ${{ env.REGISTRY }}/zhayujie/cowagent + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=ref,event=tag - name: Build and push Docker image uses: docker/build-push-action@v3 diff --git a/.github/workflows/test-windows-bash.yml b/.github/workflows/test-windows-bash.yml new file mode 100644 index 00000000..4a39dbe6 --- /dev/null +++ b/.github/workflows/test-windows-bash.yml @@ -0,0 +1,32 @@ +name: Windows Bash Streaming Tests + +on: + workflow_dispatch: + pull_request: + paths: + - "agent/tools/bash/bash.py" + - "tests/test_bash_streaming.py" + - ".github/workflows/test-windows-bash.yml" + +jobs: + windows-bash-tests: + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pytest + python -m pip install -r requirements.txt + + - name: Run Windows Bash streaming tests + run: python -m pytest tests/test_bash_streaming.py -v diff --git a/.gitignore b/.gitignore index e217c97b..de10c0b7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,8 +32,16 @@ plugins/banwords/lib/__pycache__ !plugins/role !plugins/keyword !plugins/linkai -!plugins/agent +!plugins/cow_cli client_config.json ref/ +**/.dev.vars .cursor/ local/ +node_modules/ + +# cow cli +dist/ +build/ +*.egg-info/ +.cow.pid diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..4a9d7d8a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing to CowAgent + +Thanks for taking the time to contribute! 🎉 CowAgent is built by a global +community, and contributions of all sizes are welcome — from typo fixes to new +features. + +## Language policy + +To keep the project accessible to a global community, **please write issues, +pull requests, code comments, and commit messages in English.** + +> 为方便全球开发者协作,请尽量使用**英文**提交 issue、PR、代码注释与 +> commit message。不必担心英文不完美——表达清楚即可,工具翻译也完全没问题。感谢理解 ❤️ + +## Reporting issues + +Found a bug or have an idea? [Open an issue](https://github.com/zhayujie/CowAgent/issues/new/choose). + +Before opening one, please search existing issues (including closed ones) to +avoid duplicates, and make sure you're on the latest version. + +## Submitting a pull request + +1. **Fork** the repo and create a branch from `master` + (e.g. `feat/web-search`, `fix/telegram-reconnect`). +2. Make your change. Keep it focused — one logical change per PR. +3. Follow the existing code style. Write comments and docstrings in English. +4. Run the app locally to confirm your change works. +5. Open a PR with a clear title and a short description of **what** and **why**. + +We keep the bar friendly: clear, focused, and working is enough. Maintainers are +happy to help polish details during review. + +### Commit & PR titles + +Use a short, imperative summary. The [Conventional Commits](https://www.conventionalcommits.org/) +style is preferred but not required: + +``` +feat: add web search tool +fix: reconnect Telegram websocket on timeout +docs: clarify Docker setup +``` + +## Development setup + +See the [Install from Source](https://docs.cowagent.ai/guide/manual-install) +guide. In short: + +```bash +git clone https://github.com/zhayujie/CowAgent.git +cd CowAgent +pip install -r requirements.txt +pip install -e . +cow start +``` + +## Code of conduct + +Be respectful and constructive. We want CowAgent to be a welcoming place for +everyone. diff --git a/README.md b/README.md index 42d33878..2e98edb1 100644 --- a/README.md +++ b/README.md @@ -1,825 +1,274 @@ -

Chatgpt-on-Wechat

+

CowAgent

- Latest release - License: MIT - Stars
- [中文] | [English] | [日本語] + Latest release + License: MIT + Stars + Docs

-**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入网页、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号中使用,7*24小时运行于你的个人电脑或服务器中。 -

- 🌐 官网  ·  - 📖 文档中心  ·  - 🚀 快速开始  ·  - ☁️ 在线体验 + zhayujie%2FCowAgent | Trendshift

+

+ [English] | [中文] | [日本語] +

+**CowAgent** is an open-source super AI assistant that proactively plans tasks, controls your computer and external services, creates and runs Skills, builds a personal knowledge base and long-term memory, and grows alongside you through self-evolution — a reference implementation of Agent Harness engineering. -# 简介 +CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major LLM provider and run it 24/7 on a personal computer or server, across the web and all major IM platforms. -> 该项目既是一个可以开箱即用的超级AI助理,也是一个支持高扩展的Agent框架,可以通过为项目扩展大模型接口、接入渠道、内置工具、Skills系统来灵活实现各种定制需求。核心能力如下: - -- ✅ **复杂任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标,支持通过工具操作访问文件、终端、浏览器、定时任务等系统资源 -- ✅ **长期记忆:** 自动将对话记忆持久化至本地文件和数据库中,包括全局记忆和天级记忆,支持关键词及向量检索 -- ✅ **技能系统:** 实现了Skills创建和运行的引擎,内置多种技能,并支持通过自然语言对话完成自定义Skills开发 -- ✅ **多模态消息:** 支持对文本、图片、语音、文件等多类型消息进行解析、处理、生成、发送等操作 -- ✅ **多模型接入:** 支持OpenAI, Claude, Gemini, DeepSeek, MiniMax、GLM、Qwen、Kimi、Doubao等国内外主流模型厂商 -- ✅ **多端部署:** 支持运行在本地计算机或服务器,可集成到飞书、钉钉、企业微信、QQ、微信公众号、网页中使用 - -## 声明 - -1. 本项目遵循 [MIT开源协议](/LICENSE),主要用于技术研究和学习,使用本项目时需遵守所在地法律法规、相关政策以及企业章程,禁止用于任何违法或侵犯他人权益的行为。任何个人、团队和企业,无论以何种方式使用该项目、对何对象提供服务,所产生的一切后果,本项目均不承担任何责任。 -2. 成本与安全:Agent模式下Token使用量高于普通对话模式,请根据效果及成本综合选择模型。Agent具有访问所在操作系统的能力,请谨慎选择项目部署环境。同时项目也会持续升级安全机制、并降低模型消耗成本。 -3. CowAgent项目专注于开源技术开发,不会参与、授权或发行任何加密货币。 - -## 演示 - -- 使用说明(Agent模式):[CowAgent介绍](https://docs.cowagent.ai/intro/features) - -- 免部署在线体验:[CowAgent](https://link-ai.tech/cowagent/create) - -- DEMO视频(对话模式):https://cdn.link-ai.tech/doc/cow_demo.mp4 - -## 社区 - -添加小助手微信加入开源项目交流群: - - +

+ 🌐 Website  ·  + 📖 Docs  ·  + 🚀 Quick Start  ·  + 🧩 Skill Hub  ·  + ☁️ Try Online +


-# 企业服务 +## 🌟 Highlights - - -> [LinkAI](https://link-ai.tech/) 是面向企业和个人的一站式AI智能体平台,聚合多模态大模型、知识库、技能、工作流等能力,支持一键接入主流平台并管理,支持SaaS、私有化部署等多种模式,可免部署在线运行[CowAgent助理](https://link-ai.tech/cowagent/create)。 -> -> LinkAI 目前已在智能客服、私域运营、企业效率助手等场景积累了丰富的AI解决方案,在消费、健康、文教、科技制造等各行业沉淀了大模型落地应用的最佳实践,致力于帮助更多企业和开发者拥抱 AI 生产力。 - -**产品咨询和企业服务** 可联系产品客服: - - +| Capability | Description | +| :--- | :--- | +| [Planning](https://docs.cowagent.ai/intro/architecture) | Decomposes complex tasks and executes them step by step, looping over tools until the goal is reached | +| [Memory](https://docs.cowagent.ai/memory/index) | Three-tier architecture (context → daily → core), automatic Deep Dream distillation, hybrid keyword + vector retrieval | +| [Knowledge](https://docs.cowagent.ai/knowledge/index) | Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing | +| [Evolution](https://docs.cowagent.ai/memory/self-evolution) | Self-Evolution reviews conversations automatically to improve skills, follow up on unfinished tasks, and consolidate memory and knowledge, growing through everyday use | +| [Skills](https://docs.cowagent.ai/skills/index) | One-click install from [Skill Hub](https://skills.cowagent.ai/), GitHub, ClawHub; or create custom skills via natural-language conversation | +| [Tools](https://docs.cowagent.ai/tools/index) | Built-in file I/O, terminal, browser, scheduler, memory retrieval, web search, and 10+ more tools — with native MCP integration | +| [Channels](https://docs.cowagent.ai/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, Official Accounts, Telegram, and Slack | +| Multimodal | First-class support for text, images, voice, and files — recognition, generation, and delivery | +| [Models](https://docs.cowagent.ai/models/index) | Claude, GPT, Gemini, DeepSeek, Qwen, GLM, Kimi, MiniMax, Doubao, and more — swap providers from the Web console with one click | +| [Deploy](https://docs.cowagent.ai/guide/quick-start) | One-line installer, unified Web console, multiple deployment modes (local, Docker, server) |
-# 🏷 更新日志 +## 🏗️ Architecture ->**2026.03.18:** [2.0.3版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.3),新增企微智能机器人和 QQ 通道、支持Coding Plan、新增多个模型、Web端文件处理、记忆系统升级。 +CowAgent Architecture ->**2026.02.27:** [2.0.2版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.2),Web 控制台全面升级(流式对话、模型/技能/记忆/通道/定时任务/日志管理)、支持多通道同时运行、会话持久化存储、新增多个模型。 +CowAgent is a complete **Agent Harness**: messages flow in through **Channels**; the **Agent Core** plans and reasons over memory, knowledge, and the available tools and skills; **Models** generate the response, which is sent back through the originating channel. Every layer is decoupled and independently extensible. ->**2026.02.13:** [2.0.1版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.1),内置 Web Search 工具、智能上下文裁剪策略、运行时信息动态更新、Windows 兼容性适配,修复定时任务记忆丢失、飞书连接等多项问题。 - ->**2026.02.03:** [2.0.0版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.0),正式升级为超级Agent助理,支持多轮任务决策、具备长期记忆、实现多种系统工具、支持Skills框架,新增多种模型并优化了接入渠道。 - ->**2025.05.23:** [1.7.6版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.6) 优化web网页channel、新增 [AgentMesh](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/agent/README.md)多智能体插件、百度语音合成优化、企微应用`access_token`获取优化、支持`claude-4-sonnet`和`claude-4-opus`模型 - ->**2025.04.11:** [1.7.5版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.5) 新增支持 [wechatferry](https://github.com/zhayujie/chatgpt-on-wechat/pull/2562) 协议、新增 deepseek 模型、新增支持腾讯云语音能力、新增支持 ModelScope 和 Gitee-AI API接口 - -更多更新历史请查看: [更新日志](https://docs.cowagent.ai/releases) +Read more in [Architecture](https://docs.cowagent.ai/intro/architecture).
-# 🚀 快速开始 +## 🚀 Quick Start -项目提供了一键安装、配置、启动、管理程序的脚本,推荐使用脚本快速运行,也可以根据下文中的详细指引一步步安装运行。 +A one-line installer takes care of dependencies, configuration, and startup: -在终端执行以下命令: +**Linux / macOS:** ```bash bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh) ``` -脚本使用说明:[一键运行脚本](https://docs.cowagent.ai/guide/quick-start) +**Windows (PowerShell):** - -## 一、准备 - -### 1. 模型API - -项目支持国内外主流厂商的模型接口,可选模型及配置说明参考:[模型说明](#模型说明)。 - -> 注:Agent模式下推荐使用以下模型,可根据效果及成本综合选择:MiniMax-M2.7、glm-5-turbo、kimi-k2.5、qwen3.5-plus、claude-sonnet-4-6、gemini-3.1-pro-preview、gpt-5.4、gpt-5.4-mini - -同时支持使用 **LinkAI平台** 接口,支持上述全部模型,并支持知识库、工作流、插件等Agent技能,参考 [接口文档](https://docs.link-ai.tech/platform/api)。 - -### 2.环境安装 - -支持 Linux、MacOS、Windows 操作系统,可在个人计算机及服务器上运行,需安装 `Python`,Python版本需在3.7 ~ 3.12 之间,推荐使用3.9版本。 - -> 注意:Agent模式推荐使用源码运行,若选择Docker部署则无需安装python环境和下载源码,可直接快进到下一节。 - -**(1) 克隆项目代码:** - -```bash -git clone https://github.com/zhayujie/chatgpt-on-wechat -cd chatgpt-on-wechat/ +```powershell +irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex ``` -若遇到网络问题可使用国内仓库地址:https://gitee.com/zhayujie/chatgpt-on-wechat - -**(2) 安装核心依赖 (必选):** - -```bash -pip3 install -r requirements.txt -``` - -**(3) 拓展依赖 (可选,建议安装):** - -```bash -pip3 install -r requirements-optional.txt -``` -如果某项依赖安装失败可注释掉对应的行后重试。 - -## 二、配置 - -配置文件的模板在根目录的`config-template.json`中,需复制该模板创建最终生效的 `config.json` 文件: - -```bash - cp config-template.json config.json -``` - -然后在`config.json`中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改(注意实际使用时请去掉注释,保证JSON格式的规范): - -```bash -# config.json 文件内容示例 -{ - "channel_type": "web", # 接入渠道类型,默认为web,支持修改为:feishu,dingtalk,wecom_bot,qq,wechatcom_app,wechatmp_service,wechatmp,terminal - "model": "MiniMax-M2.7", # 模型名称 - "minimax_api_key": "", # MiniMax API Key - "zhipu_ai_api_key": "", # 智谱GLM API Key - "moonshot_api_key": "", # Kimi/Moonshot API Key - "ark_api_key": "", # 豆包(火山方舟) API Key - "dashscope_api_key": "", # 百炼(通义千问)API Key - "claude_api_key": "", # Claude API Key - "claude_api_base": "https://api.anthropic.com/v1", # Claude API 地址,修改可接入三方代理平台 - "gemini_api_key": "", # Gemini API Key - "gemini_api_base": "https://generativelanguage.googleapis.com", # Gemini API地址 - "open_ai_api_key": "", # OpenAI API Key - "open_ai_api_base": "https://api.openai.com/v1", # OpenAI API 地址 - "linkai_api_key": "", # LinkAI API Key - "proxy": "", # 代理客户端的ip和端口,国内环境需要开启代理的可填写该项,如 "127.0.0.1:7890" - "speech_recognition": false, # 是否开启语音识别 - "group_speech_recognition": false, # 是否开启群组语音识别 - "voice_reply_voice": false, # 是否使用语音回复语音 - "use_linkai": false, # 是否使用LinkAI接口,默认关闭,设置为true后可对接LinkAI平台模型 - "agent": true, # 是否启用Agent模式,启用后拥有多轮工具决策、长期记忆、Skills能力等 - "agent_workspace": "~/cow", # Agent的工作空间路径,用于存储memory、skills、系统设定等 - "agent_max_context_tokens": 40000, # Agent模式下最大上下文tokens,超出将自动丢弃最早的上下文 - "agent_max_context_turns": 30, # Agent模式下最大上下文记忆轮次,每轮包括一次用户提问和AI回复 - "agent_max_steps": 15 # Agent模式下单次任务的最大决策步数,超出后将停止继续调用工具 -} -``` - -**配置补充说明:** - -
-1. 语音配置 - -+ 添加 `"speech_recognition": true` 将开启语音识别,默认使用openai的whisper模型识别为文字,同时以文字回复,该参数仅支持私聊 (注意由于语音消息无法匹配前缀,一旦开启将对所有语音自动回复,支持语音触发画图); -+ 添加 `"group_speech_recognition": true` 将开启群组语音识别,默认使用openai的whisper模型识别为文字,同时以文字回复,参数仅支持群聊 (会匹配group_chat_prefix和group_chat_keyword, 支持语音触发画图); -+ 添加 `"voice_reply_voice": true` 将开启语音回复语音(同时作用于私聊和群聊) -
- -
-2. 其他配置 - -+ `model`: 模型名称,Agent模式下推荐使用 `MiniMax-M2.7`、`glm-5-turbo`、`kimi-k2.5`、`qwen3.5-plus`、`claude-sonnet-4-6`、`gemini-3.1-pro-preview`,全部模型名称参考[common/const.py](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/common/const.py)文件 -+ `character_desc`:普通对话模式下的机器人系统提示词。在Agent模式下该配置不生效,由工作空间中的文件内容构成。 -+ `subscribe_msg`:订阅消息,公众号和企业微信channel中请填写,当被订阅时会自动回复, 可使用特殊占位符。目前支持的占位符有{trigger_prefix},在程序中它会自动替换成bot的触发词。 -
- -
-3. LinkAI配置 - -+ `use_linkai`: 是否使用LinkAI接口,默认关闭,设置为true后可对接LinkAI平台,使用模型、知识库、工作流、插件等技能, 参考[接口文档](https://docs.link-ai.tech/platform/api/chat) -+ `linkai_api_key`: LinkAI Api Key,可在 [控制台](https://link-ai.tech/console/interface) 创建 -
- -注:全部配置项说明可在 [`config.py`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py) 文件中查看。 - -## 三、运行 - -### 1.本地运行 - -如果是个人计算机 **本地运行**,直接在项目根目录下执行: - -```bash -python3 app.py # windows环境下该命令通常为 python app.py -``` - -运行后默认会启动web服务,可通过访问 `http://localhost:9899/chat` 在网页端对话。 - -如果需要接入其他应用通道只需修改 `config.json` 配置文件中的 `channel_type` 参数,详情参考:[通道说明](#通道说明)。 - - -### 2.服务器部署 - -在服务器中可使用 `nohup` 命令在后台运行程序: - -```bash -nohup python3 app.py & tail -f nohup.out -``` - -执行后程序运行于服务器后台,可通过 `ctrl+c` 关闭日志,不会影响后台程序的运行。使用 `ps -ef | grep app.py | grep -v grep` 命令可查看运行于后台的进程,如果想要重新启动程序可以先 `kill` 掉对应的进程。 日志关闭后如果想要再次打开只需输入 `tail -f nohup.out`。 - -此外,项目根目录下的 `run.sh` 脚本支持一键启动和管理服务,包括 `./run.sh start`、`./run.sh stop`、`./run.sh restart`、`./run.sh logs` 等命令,执行 `./run.sh help` 可查看全部用法。 - -> 如果需要通过浏览器访问Web控制台,请确保服务器的 `9899` 端口已在防火墙或安全组中放行,建议仅对指定IP开放以保证安全。 - -### 3.Docker部署 - -使用docker部署无需下载源码和安装依赖,只需要获取 `docker-compose.yml` 配置文件并启动容器即可。Agent模式下更推荐使用源码进行部署,以获得更多系统访问能力。 - -> 前提是需要安装好 `docker` 及 `docker-compose`,安装成功后执行 `docker -v` 和 `docker-compose version` (或 `docker compose version`) 可查看到版本号。安装地址为 [docker官网](https://docs.docker.com/engine/install/) 。 - -**(1) 下载 docker-compose.yml 文件** +**Docker:** ```bash curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml +docker compose up -d ``` -下载完成后打开 `docker-compose.yml` 填写所需配置,例如 `CHANNEL_TYPE`、`OPEN_AI_API_KEY` 和等配置。 +Once started, open `http://localhost:9899` to access the **Web console** — your one-stop hub to chat with the Agent, configure models, connect channels, and install skills. -**(2) 启动容器** +> Deploying on a server? Set `web_host` to `0.0.0.0` in `config.json` to make the console reachable from outside, and set `web_password` to protect it. Don't forget to open port `9899` in your firewall or security group. -在 `docker-compose.yml` 所在目录下执行以下命令启动容器: +> 📖 Detailed guides: [Quick Start](https://docs.cowagent.ai/guide/quick-start) · [Install from Source](https://docs.cowagent.ai/guide/manual-install) · [Upgrade](https://docs.cowagent.ai/guide/upgrade) + +After installation, manage the service with the [cow CLI](https://docs.cowagent.ai/cli/index): ```bash -sudo docker compose up -d # 若docker-compose为 1.X 版本,则执行 `sudo docker-compose up -d` +cow start | stop | restart # service control +cow status | logs # status and logs +cow update # pull latest code and restart +cow skill install # install a skill +cow install-browser # install browser automation ``` -运行命令后,会自动取 [docker hub](https://hub.docker.com/r/zhayujie/chatgpt-on-wechat) 拉取最新release版本的镜像。当执行 `sudo docker ps` 能查看到 NAMES 为 chatgpt-on-wechat 的容器即表示运行成功。最后执行以下命令可查看容器的运行日志: - -```bash -sudo docker logs -f chatgpt-on-wechat -``` - -> 如果需要通过浏览器访问Web控制台,请确保服务器的 `9899` 端口已在防火墙或安全组中放行,建议仅对指定IP开放以保证安全。 - -## 模型说明 - -以下对所有可支持的模型的配置和使用方法进行说明,模型接口实现在项目的 `models/` 目录下。 - -
-OpenAI - -1. API Key创建:在 [OpenAI平台](https://platform.openai.com/api-keys) 创建API Key - -2. 填写配置 - -```json -{ - "model": "gpt-5.4", - "open_ai_api_key": "YOUR_API_KEY", - "open_ai_api_base": "https://api.openai.com/v1", - "bot_type": "openai" -} -``` - - - `model`: 与OpenAI接口的 [model参数](https://platform.openai.com/docs/models) 一致,支持包括 gpt-5.4、gpt-5.4-mini、gpt-5.4-nano、o系列、gpt-4.1等模型,Agent模式推荐使用 `gpt-5.4`、`gpt-5.4-mini` - - `open_ai_api_base`: 如果需要接入第三方代理接口,可通过修改该参数进行接入 - - `bot_type`: 使用OpenAI相关模型时无需填写。当使用第三方代理接口接入Claude等非OpenAI官方模型时,该参数设为 `openai` -
- -
-LinkAI - -1. API Key创建:在 [LinkAI平台](https://link-ai.tech/console/interface) 创建API Key - -2. 填写配置 - -```json -{ - "model": "gpt-5.4-mini", - "use_linkai": true, - "linkai_api_key": "YOUR API KEY" -} -``` - -+ `use_linkai`: 是否使用LinkAI接口,默认关闭,设置为true后可对接LinkAI平台的模型,并使用知识库、工作流、数据库、插件等丰富的Agent技能 -+ `linkai_api_key`: LinkAI平台的API Key,可在 [控制台](https://link-ai.tech/console/interface) 中创建 -+ `model`: [模型列表](https://link-ai.tech/console/models)中的全部模型均可使用 -
- -
-MiniMax - -方式一:官方接入,配置如下(推荐): - -```json -{ - "model": "MiniMax-M2.7", - "minimax_api_key": "" -} -``` - - `model`: 可填写 `MiniMax-M2.7、MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2、abab6.5-chat` 等 - - `minimax_api_key`:MiniMax平台的API-KEY,在 [控制台](https://platform.minimaxi.com/user-center/basic-information/interface-key) 创建 - -方式二:OpenAI兼容方式接入,配置如下: -```json -{ - "bot_type": "openai", - "model": "MiniMax-M2.7", - "open_ai_api_base": "https://api.minimaxi.com/v1", - "open_ai_api_key": "" -} -``` -- `bot_type`: OpenAI兼容方式 -- `model`: 可填 `MiniMax-M2.7、MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2`,参考[API文档](https://platform.minimaxi.com/document/%E5%AF%B9%E8%AF%9D?key=66701d281d57f38758d581d0#QklxsNSbaf6kM4j6wjO5eEek) -- `open_ai_api_base`: MiniMax平台API的 BASE URL -- `open_ai_api_key`: MiniMax平台的API-KEY -
- -
-智谱AI (GLM) - -方式一:官方接入,配置如下(推荐): - -```json -{ - "model": "glm-5-turbo", - "zhipu_ai_api_key": "" -} -``` - - `model`: 可填 `glm-5-turbo、glm-5、glm-4.7、glm-4-plus、glm-4-flash、glm-4-air、glm-4-airx、glm-4-long` 等, 参考 [glm系列模型编码](https://bigmodel.cn/dev/api/normal-model/glm-4) - - `zhipu_ai_api_key`: 智谱AI平台的 API KEY,在 [控制台](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) 创建 - -方式二:OpenAI兼容方式接入,配置如下: -```json -{ - "bot_type": "openai", - "model": "glm-5-turbo", - "open_ai_api_base": "https://open.bigmodel.cn/api/paas/v4", - "open_ai_api_key": "" -} -``` -- `bot_type`: OpenAI兼容方式 -- `model`: 可填 `glm-5-turbo、glm-5、glm-4.7、glm-4-plus、glm-4-flash、glm-4-air、glm-4-airx、glm-4-long` 等 -- `open_ai_api_base`: 智谱AI平台的 BASE URL -- `open_ai_api_key`: 智谱AI平台的 API KEY -
- -
-通义千问 (Qwen) - -方式一:官方SDK接入,配置如下(推荐): - -```json -{ - "model": "qwen3.5-plus", - "dashscope_api_key": "sk-qVxxxxG" -} -``` - - `model`: 可填写 `qwen3.5-plus、qwen3-max、qwen-max、qwen-plus、qwen-turbo、qwen-long、qwq-plus` 等 - - `dashscope_api_key`: 通义千问的 API-KEY,参考 [官方文档](https://bailian.console.aliyun.com/?tab=api#/api) ,在 [控制台](https://bailian.console.aliyun.com/?tab=model#/api-key) 创建 - -方式二:OpenAI兼容方式接入,配置如下: -```json -{ - "bot_type": "openai", - "model": "qwen3.5-plus", - "open_ai_api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "open_ai_api_key": "sk-qVxxxxG" -} -``` -- `bot_type`: OpenAI兼容方式 -- `model`: 支持官方所有模型,参考[模型列表](https://help.aliyun.com/zh/model-studio/models?spm=a2c4g.11186623.0.0.78d84823Kth5on#9f8890ce29g5u) -- `open_ai_api_base`: 通义千问API的 BASE URL -- `open_ai_api_key`: 通义千问的 API-KEY -
- -
-Kimi (Moonshot) - -方式一:官方接入,配置如下: - -```json -{ - "model": "kimi-k2.5", - "moonshot_api_key": "" -} -``` - - `model`: 可填写 `kimi-k2.5、kimi-k2、moonshot-v1-8k、moonshot-v1-32k、moonshot-v1-128k` - - `moonshot_api_key`: Moonshot的API-KEY,在 [控制台](https://platform.moonshot.cn/console/api-keys) 创建 - -方式二:OpenAI兼容方式接入,配置如下: -```json -{ - "bot_type": "openai", - "model": "kimi-k2.5", - "open_ai_api_base": "https://api.moonshot.cn/v1", - "open_ai_api_key": "" -} -``` -- `bot_type`: OpenAI兼容方式 -- `model`: 可填写 `kimi-k2.5、kimi-k2、moonshot-v1-8k、moonshot-v1-32k、moonshot-v1-128k` -- `open_ai_api_base`: Moonshot的 BASE URL -- `open_ai_api_key`: Moonshot的 API-KEY -
- -
-豆包 (Doubao) - -1. API Key创建:在 [火山方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) 创建API Key - -2. 填写配置 - -```json -{ - "model": "doubao-seed-2-0-code-preview-260215", - "ark_api_key": "YOUR_API_KEY" -} -``` - - `model`: 可填写 `doubao-seed-2-0-code-preview-260215、doubao-seed-2-0-pro-260215、doubao-seed-2-0-lite-260215、doubao-seed-2-0-mini-260215` 等 - - `ark_api_key`: 火山方舟平台的 API Key,在 [控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) 创建 - - `ark_base_url`: 可选,默认为 `https://ark.cn-beijing.volces.com/api/v3` -
- -
-Claude - -1. API Key创建:在 [Claude控制台](https://console.anthropic.com/settings/keys) 创建API Key - -2. 填写配置 - -```json -{ - "model": "claude-sonnet-4-6", - "claude_api_key": "YOUR_API_KEY" -} -``` - - `model`: 参考 [官方模型ID](https://docs.anthropic.com/en/docs/about-claude/models/overview#model-aliases) ,支持 `claude-sonnet-4-6、claude-opus-4-6、claude-sonnet-4-5、claude-sonnet-4-0、claude-opus-4-0、claude-3-5-sonnet-latest` 等 -
- -
-Gemini - -API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn) 创建API Key ,配置如下 -```json -{ - "model": "gemini-3.1-flash-lite-preview", - "gemini_api_key": "" -} -``` - - `model`: 参考[官方文档-模型列表](https://ai.google.dev/gemini-api/docs/models?hl=zh-cn),支持 `gemini-3.1-flash-lite-preview、gemini-3.1-pro-preview、gemini-3-flash-preview、gemini-3-pro-preview` 等 -
- -
-DeepSeek - -1. API Key创建:在 [DeepSeek平台](https://platform.deepseek.com/api_keys) 创建API Key - -2. 填写配置 - -```json -{ - "model": "deepseek-chat", - "open_ai_api_key": "sk-xxxxxxxxxxx", - "open_ai_api_base": "https://api.deepseek.com/v1", - "bot_type": "openai" - -} -``` - - - `bot_type`: OpenAI兼容方式 - - `model`: 可填 `deepseek-chat、deepseek-reasoner`,分别对应的是 DeepSeek-V3 和 DeepSeek-R1 模型 - - `open_ai_api_key`: DeepSeek平台的 API Key - - `open_ai_api_base`: DeepSeek平台 BASE URL -
- -
-Azure - -1. API Key创建:在 [Azure平台](https://oai.azure.com/) 创建API Key - -2. 填写配置 - -```json -{ - "model": "", - "use_azure_chatgpt": true, - "open_ai_api_key": "", - "open_ai_api_base": "", - "azure_deployment_id": "", - "azure_api_version": "2025-01-01-preview" -} -``` - - - `model`: 留空即可 - - `use_azure_chatgpt`: 设为 true - - `open_ai_api_key`: Azure平台的密钥 - - `open_ai_api_base`: Azure平台的 BASE URL - - `azure_deployment_id`: Azure平台部署的模型名称 - - `azure_api_version`: api版本以及以上参数可以在部署的 [模型配置](https://oai.azure.com/resource/deployments) 界面查看 -
- -
-百度文心 -方式一:官方SDK接入,配置如下: - -```json -{ - "model": "wenxin-4", - "baidu_wenxin_api_key": "IajztZ0bDxgnP9bEykU7lBer", - "baidu_wenxin_secret_key": "EDPZn6L24uAS9d8RWFfotK47dPvkjD6G" -} -``` - - `model`: 可填 `wenxin`和`wenxin-4`,对应模型为 文心-3.5 和 文心-4.0 - - `baidu_wenxin_api_key`:参考 [千帆平台-access_token鉴权](https://cloud.baidu.com/doc/WENXINWORKSHOP/s/dlv4pct3s) 文档获取 API Key - - `baidu_wenxin_secret_key`:参考 [千帆平台-access_token鉴权](https://cloud.baidu.com/doc/WENXINWORKSHOP/s/dlv4pct3s) 文档获取 Secret Key - -方式二:OpenAI兼容方式接入,配置如下: -```json -{ - "bot_type": "openai", - "model": "ERNIE-4.0-Turbo-8K", - "open_ai_api_base": "https://qianfan.baidubce.com/v2", - "open_ai_api_key": "bce-v3/ALTxxxxxxd2b" -} -``` -- `bot_type`: OpenAI兼容方式 -- `model`: 支持官方所有模型,参考[模型列表](https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Wm9cvy6rl) -- `open_ai_api_base`: 百度文心API的 BASE URL -- `open_ai_api_key`: 百度文心的 API-KEY,参考 [官方文档](https://cloud.baidu.com/doc/qianfan-api/s/ym9chdsy5) ,在 [控制台](https://console.bce.baidu.com/iam/#/iam/apikey/list) 创建API Key - -
- -
-讯飞星火 - -方式一:官方接入,配置如下: -参考 [官方文档-快速指引](https://www.xfyun.cn/doc/platform/quickguide.html#%E7%AC%AC%E4%BA%8C%E6%AD%A5-%E5%88%9B%E5%BB%BA%E6%82%A8%E7%9A%84%E7%AC%AC%E4%B8%80%E4%B8%AA%E5%BA%94%E7%94%A8-%E5%BC%80%E5%A7%8B%E4%BD%BF%E7%94%A8%E6%9C%8D%E5%8A%A1) 获取 `APPID、 APISecret、 APIKey` 三个参数 - -```json -{ - "model": "xunfei", - "xunfei_app_id": "", - "xunfei_api_key": "", - "xunfei_api_secret": "", - "xunfei_domain": "4.0Ultra", - "xunfei_spark_url": "wss://spark-api.xf-yun.com/v4.0/chat" -} -``` - - `model`: 填 `xunfei` - - `xunfei_domain`: 可填写 `4.0Ultra、generalv3.5、max-32k、generalv3、pro-128k、lite` - - `xunfei_spark_url`: 填写参考 [官方文档-请求地址](https://www.xfyun.cn/doc/spark/Web.html#_1-1-%E8%AF%B7%E6%B1%82%E5%9C%B0%E5%9D%80) 的说明 - -方式二:OpenAI兼容方式接入,配置如下: -```json -{ - "bot_type": "openai", - "model": "4.0Ultra", - "open_ai_api_base": "https://spark-api-open.xf-yun.com/v1", - "open_ai_api_key": "" -} -``` -- `bot_type`: OpenAI兼容方式 -- `model`: 可填写 `4.0Ultra、generalv3.5、max-32k、generalv3、pro-128k、lite` -- `open_ai_api_base`: 讯飞星火平台的 BASE URL -- `open_ai_api_key`: 讯飞星火平台的[APIPassword](https://console.xfyun.cn/services/bm3) ,因模型而已 -
- -
-ModelScope - -```json -{ - "bot_type": "modelscope", - "model": "Qwen/QwQ-32B", - "modelscope_api_key": "your_api_key", - "modelscope_base_url": "https://api-inference.modelscope.cn/v1/chat/completions", - "text_to_image": "MusePublic/489_ckpt_FLUX_1" -} -``` - -- `bot_type`: modelscope接口格式 -- `model`: 参考[模型列表](https://www.modelscope.cn/models?filter=inference_type&page=1) -- `modelscope_api_key`: 参考 [官方文档-访问令牌](https://modelscope.cn/docs/accounts/token) ,在 [控制台](https://modelscope.cn/my/myaccesstoken) -- `modelscope_base_url`: modelscope平台的 BASE URL -- `text_to_image`: 图像生成模型,参考[模型列表](https://www.modelscope.cn/models?filter=inference_type&page=1) -
- -
-Coding Plan - -Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 OpenAI 兼容方式接入: - -```json -{ - "bot_type": "openai", - "model": "模型名称", - "open_ai_api_base": "厂商 Coding Plan API Base", - "open_ai_api_key": "YOUR_API_KEY" -} -``` - -目前支持阿里云、MiniMax、智谱GLM、Kimi、火山引擎等厂商,各厂商详细配置请参考 [Coding Plan 文档](https://docs.cowagent.ai/models/coding-plan)。 -
- - -## 通道说明 - -以下对可接入通道的配置方式进行说明,应用通道代码在项目的 `channel/` 目录下。 - -支持同时可接入多个通道,配置时可通过逗号进行分割,例如 `"channel_type": "feishu,dingtalk"`。 - -
-1. Web - -项目启动后会默认运行Web控制台,配置如下: - -```json -{ - "channel_type": "web", - "web_port": 9899 -} -``` - -- `web_port`: 默认为 9899,可按需更改,需要服务器防火墙和安全组放行该端口 -- 如本地运行,启动后请访问 `http://localhost:9899/chat` ;如服务器运行,请访问 `http://ip:9899/chat` -> 注:请将上述 url 中的 ip 或者 port 替换为实际的值 -
- -
-2. Feishu - 飞书 - -飞书支持两种事件接收模式:WebSocket 长连接(推荐)和 Webhook。 - -**方式一:WebSocket 模式(推荐,无需公网 IP)** - -```json -{ - "channel_type": "feishu", - "feishu_app_id": "APP_ID", - "feishu_app_secret": "APP_SECRET", - "feishu_event_mode": "websocket" -} -``` - -**方式二:Webhook 模式(需要公网 IP)** - -```json -{ - "channel_type": "feishu", - "feishu_app_id": "APP_ID", - "feishu_app_secret": "APP_SECRET", - "feishu_token": "VERIFICATION_TOKEN", - "feishu_event_mode": "webhook", - "feishu_port": 9891 -} -``` - -- `feishu_event_mode`: 事件接收模式,`websocket`(推荐)或 `webhook` -- WebSocket 模式需安装依赖:`pip3 install lark-oapi` - -详细步骤和参数说明参考 [飞书接入](https://docs.cowagent.ai/channels/feishu) - -
- -
-3. DingTalk - 钉钉 - -钉钉需要在开放平台创建智能机器人应用,将以下配置填入 `config.json`: - -```json -{ - "channel_type": "dingtalk", - "dingtalk_client_id": "CLIENT_ID", - "dingtalk_client_secret": "CLIENT_SECRET" -} -``` -详细步骤和参数说明参考 [钉钉接入](https://docs.cowagent.ai/channels/dingtalk) -
- -
-4. WeCom Bot - 企微智能机器人 - -企微智能机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,配置简单: - -```json -{ - "channel_type": "wecom_bot", - "wecom_bot_id": "YOUR_BOT_ID", - "wecom_bot_secret": "YOUR_SECRET" -} -``` -详细步骤和参数说明参考 [企微智能机器人接入](https://docs.cowagent.ai/channels/wecom-bot) - -
- -
-5. QQ - QQ 机器人 - -QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支持 QQ 单聊、群聊和频道消息: - -```json -{ - "channel_type": "qq", - "qq_app_id": "YOUR_APP_ID", - "qq_app_secret": "YOUR_APP_SECRET" -} -``` -详细步骤和参数说明参考 [QQ 机器人接入](https://docs.cowagent.ai/channels/qq) - -
- -
-6. WeCom App - 企业微信应用 - -企业微信自建应用接入需在后台创建应用并启用消息回调,配置示例: - -```json -{ - "channel_type": "wechatcom_app", - "wechatcom_corp_id": "CORPID", - "wechatcomapp_token": "TOKEN", - "wechatcomapp_port": 9898, - "wechatcomapp_secret": "SECRET", - "wechatcomapp_agent_id": "AGENTID", - "wechatcomapp_aes_key": "AESKEY" -} -``` -详细步骤和参数说明参考 [企微自建应用接入](https://docs.cowagent.ai/channels/wecom) - -
- -
-7. WeChat MP - 微信公众号 - -本项目支持订阅号和服务号两种公众号,通过服务号(`wechatmp_service`)体验更佳。 - -**个人订阅号(wechatmp)** - -```json -{ - "channel_type": "wechatmp", - "wechatmp_token": "TOKEN", - "wechatmp_port": 80, - "wechatmp_app_id": "APPID", - "wechatmp_app_secret": "APPSECRET", - "wechatmp_aes_key": "" -} -``` - -**企业服务号(wechatmp_service)** - -```json -{ - "channel_type": "wechatmp_service", - "wechatmp_token": "TOKEN", - "wechatmp_port": 80, - "wechatmp_app_id": "APPID", - "wechatmp_app_secret": "APPSECRET", - "wechatmp_aes_key": "" -} -``` - -详细步骤和参数说明参考 [微信公众号接入](https://docs.cowagent.ai/channels/wechatmp) - -
- -
-8. Terminal - 终端 - -修改 `config.json` 中的 `channel_type` 字段: - -```json -{ - "channel_type": "terminal" -} -``` - -运行后可在终端与机器人进行对话。 - -
-
-# 🔗 相关项目 +## 🤖 Models -- [bot-on-anything](https://github.com/zhayujie/bot-on-anything):轻量和高可扩展的大模型应用框架,支持接入Slack, Telegram, Discord, Gmail等海外平台,可作为本项目的补充使用。 -- [AgentMesh](https://github.com/MinimalFuture/AgentMesh):开源的多智能体(Multi-Agent)框架,可以通过多智能体团队的协同来解决复杂问题。本项目基于该框架实现了[Agent插件](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/agent/README.md),可访问终端、浏览器、文件系统、搜索引擎 等各类工具,并实现了多智能体协同。 +CowAgent supports all mainstream LLM providers. **Chat, vision, image generation, ASR/TTS, and embeddings** can each be routed to a different vendor. Providers are configured directly in the Web console — no manual file editing required. +| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding | +| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | +| [Claude](https://docs.cowagent.ai/models/claude) | claude-fable-5 | ✅ | ✅ | | | | | +| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | +| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | +| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | +| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ | +| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | | +| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | | +| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | +| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | | +| [LinkAI](https://docs.cowagent.ai/models/linkai) | One key for 100+ models | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Custom](https://docs.cowagent.ai/models/custom) | Local models / third-party proxy | ✅ | | | | | | +> For details on each provider, see the [Models overview](https://docs.cowagent.ai/models/index). -# 🔎 常见问题 +
-FAQs: +## 💬 Channels -或直接在线咨询 [项目小助手](https://link-ai.tech/app/Kv2fXJcH) (知识库持续完善中,回复供参考) +A single Agent instance can serve multiple channels in parallel. Most channels can be onboarded right from the Web console. -# 🛠️ 开发 +| Channel | Text | Image | File | Voice | Group | +| --- | :-: | :-: | :-: | :-: | :-: | +| [Web Console](https://docs.cowagent.ai/channels/web) (default) | ✅ | ✅ | ✅ | ✅ | | +| [Telegram](https://docs.cowagent.ai/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](https://docs.cowagent.ai/channels/slack) | ✅ | ✅ | ✅ | | ✅ | +| [Discord](https://docs.cowagent.ai/channels/discord) | ✅ | ✅ | ✅ | | ✅ | +| [WeChat](https://docs.cowagent.ai/channels/weixin) | ✅ | ✅ | ✅ | ✅ | | +| [Feishu / Lark](https://docs.cowagent.ai/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [DingTalk](https://docs.cowagent.ai/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [WeCom Bot](https://docs.cowagent.ai/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [QQ](https://docs.cowagent.ai/channels/qq) | ✅ | ✅ | ✅ | | ✅ | +| [WeCom App](https://docs.cowagent.ai/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | +| [WeChat Customer Service](https://docs.cowagent.ai/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | | +| [WeChat Official Account](https://docs.cowagent.ai/channels/wechatmp) | ✅ | ✅ | | ✅ | | -欢迎接入更多应用通道,参考 [飞书通道](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/feishu/feishu_channel.py) 新增自定义通道,实现接收和发送消息逻辑即可完成接入。 同时欢迎贡献新的Skills,参考 [Skill创造器说明](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/skills/skill-creator/SKILL.md)。 +> See the [Channels overview](https://docs.cowagent.ai/channels/index) for setup details. -# ✉ 联系 +CowAgent Web Console -欢迎提交PR、Issues进行反馈,以及通过 🌟Star 支持并关注项目更新。项目运行遇到问题可以查看 [常见问题列表](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) ,以及前往 [Issues](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中搜索。个人开发者可加入开源交流群参与更多讨论,企业用户可联系[产品客服](https://cdn.link-ai.tech/portal/linkai-customer-service.png)咨询。 +*The Web console is the default channel and the unified entry point to configure models, channels, skills, memory, and more.* -# 🌟 贡献者 +
-![cow contributors](https://contrib.rocks/image?repo=zhayujie/chatgpt-on-wechat&max=1000) +## 🧠 Memory & Knowledge Base + +**Long-term memory** uses a three-tier architecture: conversation context (short-term) → daily memory (mid-term) → MEMORY.md (long-term). A nightly **Deep Dream** pass distills scattered memories into refined long-term entries and a narrative journal. See [Long-term Memory](https://docs.cowagent.ai/memory/index) · [Deep Dream](https://docs.cowagent.ai/memory/deep-dream). + +**Personal knowledge base** complements the time-ordered memory by organizing structured knowledge **by topic**. The Agent automatically curates valuable information from conversations, maintains cross-references and indexes, and the Web console offers an interactive knowledge-graph view. See [Personal Knowledge Base](https://docs.cowagent.ai/knowledge/index). + + + + + + +
+ Long-term Memory +

Long-term Memory · Three-tier architecture + Deep Dream

+
+ Personal Knowledge Base +

Knowledge Base · Auto-curated Markdown wiki

+
+ +
+ +## 🔧 Tools & Skills + +**Tools** are atomic capabilities the Agent uses to interact with system resources. **Skills** are higher-level workflows defined by a manifest file that compose multiple tools to accomplish complex tasks. + +### Tool System + +**Built-in tools** cover file I/O (`read` / `write` / `edit` / `ls`), terminal (`bash`), file sending (`send`), memory retrieval (`memory`), environment variables (`env_config`), web fetching (`web_fetch`), scheduling (`scheduler`), web search (`web_search`), vision (`vision`), and browser automation (`browser`). + +**MCP protocol** integrates the open ecosystem of [Model Context Protocol](https://modelcontextprotocol.io) servers. A single `mcp.json` is enough — supports stdio / SSE transports, hot reload, and zero-code integration. + +Learn more: [Tools overview](https://docs.cowagent.ai/tools/index) · [MCP integration](https://docs.cowagent.ai/tools/mcp). + +### Skills System + +- **[Skill Hub](https://skills.cowagent.ai/)** — open skill marketplace: browse, search, install in one click +- **GitHub / ClawHub / URL and more** — install skills from any source +- **Conversational authoring** — generate custom skills through dialogue with `skill-creator`; turn any workflow or third-party API into a reusable skill + +```bash +/skill list # list installed skills +/skill search # search the marketplace +/skill install # one-click install +``` + +Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creating Skills](https://docs.cowagent.ai/skills/create). + +
+ +## 🏷 Changelog + +> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements. + +> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — Self-Evolution, Web console upgrades (message management, parallel sessions), cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus), Python 3.13 support. + +> **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — Internationalization, new channels (Telegram, Discord, Slack, WeChat Customer Service), CLI interaction upgrades, streamlined one-line install, MCP Streamable HTTP support, new models (claude-opus-4-8, MiMo). + +> **2026.05.22:** [v2.0.9](https://github.com/zhayujie/CowAgent/releases/tag/2.0.9) — Model management, MCP protocol support, persistent browser sessions, new models (gpt-5.5, gemini-3.5-flash, qwen3.7-max), deployment hardening. + +> **2026.05.06:** [v2.0.8](https://github.com/zhayujie/CowAgent/releases/tag/2.0.8) — Feishu channel overhaul (voice, streaming, QR onboarding), DeepSeek V4 and Baidu Qianfan support, scheduler tool upgrades. + +> **2026.04.22:** [v2.0.7](https://github.com/zhayujie/CowAgent/releases/tag/2.0.7) — Built-in image generation (GPT Image 2, Nano Banana), new models (Kimi K2.6, Claude Opus 4.7, GLM 5.1), memory and knowledge enhancements. + +> **2026.04.14:** [v2.0.6](https://github.com/zhayujie/CowAgent/releases/tag/2.0.6) — Knowledge base, Deep Dream memory distillation, smart context compression, multi-session Web console. + +> **2026.04.01:** [v2.0.5](https://github.com/zhayujie/CowAgent/releases/tag/2.0.5) — Cow CLI, Skill Hub open source, browser tool, WeCom Bot QR onboarding. + +> **2026.02.03:** [v2.0.0](https://github.com/zhayujie/CowAgent/releases/tag/2.0.0) — Major upgrade to a super Agent assistant with multi-step task planning, long-term memory, and the Skills framework. + +Full history: [Release Notes](https://docs.cowagent.ai/releases/overview) + +
+ +## 🤝 Community & Support + +[File an issue](https://github.com/zhayujie/CowAgent/issues) on GitHub, or scan the QR code below to join our WeChat community: + + + +
+ +## 🔗 Related Projects + +- **[Cow Skill Hub](https://github.com/zhayujie/cow-skill-hub)** — open skill marketplace for AI Agents; works with CowAgent, OpenClaw, Claude Code, and more +- **[bot-on-anything](https://github.com/zhayujie/bot-on-anything)** — lightweight LLM application framework with integrations for Slack, Telegram, Discord, Gmail, and more +- **[AgentMesh](https://github.com/MinimalFuture/AgentMesh)** — open-source multi-agent framework for solving complex problems through team collaboration + +
+ +## 🏢 Enterprise Services + +[**LinkAI**](https://link-ai.tech/) is an all-in-one AI Agent platform for enterprises and developers, offering managed hosting and enterprise-grade support for CowAgent: + +- **🚀 Zero-deployment hosted runtime** — spin up a [CowAgent online assistant](https://link-ai.tech/cowagent/create) in under a minute, no server required +- **🧠 Agent infrastructure** — unified access to LLMs, knowledge bases, databases, skills, and workflows; plug-and-play building blocks that extend what CowAgent can do +- **🏢 Team & enterprise features** — workspaces, role-based access, audit logs, and private deployment for production use cases + +For enterprise inquiries: sales@simple-future.tech or [scan the QR code](https://cdn.link-ai.tech/consultant.jpg) to reach our team on WeChat. + +
+ +## 🛠️ Development & Contributing + +All kinds of contributions are welcome — new features, bug fixes, performance improvements, docs, or sharing your own skills on the [Skill Hub](https://skills.cowagent.ai/submit). See [CONTRIBUTING.md](/CONTRIBUTING.md) to get started, then open an Issue to discuss or send a PR directly. + +⭐ Star the project to show your support, and Watch → Custom → Releases to get notified of new versions. PRs and Issues are always welcome. + +## 🌟 Contributors + +![cow contributors](https://contrib.rocks/image?repo=zhayujie/CowAgent&max=1000) + +
+ +## ⚠️ Disclaimer + +1. This project is licensed under the [MIT License](/LICENSE) and is intended for technical research and learning. You are responsible for complying with applicable laws and regulations in your jurisdiction; the maintainers assume no liability for any consequences arising from use of this project. +2. **Cost & safety:** Agent mode consumes substantially more tokens than regular chat — pick models that balance quality and cost. The Agent has access to your local operating system, so only deploy it in trusted environments. +3. CowAgent is a pure open-source project and does not participate in, authorize, or issue any cryptocurrency. + +
+ +## 📌 Project Renaming Notice + +This project was previously named `chatgpt-on-wechat` and is now officially **CowAgent**. The old GitHub URL redirects automatically; existing users may optionally run `git remote set-url origin https://github.com/zhayujie/CowAgent.git` to update the local remote. diff --git a/agent/chat/service.py b/agent/chat/service.py index d3712dbf..66e91792 100644 --- a/agent/chat/service.py +++ b/agent/chat/service.py @@ -49,6 +49,16 @@ class ChatService: agent.model.channel_type = channel_type or "" agent.model.session_id = session_id or "" + # Build a context so context-aware tools (e.g. scheduler) can resolve the + # receiver/session. This streaming path bypasses agent_bridge.agent_reply, + # so the attach step that normally happens there must be done here too. + context = self._build_context(query, session_id, channel_type) + self._attach_context_aware_tools(agent, context) + + # Mark this session as mid-run so the self-evolution idle scan does not + # fire concurrently when a single turn runs longer than idle_minutes. + self._mark_run_active(agent, True) + # State shared between the event callback and this method state = _StreamState() @@ -57,7 +67,16 @@ class ChatService: event_type = event.get("type") data = event.get("data", {}) - if event_type == "message_update": + if event_type == "reasoning_update": + delta = data.get("delta", "") + if delta: + send_chunk_fn({ + "chunk_type": "reasoning", + "delta": delta, + "segment_id": state.segment_id, + }) + + elif event_type == "message_update": # Incremental text delta delta = data.get("delta", "") if delta: @@ -75,6 +94,23 @@ class ChatService: # a new segment; collect tool results until turn_end. state.pending_tool_results = [] + elif event_type == "file_to_send": + url = data.get("url") or "" + if url: + fname = data.get("file_name") or "file" + ft = data.get("file_type") or "file" + if ft == "image": + link = f"![{fname}]({url})" + else: + link = f"[{fname}]({url})" + send_chunk_fn({ + "chunk_type": "content", + "delta": "\n\n" + link + "\n\n", + "segment_id": state.segment_id, + }) + # Remove url so the model won't repeat it in its reply + data.pop("url", None) + elif event_type == "tool_execution_start": # Notify the client that a tool is about to run (with its input args) tool_name = data.get("tool_name", "") @@ -145,6 +181,12 @@ class ChatService: from agent.protocol.agent_stream import AgentStreamExecutor + # Register a cancel token so /cancel can abort this in-flight run. + # IM channels key on session_id (no per-turn request_id here). + from agent.protocol import get_cancel_registry + registry = get_cancel_registry() + cancel_event = registry.register(session_id, session_id=session_id) if session_id else None + executor = AgentStreamExecutor( agent=agent, model=agent.model, @@ -154,6 +196,7 @@ class ChatService: on_event=on_event, messages=messages_copy, max_context_turns=max_context_turns, + cancel_event=cancel_event, ) try: @@ -165,11 +208,66 @@ class ChatService: agent.messages.clear() logger.info("[ChatService] Cleared agent message history after executor recovery") raise + finally: + # Clear the mid-run flag so idle scans can review this session again. + self._mark_run_active(agent, False) + # Release cancel token to keep the registry bounded. + if session_id: + try: + registry.unregister(session_id) + except Exception: + pass - # Append only the NEW messages from this execution (thread-safe) + # Sync executor messages back to agent (thread-safe). + # The executor may have trimmed context, making its list shorter than + # original_length. In that case we must replace entirely — just + # appending would leave stale pre-trim messages in agent.messages + # and cause the same trim to fire on every subsequent request. with agent.messages_lock: - new_messages = executor.messages[original_length:] - agent.messages.extend(new_messages) + trimmed = len(executor.messages) < original_length + if trimmed: + # Context was trimmed: the executor appended the new user + # query *before* trimming, so the new messages (user + + # assistant + tools) sit at the tail of the trimmed list. + # We cannot simply slice at original_length (it exceeds the + # list length). Instead, count how many messages the + # executor added on top of the post-trim baseline. + # + # Timeline inside executor.run_stream: + # 1. messages had `original_length` items + # 2. append user query → original_length + 1 + # 3. _trim_messages() → some smaller number (includes the + # user query because it belongs to the last turn) + # 4. LLM replies / tool calls appended + # + # The user query message is always the first message of the + # last turn (it cannot be trimmed away), so we locate it to + # find where "new" messages begin. + new_start = original_length # fallback + for idx in range(len(executor.messages) - 1, -1, -1): + msg = executor.messages[idx] + if msg.get("role") == "user": + content = msg.get("content", []) + is_user_query = False + if isinstance(content, list): + has_text = any( + isinstance(b, dict) and b.get("type") == "text" + for b in content + ) + has_tool_result = any( + isinstance(b, dict) and b.get("type") == "tool_result" + for b in content + ) + is_user_query = has_text and not has_tool_result + elif isinstance(content, str): + is_user_query = True + if is_user_query: + new_start = idx + break + new_messages = list(executor.messages[new_start:]) + else: + new_messages = list(executor.messages[original_length:]) + agent.messages = list(executor.messages) # Persist new messages to SQLite so they survive restarts and # can be queried via the HISTORY interface. @@ -182,10 +280,68 @@ class ChatService: # Execute post-process tools agent._execute_post_process_tools() + # Record this user turn for the self-evolution idle trigger. This + # streaming path bypasses agent_bridge.agent_reply, so the activity must + # be noted here, otherwise idle scans never see any signal to evolve. + self._note_evolution_turn(agent, context) + logger.info(f"[ChatService] Agent run completed: session={session_id}") + @staticmethod + def _build_context(query: str, session_id: str, channel_type: str): + """Build a Context for tool resolution on the streaming chat path. + + receiver falls back to session_id; the scheduler's delivery keys on + session_id as the receiver. + """ + from bridge.context import Context, ContextType + # Pass an explicit kwargs dict: Context's default kwargs is a shared + # mutable default, so omitting it would leak fields across sessions. + ctx = Context(ContextType.TEXT, query, kwargs={}) + ctx["session_id"] = session_id + ctx["receiver"] = session_id + ctx["isgroup"] = False + ctx["channel_type"] = channel_type or "" + return ctx + + @staticmethod + def _attach_context_aware_tools(agent, context): + """Attach the current context to tools that need it (scheduler).""" + try: + if not (context and getattr(agent, "tools", None)): + return + for tool in agent.tools: + if tool.name == "scheduler": + from agent.tools.scheduler.integration import attach_scheduler_to_tool + attach_scheduler_to_tool(tool, context) + break + except Exception as e: + logger.warning(f"[ChatService] Failed to attach context to scheduler: {e}") + + @staticmethod + def _mark_run_active(agent, active): + """Toggle the self-evolution mid-run flag for this session's agent.""" + try: + from agent.evolution.trigger import mark_run_active + mark_run_active(agent, active) + except Exception: + pass + + @staticmethod + def _note_evolution_turn(agent, context): + """Record a user turn so the self-evolution idle trigger has signal.""" + try: + from agent.evolution.trigger import note_user_turn + ch = (context.get("channel_type") or "") if context else "" + rcv = (context.get("receiver") or "") if context else "" + is_group = bool(context.get("isgroup")) if context else False + # Only single chats get a proactive push target; group push is noisy. + note_user_turn(agent, channel_type=ch, receiver=(rcv if not is_group else "")) + except Exception: + pass + @staticmethod def _persist_messages(session_id: str, new_messages: list, channel_type: str = ""): try: diff --git a/agent/chat/session_service.py b/agent/chat/session_service.py new file mode 100644 index 00000000..86bf1535 --- /dev/null +++ b/agent/chat/session_service.py @@ -0,0 +1,241 @@ +""" +SessionService - Manages multi-session lifecycle for both web channel and cloud client. + +Provides a unified interface for listing, deleting, renaming, clearing context, +and generating AI titles for conversation sessions. Backed by ConversationStore +(SQLite) and AgentBridge (in-memory agent instances). +""" + +import re +from typing import Optional + +from common.log import logger + + +def _truncate_fallback_title(user_message: str, max_len: int = 30) -> str: + """Pick the first non-empty line of the user message and truncate it.""" + if not user_message: + return "New Chat" + first_line = "" + for line in user_message.splitlines(): + line = line.strip() + if line: + first_line = line + break + if not first_line: + return "New Chat" + if len(first_line) > max_len: + first_line = first_line[:max_len].rstrip() + "..." + return first_line + + +def generate_session_title(user_message: str, assistant_reply: str = "") -> str: + """ + Generate a short session title by calling the current bot's reply_text. + Falls back to the first line of the user message if the LLM call fails + or returns an obvious error sentinel. + """ + fallback = _truncate_fallback_title(user_message) + try: + from bridge.bridge import Bridge + from models.session_manager import Session + bot = Bridge().get_bot("chat") + + prompt_parts = [f"User: {user_message[:300]}"] + if assistant_reply: + prompt_parts.append(f"Assistant: {assistant_reply[:300]}") + + session = Session("__title_gen__", system_prompt="") + session.messages = [ + {"role": "user", "content": ( + "Generate a very short title (max 15 characters for Chinese, max 6 words for English) " + "summarizing this conversation. Return ONLY the title text, nothing else.\n\n" + + "\n".join(prompt_parts) + )} + ] + + result = bot.reply_text(session) or {} + # When bots fail (network error, auth error, rate limit, etc.) they + # typically return completion_tokens=0 with a sentinel content like + # "请再问我一次吧" / "我现在有点累了". Treat that as failure. + completion_tokens = result.get("completion_tokens", 0) or 0 + raw = (result.get("content") or "").strip() + if completion_tokens <= 0: + logger.warning( + f"[SessionService] Title generation got empty completion " + f"(completion_tokens={completion_tokens}, content='{raw[:50]}'), " + f"using fallback") + return fallback + + title = re.sub(r'.*?', '', raw, flags=re.DOTALL).strip().strip('"\'') + logger.info(f"[SessionService] Title generation result: '{title}' (len={len(title)})") + if title and len(title) <= 50: + return title + except Exception as e: + logger.warning(f"[SessionService] Title generation failed: {e}") + return fallback + + +class SessionService: + """ + High-level service for session lifecycle management. + + Usage: + svc = SessionService() + result = svc.dispatch("list", {"channel_type": "web", "page": 1}) + """ + + def _get_store(self): + from agent.memory import get_conversation_store + return get_conversation_store() + + def _remove_agent(self, session_id: str): + """Remove the in-memory Agent instance for a session if it exists.""" + try: + from bridge.bridge import Bridge + ab = Bridge().get_agent_bridge() + if session_id in ab.agents: + del ab.agents[session_id] + logger.info(f"[SessionService] Removed agent instance: {session_id}") + except Exception: + pass + + @staticmethod + def _normalize_sid(session_id: str) -> str: + if session_id and not session_id.startswith("session_"): + return f"session_{session_id}" + return session_id + + # ------------------------------------------------------------------ + # actions + # ------------------------------------------------------------------ + def list_sessions(self, channel_type: Optional[str] = None, + page: int = 1, page_size: int = 50) -> dict: + store = self._get_store() + return store.list_sessions( + channel_type=channel_type, + page=page, + page_size=page_size, + ) + + def delete_session(self, session_id: str) -> None: + if not session_id: + raise ValueError("session_id required") + session_id = self._normalize_sid(session_id) + + store = self._get_store() + store.clear_session(session_id) + self._remove_agent(session_id) + logger.info(f"[SessionService] Session deleted: {session_id}") + + def rename_session(self, session_id: str, title: str) -> None: + if not session_id: + raise ValueError("session_id required") + if not title: + raise ValueError("title required") + session_id = self._normalize_sid(session_id) + + store = self._get_store() + found = store.rename_session(session_id, title) + if not found: + raise ValueError("session not found") + + def clear_context(self, session_id: str) -> int: + """ + Set context boundary. Returns the new context_start_seq value. + """ + if not session_id: + raise ValueError("session_id required") + session_id = self._normalize_sid(session_id) + + store = self._get_store() + new_seq = store.clear_context(session_id) + self._remove_agent(session_id) + return new_seq + + def gen_title(self, session_id: str, user_message: str, + assistant_reply: str = "") -> str: + """ + Generate an AI title and persist it. Returns the generated title. + """ + if not session_id: + raise ValueError("session_id required") + if not user_message: + raise ValueError("user_message required") + session_id = self._normalize_sid(session_id) + + title = generate_session_title(user_message, assistant_reply) + + store = self._get_store() + updated = store.rename_session(session_id, title) + logger.info(f"[SessionService] Title set: sid={session_id}, " + f"title='{title}', db_updated={updated}") + return title + + # ------------------------------------------------------------------ + # dispatch — single entry point for protocol messages + # ------------------------------------------------------------------ + def dispatch(self, action: str, payload: Optional[dict] = None) -> dict: + """ + Dispatch a session management action and return a protocol-compatible + response dict. + + Action names use a ``*_session`` / session-prefixed convention so they + can coexist with history actions (e.g. ``query``) on the same HISTORY + message channel without ambiguity. + + Supported actions: + - list_sessions: list sessions with pagination + - delete_session: delete a session + - rename_session: rename a session title + - clear_context: set context boundary + - generate_title: AI-generate a session title + + :param action: one of the above action names + :param payload: action-specific payload + :return: dict with action, code, message, payload + """ + payload = payload or {} + try: + if action == "list_sessions": + result = self.list_sessions( + channel_type=payload.get("channel_type"), + page=int(payload.get("page", 1)), + page_size=int(payload.get("page_size", 50)), + ) + return {"action": action, "code": 200, "message": "success", "payload": result} + + elif action == "delete_session": + self.delete_session(payload.get("session_id", "")) + return {"action": action, "code": 200, "message": "success", "payload": None} + + elif action == "rename_session": + self.rename_session( + payload.get("session_id", ""), + payload.get("title", "").strip(), + ) + return {"action": action, "code": 200, "message": "success", "payload": None} + + elif action == "clear_context": + new_seq = self.clear_context(payload.get("session_id", "")) + return {"action": action, "code": 200, "message": "success", + "payload": {"context_start_seq": new_seq}} + + elif action == "generate_title": + title = self.gen_title( + payload.get("session_id", ""), + payload.get("user_message", ""), + payload.get("assistant_reply", ""), + ) + return {"action": action, "code": 200, "message": "success", + "payload": {"title": title}} + + else: + return {"action": action, "code": 400, + "message": f"unknown action: {action}", "payload": None} + + except ValueError as e: + return {"action": action, "code": 400, "message": str(e), "payload": None} + except Exception as e: + logger.error(f"[SessionService] dispatch error: action={action}, error={e}") + return {"action": action, "code": 500, "message": str(e), "payload": None} diff --git a/agent/evolution/__init__.py b/agent/evolution/__init__.py new file mode 100644 index 00000000..f9f665f3 --- /dev/null +++ b/agent/evolution/__init__.py @@ -0,0 +1,19 @@ +""" +Self-evolution subsystem for CowAgent. + +Runs a lightweight, isolated review pass after a conversation goes idle to +decide whether anything is worth durably learning (memory / skill) or whether +an unfinished task can be pushed forward. Conservative by design: most +conversations should produce no change at all. + +Public entry points: + from agent.evolution import get_evolution_config + from agent.evolution.trigger import start_evolution_trigger, note_user_turn +""" + +from agent.evolution.config import EvolutionConfig, get_evolution_config + +__all__ = [ + "EvolutionConfig", + "get_evolution_config", +] diff --git a/agent/evolution/backup.py b/agent/evolution/backup.py new file mode 100644 index 00000000..4f294b96 --- /dev/null +++ b/agent/evolution/backup.py @@ -0,0 +1,102 @@ +"""File backup / rollback support for self-evolution. + +Before the evolution agent edits MEMORY.md or a skill file, we snapshot the +current state into ``memory/.evolution_backups//`` so a later "undo" +can restore it. File-level restore only — simple and reliable. +""" + +from __future__ import annotations + +import json +import shutil +import time +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from common.log import logger + +_BACKUP_DIRNAME = ".evolution_backups" +_MANIFEST_NAME = "manifest.json" +# Keep only the most recent N backups to bound disk usage. +_MAX_BACKUPS = 10 + + +def _backups_root(workspace_dir: Path) -> Path: + return Path(workspace_dir) / "memory" / _BACKUP_DIRNAME + + +def create_backup(workspace_dir: Path, files: List[Path]) -> Optional[str]: + """Snapshot ``files`` (those that exist) under a new backup id. + + Returns the backup_id, or None when there is nothing to back up. + """ + existing = [Path(f) for f in files if Path(f).exists()] + if not existing: + return None + + backup_id = datetime.now().strftime("%Y%m%d-%H%M%S-") + str(int(time.time() * 1000) % 1000) + root = _backups_root(workspace_dir) + target = root / backup_id + try: + target.mkdir(parents=True, exist_ok=True) + ws = Path(workspace_dir) + manifest = [] + for idx, src in enumerate(existing): + # Store under a flat index plus the relative path so restore knows + # where it came from, even for nested skill files. + try: + rel = str(src.relative_to(ws)) + except ValueError: + rel = src.name + dst = target / f"{idx}.bak" + shutil.copy2(src, dst) + manifest.append({"rel": rel, "bak": f"{idx}.bak"}) + (target / _MANIFEST_NAME).write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8" + ) + _prune_old_backups(root) + # Caller logs a combined backup+review line; keep this at debug. + logger.debug(f"[Evolution] Created backup {backup_id} ({len(manifest)} file(s))") + return backup_id + except Exception as e: + logger.warning(f"[Evolution] Failed to create backup: {e}") + return None + + +def restore_backup(workspace_dir: Path, backup_id: str) -> bool: + """Restore all files captured under ``backup_id``. Returns success.""" + if not backup_id: + return False + target = _backups_root(workspace_dir) / backup_id + manifest_path = target / _MANIFEST_NAME + if not manifest_path.exists(): + logger.warning(f"[Evolution] Backup not found: {backup_id}") + return False + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + ws = Path(workspace_dir) + for entry in manifest: + bak = target / entry["bak"] + dst = ws / entry["rel"] + if bak.exists(): + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(bak, dst) + logger.info(f"[Evolution] Restored backup {backup_id} ({len(manifest)} file(s))") + return True + except Exception as e: + logger.warning(f"[Evolution] Failed to restore backup {backup_id}: {e}") + return False + + +def _prune_old_backups(root: Path) -> None: + """Drop the oldest backups beyond _MAX_BACKUPS (sorted by name = chronological).""" + try: + dirs = sorted( + [d for d in root.iterdir() if d.is_dir()], + key=lambda p: p.name, + ) + for old in dirs[:-_MAX_BACKUPS]: + shutil.rmtree(old, ignore_errors=True) + except Exception as e: + logger.debug(f"[Evolution] Backup prune skipped: {e}") diff --git a/agent/evolution/config.py b/agent/evolution/config.py new file mode 100644 index 00000000..948876fb --- /dev/null +++ b/agent/evolution/config.py @@ -0,0 +1,76 @@ +"""Configuration for the self-evolution subsystem. + +Reads flat ``self_evolution_*`` keys from config.json. All fields have safe +defaults so the feature degrades gracefully when keys are absent. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +# Defaults — conservative (see executor module docstring). Disabled by default +# until release; enable via ``self_evolution_enabled``. +DEFAULT_ENABLED = False +DEFAULT_IDLE_MINUTES = 10 +DEFAULT_MIN_TURNS = 6 +# Max review steps for the isolated evolution agent. Kept small (not exposed as +# config): the review is meant to be cheap and focused, not a long autonomous run. +DEFAULT_MAX_STEPS = 12 + + +@dataclass +class EvolutionConfig: + """Resolved self-evolution settings.""" + + enabled: bool = DEFAULT_ENABLED + idle_minutes: int = DEFAULT_IDLE_MINUTES + min_turns: int = DEFAULT_MIN_TURNS + max_steps: int = DEFAULT_MAX_STEPS + + @property + def idle_seconds(self) -> int: + return max(60, self.idle_minutes * 60) + + +def _as_bool(value: Any, fallback: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + v = value.strip().lower() + if v in ("true", "1", "yes", "on"): + return True + if v in ("false", "0", "no", "off"): + return False + return fallback + + +def _as_pos_int(value: Any, fallback: int) -> int: + try: + n = int(value) + return n if n > 0 else fallback + except (TypeError, ValueError): + return fallback + + +def get_evolution_config() -> EvolutionConfig: + """Build EvolutionConfig from the live config.json ``self_evolution_*`` keys.""" + try: + from config import conf + c = conf() + except Exception: + c = {} + + def _get(key, default): + try: + return c.get(key, default) + except Exception: + return default + + return EvolutionConfig( + enabled=_as_bool(_get("self_evolution_enabled", None), DEFAULT_ENABLED), + idle_minutes=_as_pos_int(_get("self_evolution_idle_minutes", None), DEFAULT_IDLE_MINUTES), + min_turns=_as_pos_int(_get("self_evolution_min_turns", None), DEFAULT_MIN_TURNS), + max_steps=DEFAULT_MAX_STEPS, + ) diff --git a/agent/evolution/executor.py b/agent/evolution/executor.py new file mode 100644 index 00000000..63380343 --- /dev/null +++ b/agent/evolution/executor.py @@ -0,0 +1,551 @@ +"""Self-evolution executor. + +Runs an isolated review agent over an idle conversation's transcript and, if a +clear signal is found, lets it edit memory / skills via a restricted toolset. +Conservative by design: most runs return ``[SILENT]`` and change nothing. + +Flow: + 1. Build a transcript from the session's new (since last pass) messages. + 2. Snapshot MEMORY.md + daily file + editable skills (for undo) -> backup_id. + 3. Run an isolated agent (same model, restricted tools, evolution prompt). + 4. If output is [SILENT], or no workspace file actually changed -> done. + 5. Otherwise -> record to the evolution log, inject an [EVOLUTION] note into + the user session (so the main agent can honor "undo"), and push the + summary to the user's channel. + +Reuses existing infrastructure (AgentBridge.create_agent, ToolManager, +remember_scheduled_output, channel_factory) rather than introducing a fork. +""" + +from __future__ import annotations + +import re +import threading +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from common.log import logger + +from agent.evolution.backup import create_backup +from agent.evolution.config import get_evolution_config +from agent.evolution.prompts import ( + EVOLUTION_MARKER, + EVOLUTION_SYSTEM_PROMPT, + SILENT_TOKEN, + build_review_user_message, +) +from agent.evolution.record import append_session_evolution + +# Tools the isolated evolution agent is allowed to use. Everything else is +# withheld so a review pass can only read context, run workspace scripts, and +# edit memory/skill files. bash is needed by skill-creator's init script and is +# confined to the workspace by _BashWorkspaceGuard. +_ALLOWED_TOOLS = {"read", "write", "edit", "ls", "bash", "memory_search", "memory_get"} + +# Cap concurrent evolution passes so a burst of idle sessions can't spawn many +# background model runs at once. Extra sessions simply wait for the next scan. +_MAX_CONCURRENT = 2 +_running_lock = threading.Lock() +_running_count = 0 + + +def _builtin_skill_names() -> set: + """Names of skills shipped with the product (project-root ``skills/``). + + These are protected: the evolution agent must never edit them, even though + a same-named copy exists in the workspace at runtime. The project dir is the + authoritative list of what counts as built-in. + """ + try: + # executor.py -> agent/evolution -> agent -> project root + project_root = Path(__file__).resolve().parents[2] + builtin_dir = project_root / "skills" + if not builtin_dir.is_dir(): + return set() + names = set() + for entry in builtin_dir.iterdir(): + if entry.is_dir() and not entry.name.startswith("."): + names.add(entry.name) + return names + except Exception: + return set() + + +def _build_transcript(messages: List[dict], max_chars: int = 12000) -> str: + """Render the session messages into a compact text transcript.""" + lines: List[str] = [] + for msg in messages: + role = msg.get("role", "") + if role not in ("user", "assistant"): + continue + content = msg.get("content", "") + text = _extract_text(content) + if not text.strip(): + continue + speaker = "User" if role == "user" else "Assistant" + lines.append(f"{speaker}: {text.strip()}") + transcript = "\n".join(lines) + # Keep the most RECENT context if oversized (tail is most relevant). + if len(transcript) > max_chars: + transcript = "...(earlier omitted)...\n" + transcript[-max_chars:] + return transcript + + +def _extract_text(content) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + parts.append(block.get("text", "")) + elif isinstance(block, str): + parts.append(block) + return "\n".join(parts) + return "" + + +def _select_tools(all_tools: list) -> list: + return [t for t in all_tools if getattr(t, "name", None) in _ALLOWED_TOOLS] + + +# Tools whose writes must be confined to the workspace during evolution. +_WRITE_TOOLS = {"write", "edit"} + + +class _WorkspaceWriteGuard: + """Wraps a write/edit tool so it can ONLY write inside the workspace. + + Hard engineering guard (not prompt-based): any write resolving outside the + workspace — e.g. the project's bundled ``skills/`` dir — is rejected. This + protects built-in skills regardless of what the model attempts. + """ + + def __init__(self, inner, workspace_dir: str): + self._inner = inner + self._ws = Path(workspace_dir).resolve() + # Mirror the attributes the agent runtime reads off a tool. + self.name = inner.name + self.description = inner.description + self.params = inner.params + + def __getattr__(self, item): + return getattr(self._inner, item) + + def execute_tool(self, params): + # The agent runtime calls execute_tool (not execute); route it through + # our guarded execute so the path checks always run. + try: + return self.execute(params) + except Exception as e: + logger.error(f"[Evolution] guarded tool error: {e}") + from agent.tools.base_tool import ToolResult + return ToolResult.fail(f"Error: {e}") + + def execute(self, args): + path = (args.get("path") or "").strip() + if path: + try: + resolved = Path(self._inner._resolve_path(path)).resolve() + from agent.tools.base_tool import ToolResult + # Confine writes to the workspace. This protects the product's + # bundled skills (which live outside the workspace) from ever + # being modified, no matter what path the model attempts. + if self._ws not in resolved.parents and resolved != self._ws: + return ToolResult.fail( + "Error: evolution may only write inside the workspace; " + f"path '{path}' is outside and was blocked." + ) + except Exception: + pass + return self._inner.execute(args) + + +class _BashWorkspaceGuard: + """Wraps the bash tool so evolution can only run commands inside the + workspace. + + Evolution needs bash for skill-creator's init script, but it runs + unattended in the background, so a raw shell is too broad. This guard: + - forces the command to execute with cwd = workspace, + - rejects commands that reference an absolute path or ``..`` segment + pointing OUTSIDE the workspace (the common ways to escape it). + It is a coarse textual check, not a sandbox — paired with the model's + instruction to only run skill-creator scripts, it keeps writes local. + """ + + def __init__(self, inner, workspace_dir: str): + self._inner = inner + self._ws = Path(workspace_dir).resolve() + # Pin the shell's working directory to the workspace. + try: + self._inner.cwd = str(self._ws) + except Exception: + pass + self.name = inner.name + self.description = inner.description + self.params = inner.params + + def __getattr__(self, item): + return getattr(self._inner, item) + + def execute_tool(self, params): + try: + return self.execute(params) + except Exception as e: + logger.error(f"[Evolution] guarded bash error: {e}") + from agent.tools.base_tool import ToolResult + return ToolResult.fail(f"Error: {e}") + + def _escapes_workspace(self, command: str) -> bool: + # Absolute paths that are not under the workspace. + for tok in re.findall(r'(?:^|\s)(/[^\s\'";|&]+)', command): + try: + resolved = Path(tok).resolve() + except Exception: + continue + if self._ws != resolved and self._ws not in resolved.parents: + return True + # Parent-dir traversal that climbs above the workspace. + for tok in re.findall(r'[^\s\'";|&]*\.\.[^\s\'";|&]*', command): + try: + resolved = (self._ws / tok).resolve() + except Exception: + continue + if self._ws != resolved and self._ws not in resolved.parents: + return True + return False + + def execute(self, args): + from agent.tools.base_tool import ToolResult + command = (args.get("command") or "").strip() + if command and self._escapes_workspace(command): + return ToolResult.fail( + "Error: evolution may only run commands inside the workspace; " + "this command references a path outside it and was blocked." + ) + return self._inner.execute(args) + + +def _guard_tools(tools: list, workspace_dir: str) -> list: + """Wrap write/edit/bash tools with workspace guards; leave others as-is.""" + guarded = [] + for t in tools: + name = getattr(t, "name", None) + if name in _WRITE_TOOLS: + guarded.append(_WorkspaceWriteGuard(t, workspace_dir)) + elif name == "bash": + guarded.append(_BashWorkspaceGuard(t, workspace_dir)) + else: + guarded.append(t) + return guarded + + +# Workspace subtrees worth watching for evolution-induced changes. AGENT.md is +# watched too: evolution may rarely refine the assistant's persona/style there. +_WATCH_SUBDIRS = ("MEMORY.md", "AGENT.md", "skills", "knowledge", "output") +# Subpaths under memory/ to ignore: evolution's own bookkeeping + the nightly +# dream diary, none of which count as a user-facing change signal. +_MEMORY_IGNORE = (".evolution_backups", "dreams", "evolution") +# Files the skill subsystem maintains automatically (the enable/disable index). +# Not an evolution result, so a rewrite must not count as a change signal. +_WATCH_IGNORE_NAMES = ("skills_config.json",) + + +def _workspace_snapshot(workspace_dir) -> dict: + """Map relative path -> (mtime, size) for watched files. Cheap, no reads.""" + ws = Path(workspace_dir) + snap: dict = {} + for name in _WATCH_SUBDIRS: + root = ws / name + if root.is_file(): + try: + st = root.stat() + snap[name] = (st.st_mtime, st.st_size) + except OSError: + pass + continue + if not root.is_dir(): + continue + for p in root.rglob("*"): + if not p.is_file(): + continue + if p.name in _WATCH_IGNORE_NAMES: + continue + try: + st = p.stat() + snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size) + except OSError: + pass + + # Watch the daily memory files (memory/*.md and per-user dailies) since + # evolution now records learnings there. Skip backups/dreams bookkeeping. + mem_dir = ws / "memory" + if mem_dir.is_dir(): + for p in mem_dir.rglob("*.md"): + rel_parts = p.relative_to(mem_dir).parts + if rel_parts and rel_parts[0] in _MEMORY_IGNORE: + continue + try: + st = p.stat() + snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size) + except OSError: + pass + return snap + + +def _workspace_changed(workspace_dir, pre: dict) -> bool: + """True if any watched file was added, removed, or modified since ``pre``.""" + return _workspace_snapshot(workspace_dir) != pre + + +def run_evolution_for_session( + agent_bridge, + session_id: str, + channel_type: str = "", + receiver: str = "", + user_id: Optional[str] = None, + idle_minutes: float = 0.0, +) -> bool: + """Run one evolution pass for a session. Returns True if it changed anything. + + Safe to call from a background thread. All failures are swallowed and + logged — evolution must never disrupt the main pipeline. + """ + cfg = get_evolution_config() + if not cfg.enabled: + return False + + # Concurrency gate: bound how many evolution passes run at once. + global _running_count + with _running_lock: + if _running_count >= _MAX_CONCURRENT: + logger.info( + f"[Evolution] busy ({_running_count}/{_MAX_CONCURRENT} running); " + f"skipping session={session_id} this scan" + ) + return False + _running_count += 1 + + try: + agent = agent_bridge.agents.get(session_id) or agent_bridge.default_agent + if not agent: + return False + + with agent.messages_lock: + all_messages = list(agent.messages) + total_msgs = len(all_messages) + # In-memory evolution cursor: only review messages added since the last + # pass so a long session doesn't re-judge (and re-write) old content. + # Stored on the agent instance; lost on restart (acceptable — at worst + # one redundant pass right after a restart, gated by the file-change + # check downstream so it won't double-write identical memory). + done = int(getattr(agent, "_evo_done_msg_count", 0)) + if done > total_msgs: + done = 0 # history was trimmed/reset; start fresh + new_messages = all_messages[done:] + transcript = _build_transcript(new_messages) + if not transcript.strip(): + # Routine no-op: the per-minute scan hits every idle session. Advance + # the cursor so we don't re-scan the same tail; no log (pure noise). + agent._evo_done_msg_count = total_msgs + return False + + logger.info( + f"[Evolution] ▶ Reviewing session={session_id} " + f"(idle {idle_minutes:.1f}min, {len(new_messages)} new/{total_msgs} msgs, " + f"~{len(transcript)} chars)" + ) + + # Resolve workspace + files to snapshot for undo. + from agent.memory.config import get_default_memory_config + mem_cfg = get_default_memory_config() + workspace_dir = mem_cfg.get_workspace() + if user_id: + memory_file = Path(workspace_dir) / "memory" / "users" / user_id / "MEMORY.md" + else: + memory_file = Path(workspace_dir) / "MEMORY.md" + skills_dir = mem_cfg.get_skills_dir() + + # Snapshot MEMORY.md + every NON-protected skill's SKILL.md. Protected + # built-in skills are excluded from backup because they must never be + # edited in the first place. + protected_names = _builtin_skill_names() + # Back up both MEMORY.md and today's daily file: evolution now writes to + # the daily file, but MEMORY.md is cheap to snapshot and keeps undo safe + # if the model ever edits it. + today_daily = Path(workspace_dir) / "memory" / ( + datetime.now().strftime("%Y-%m-%d") + ".md" + ) + if user_id: + today_daily = Path(workspace_dir) / "memory" / "users" / user_id / ( + datetime.now().strftime("%Y-%m-%d") + ".md" + ) + # AGENT.md (persona) is backed up too so a rare persona edit is undoable. + # Persona is workspace-global (not per-user): it always lives at the + # workspace root, regardless of user_id. + agent_file = Path(workspace_dir) / "AGENT.md" + backup_files = [Path(memory_file), today_daily, agent_file] + if skills_dir.exists(): + for skill_md in skills_dir.rglob("SKILL.md"): + # The skill dir is the SKILL.md's parent (or an ancestor for + # collections); guard by checking the immediate top-level dir. + try: + top = skill_md.relative_to(skills_dir).parts[0] + except (ValueError, IndexError): + continue + if top in protected_names: + continue + backup_files.append(skill_md) + backup_id = create_backup(workspace_dir, backup_files) + _backup_n = sum(1 for f in backup_files if Path(f).exists()) + + # Snapshot the whole workspace (path -> mtime/size) so we can reliably + # detect ANY file change — including new output files written when + # finishing an unfinished task, which are not in backup_files. + pre_snapshot = _workspace_snapshot(workspace_dir) + + # Build the isolated review agent: same model, restricted tools, with a + # hard guard that confines all writes to the workspace (protects the + # project's bundled skills from ever being modified). + review_tools = _guard_tools( + _select_tools(list(getattr(agent, "tools", []) or [])), + str(workspace_dir), + ) + review_agent = agent_bridge.create_agent( + system_prompt="", + tools=review_tools, + description="Self-evolution review agent", + max_steps=cfg.max_steps, + workspace_dir=str(workspace_dir), + skill_manager=getattr(agent, "skill_manager", None), + memory_manager=getattr(agent, "memory_manager", None), + enable_skills=True, + runtime_info=getattr(agent, "runtime_info", None), + ) + # Reuse the live model so it follows the user's configured model. + review_agent.model = agent.model + # Inject the evolution task brief AFTER the full system prompt: the agent + # gets the full context (tools, workspace, user preferences, memory, time) + # AND its evolution-specific instructions on top, instead of one + # overwriting the other. + review_agent.extra_system_suffix = EVOLUTION_SYSTEM_PROMPT + + logger.info( + f"[Evolution] backup {backup_id} ({_backup_n} files) → running review agent" + ) + user_msg = build_review_user_message(transcript, protected_skills=list(protected_names)) + result = review_agent.run_stream(user_msg, clear_history=True) + result = (result or "").strip() + + # These messages are now reviewed; advance the cursor so the next pass + # only looks at messages added after this point (silent or not). + agent._evo_done_msg_count = total_msgs + + # Respect an explicit silent verdict: empty, exactly [SILENT], or text + # that STARTS with [SILENT] means the model chose to stay quiet. + if not result or result.startswith(SILENT_TOKEN): + logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])") + return False + + # Anti-nag backstop: if the model wrote a summary but actually changed no + # watched file, stay silent — never notify about work that didn't happen. + if not _workspace_changed(workspace_dir, pre_snapshot): + logger.info( + f"[Evolution] ✗ session={session_id}: text produced but no file " + f"changed — staying silent" + ) + return False + + # The model produced a real summary. Strip any stray [SILENT] tokens it + # left mid-text, then notify. + result = result.replace(SILENT_TOKEN, "").strip() + if not result: + logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])") + return False + + logger.info(f"[Evolution] ✓ session={session_id} evolved:\n{result}") + append_session_evolution(workspace_dir, result, backup_id=backup_id, user_id=user_id) + # Inject an [EVOLUTION] note so the main agent can honor "undo". + _inject_evolution_record(agent_bridge, session_id, channel_type, result, backup_id) + # The injection appended its own messages ([SCHEDULED]/[EVOLUTION]). + # Advance the cursor past them so the next scan does not treat + # evolution's own bookkeeping as new user content and re-trigger. + try: + with agent.messages_lock: + agent._evo_done_msg_count = len(agent.messages) + except Exception: + pass + + # Push the summary to the user's channel. The "did a file actually + # change" gate above is the only throttle we need: real evolutions are + # rare, so no extra opt-in switch or daily-count limit is required. + if channel_type and receiver: + _notify_user(channel_type, receiver, result) + + return True + + except Exception as e: + logger.warning(f"[Evolution] Run failed for session={session_id}: {e}") + return False + finally: + with _running_lock: + _running_count -= 1 + + +def _inject_evolution_record( + agent_bridge, session_id: str, channel_type: str, summary: str, backup_id: Optional[str] +) -> None: + """Add an [EVOLUTION] note to the user session so the main agent can undo.""" + try: + note = f"{EVOLUTION_MARKER} {summary}" + if backup_id: + note += f"\n(backup_id: {backup_id}; to undo, restore this backup)" + # Reuse the scheduler-output injection path: isolated execution, only a + # compact record lands in the user session. + agent_bridge.remember_scheduled_output( + session_id=session_id, + content=note, + channel_type=channel_type, + task_description="self-evolution", + ) + except Exception as e: + logger.debug(f"[Evolution] Failed to inject evolution record: {e}") + + +def _notify_user(channel_type: str, receiver: str, summary: str) -> None: + """Push the evolution summary to the user's channel as a new message.""" + try: + from bridge.context import Context, ContextType + from bridge.reply import Reply, ReplyType + from channel.channel_factory import create_channel + + context = Context(ContextType.TEXT, summary) + context["receiver"] = receiver + context["isgroup"] = False + context["session_id"] = receiver + # Channels that reply to an original message need msg=None for a fresh push. + if channel_type in ("feishu", "dingtalk", "wecom_bot", "qq"): + context["msg"] = None + if channel_type == "feishu": + context["receive_id_type"] = "open_id" + + channel = create_channel(channel_type) + if not channel: + return + + # Web is request-response: a background push needs a synthetic request_id + # plus a request->session mapping so the channel can route the message to + # the user's polling queue (same approach the scheduler uses). + if channel_type == "web": + import uuid + request_id = f"evolution_{uuid.uuid4().hex[:8]}" + context["request_id"] = request_id + if hasattr(channel, "request_to_session"): + channel.request_to_session[request_id] = receiver + + channel.send(Reply(ReplyType.TEXT, summary), context) + logger.info(f"[Evolution] Notified user via {channel_type}") + except Exception as e: + logger.warning(f"[Evolution] Failed to notify user: {e}") diff --git a/agent/evolution/prompts.py b/agent/evolution/prompts.py new file mode 100644 index 00000000..649495e5 --- /dev/null +++ b/agent/evolution/prompts.py @@ -0,0 +1,170 @@ +"""Prompts for the self-evolution review agent. + +The system prompt is intentionally English-only: it governs the agent's +internal reasoning and is more stable / cheaper to maintain in one language. +The user-facing summary the agent produces should follow the user's own +language (instructed at the end of the prompt). + +Design goals (see ref/hermes-agent background_review for inspiration): + - Default to doing NOTHING. Evolution is the exception, not the rule. + - Signal types: skill, unfinished task, memory, knowledge. + - An explicit "do NOT capture" list to avoid self-poisoning over time. + - Generic examples only — never bake in domain-specific business terms. +""" + +# Sentinel the agent emits when there is nothing worth evolving. +SILENT_TOKEN = "[SILENT]" + +# Marker prefix for the evolution record injected into the user session, so the +# main chat agent can recognize past evolutions and honor an "undo" request. +EVOLUTION_MARKER = "[EVOLUTION]" + + +EVOLUTION_SYSTEM_PROMPT = """You are a self-evolution review agent for an AI assistant. + +You are given a transcript of a conversation that just went idle. Your job is to +decide whether anything from it is worth durably learning so future +conversations go better — and if so, to make that change. + +# Top principle: default to doing NOTHING + +Most ordinary conversations need no evolution. Only act when there is a CLEAR +signal below. If there is none, reply with exactly `[SILENT]` and stop. Staying +silent is the normal, correct outcome — not a failure. + +Greetings, small talk, acknowledgements ("ok", "thanks", "got it"), and casual +chat are NOT signals. For these, output exactly `[SILENT]` immediately — do not +explore files, do not write a summary, do not be polite. Just `[SILENT]`. + +IMPORTANT: A summary is only allowed if you ACTUALLY made a file change via a +tool (write/edit) in this pass. If you did not change any file, you MUST output +exactly `[SILENT]` — never describe a change you only intended to make. + +# Signals worth acting on (act only if at least one clearly appears) + +SKILL and UNFINISHED TASK are your PRIMARY value — no other mechanism handles +them. When their signal is clear, act; do not be shy here. + +1. SKILL — two cases: + a) PATCH an existing skill: a skill used here showed a STRUCTURAL problem (a + missing step/section, a wrong or outdated detail, an error in its + content), or its OUTPUT repeatedly misses something the user flagged. Read + the relevant skill file under the skills directory and make a small + incremental edit so it never recurs. + b) CREATE a new skill: a clearly reusable, repeatable workflow emerged that + no existing skill covers and the user is likely to want again. Follow the + `skill-creator` skill's conventions (read its SKILL.md for the required + structure), then create `skills//SKILL.md` by WRITING the file + directly with the write tool — this is the simplest reliable path. (bash + is available and confined to the workspace if a helper script is truly + needed, but a direct write is preferred.) Only create when the workflow is + genuinely reusable — not for a one-off task. + + CRITICAL — fix the SOURCE, do not just remember the symptom: when the root + cause of a problem lives IN a skill file itself (its instructions, content, + or configuration are wrong/outdated), the correct action is to EDIT that + skill so the problem cannot recur. Recording the corrected fact in memory + does NOT prevent recurrence — only fixing the skill does. Never log "skill X + has wrong detail Y" as a memory note in place of editing skill X. + +2. UNFINISHED TASK — a specific deliverable you promised but didn't produce, + AND you already have everything needed to finish it. DO IT now with the + available tools and produce the result (e.g. write the file you said you'd + write). If key info is missing, or the task is merely waiting on the user's + reply/decision, do NOTHING and stay [SILENT] — do not nag or ping the user. + You only ever notify the user as a side effect of having actually done work. + +3. MEMORY — RARE, last resort. Default to writing NOTHING here. The main + assistant already writes memory during the chat, and a nightly pass plus + context-overflow saves are dedicated safety nets — so memory is almost always + already covered without you. Skip unless the main assistant clearly missed a + durable fact that belongs in no skill AND would visibly change future replies. + - MEMORY.md is the curated long-term index, auto-loaded into EVERY future + conversation. Treat it as precious: edit it in place to CORRECT a wrong + fact, or append a new durable preference/decision/lesson — but do so + SPARINGLY (a lasting fact, not a passing detail; the nightly pass handles + routine consolidation). + - For a NEW fact that is important but not yet clearly lasting, append ONE + short bullet to today's `memory/YYYY-MM-DD.md` instead. When unsure, the + daily file is the safe place — but first ask whether this really belongs + in a skill. + - PERSONA (AGENT.md) — EXTREMELY rare: only on an explicit, repeated signal + about the assistant's own identity/personality/style, make a small edit to + AGENT.md; never for user/world facts, and when in doubt do nothing. + - Keep it to ONE short bullet. Never write paragraphs, never re-summarize the + conversation, never copy what the main assistant already recorded. + - If it is already captured anywhere (check MEMORY.md AND the daily file + first), do NOTHING. + +4. KNOWLEDGE — only if the conversation produced durable, reusable reference + knowledge on a topic (the kind worth looking up again) that the main + assistant did NOT already save to `knowledge/`. Add or update the relevant + file there. Like memory, this is the exception: skip routine Q&A, and if the + topic is already covered in `knowledge/`, do NOTHING rather than duplicate. + +# Do NOT capture (these poison future behavior) + +- Environment failures: missing binaries, unset credentials, uninstalled + packages, "command not found". The user can fix these; they are not durable + rules. +- Negative claims about tools or features ("tool X does not work"). These + harden into refusals the agent cites against itself later. +- One-off task narratives (e.g. summarizing today's content). Not a class of + reusable work. +- Transient errors that resolved on retry within the conversation. + +# Execution constraints + +- Before changing memory or a skill, READ the current content first and make a + small INCREMENTAL edit. Never fabricate, never rewrite large sections. +- AVOID DUPLICATES. Before writing memory, READ both MEMORY.md AND today's + daily file `memory/YYYY-MM-DD.md`. If the fact/preference is already recorded + in EITHER (even if worded differently), do NOT add it again. The main + assistant likely already wrote it during the chat — only add what is + genuinely new or a correction not yet reflected anywhere. +- You may only edit files inside the workspace. Built-in skills shipped with + the product live outside it and are write-protected; do not try to edit them. +- Make at most the few edits the signals justify; do not go looking for work. + +# Output + +- Nothing worth evolving -> output exactly `[SILENT]` and nothing else. +- Otherwise, after performing the edits, output a short user-facing summary in + the SAME LANGUAGE the user speaks in the conversation transcript. Write it for an ordinary user, in plain + everyday words — NOT a developer report. No need to expose internal details + (file names/paths, system mechanics, etc.). Briefly speak directly TO the user, telling them that you just did a self-learning pass, + what you learned, and what you changed in THIS pass. Keep it clear and focused on the key changes (a few lines), and let + the user know they can undo it. +""" + + +def build_review_user_message(transcript: str, protected_skills: list = None) -> str: + """Wrap the conversation transcript as the review agent's user message. + + ``protected_skills`` lists skill names that must never be edited (built-in + skills shipped with the product). Surfaced so the agent avoids them. + """ + protected_note = "" + if protected_skills: + names = ", ".join(sorted(protected_skills)) + protected_note = ( + "\n\nPROTECTED skills (built-in — never edit these): " + f"{names}\n" + ) + try: + from common import i18n + lang_name = "中文" if i18n.is_zh() else "English" + except Exception: + lang_name = "中文" + return ( + "Here is the conversation transcript that just went idle. Review it per " + "your instructions. Acting is the exception: the main value is fixing or " + "creating a skill and finishing promised work. Memory and knowledge are " + "rare last resorts — stay [SILENT] unless there is a clear, durable signal " + "not already covered." + f"{protected_note}\n" + f"The summary should preferably be written in: {lang_name}\n" + "\n" + f"{transcript}\n" + "" + ) diff --git a/agent/evolution/record.py b/agent/evolution/record.py new file mode 100644 index 00000000..751e3a83 --- /dev/null +++ b/agent/evolution/record.py @@ -0,0 +1,55 @@ +"""Self-evolution record log. + +Session-level evolutions are appended to their OWN per-day file under +``memory/evolution/YYYY-MM-DD.md`` (separate from the nightly Deep Dream diary +in ``memory/dreams/``). Each day's file accumulates one short section per +evolution pass — tagged with a timestamp and a backup id for undo — so the +memory UI can surface "what the agent learned/changed today" on one timeline +without ever mixing into the dream diary or the main conversation memory. +""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Optional + +from common.log import logger + + +def _evolution_dir(workspace_dir: Path, user_id: Optional[str] = None) -> Path: + base = Path(workspace_dir) / "memory" + if user_id: + return base / "users" / user_id / "evolution" + return base / "evolution" + + +def append_session_evolution( + workspace_dir: Path, + summary: str, + backup_id: Optional[str] = None, + user_id: Optional[str] = None, +) -> None: + """Append a session-evolution entry to today's evolution log.""" + if not summary or not summary.strip(): + return + try: + evo_dir = _evolution_dir(workspace_dir, user_id) + evo_dir.mkdir(parents=True, exist_ok=True) + today = datetime.now().strftime("%Y-%m-%d") + log_file = evo_dir / f"{today}.md" + + ts = datetime.now().strftime("%H:%M") + header = f"## {ts}" + body = summary.strip() + if backup_id: + body += f"\n\n_backup_id: {backup_id}_" + + # Create with a title if the file is new, otherwise append a section. + if not log_file.exists(): + log_file.write_text(f"# Self-Evolution: {today}\n\n", encoding="utf-8") + with open(log_file, "a", encoding="utf-8") as f: + f.write(f"\n{header}\n\n{body}\n") + logger.info(f"[Evolution] Recorded session evolution to {log_file.name}") + except Exception as e: + logger.warning(f"[Evolution] Failed to record session evolution: {e}") diff --git a/agent/evolution/trigger.py b/agent/evolution/trigger.py new file mode 100644 index 00000000..e044339c --- /dev/null +++ b/agent/evolution/trigger.py @@ -0,0 +1,151 @@ +"""Idle-based evolution trigger. + +A single background thread periodically scans live agent sessions and runs an +evolution pass for any session that is idle for >= idle_minutes AND has enough +accumulated signal, where "enough signal" is EITHER: + - >= min_turns user turns since the last evolution, OR + - the live context has grown past _CONTEXT_RATIO of the agent's token budget + (mirrors how OpenClacky / Claude Code consolidate under context pressure). + +Turn counting is per user turn (not per message), measured from the last +evolution (or session start). After a pass runs, the baseline resets so a long +session can evolve multiple times without re-judging old content. + +Per-session evolution state is stored on the agent instance via lightweight +attributes set by AgentBridge.agent_reply (see _note_user_turn). +""" + +from __future__ import annotations + +import threading +import time + +from common.log import logger + +from agent.evolution.config import get_evolution_config +from agent.evolution.executor import run_evolution_for_session + +_SCAN_INTERVAL_SECONDS = 60 + +# Context-pressure trigger: evolve once the live context exceeds this fraction +# of the agent's token budget, even if min_turns hasn't been reached. Kept as a +# module constant (not user config) for now. Fallback budget matches +# agent_initializer / config.py (agent_max_context_tokens default = 50000). +_CONTEXT_RATIO = 0.8 +_FALLBACK_CONTEXT_BUDGET = 50000 + + +def _context_pressure_reached(agent) -> bool: + """True if the agent's live context exceeds _CONTEXT_RATIO of its budget. + + Uses the agent's own (estimated) token accounting so behavior matches the + existing context-trimming path. Best-effort: any error -> False. + """ + try: + with agent.messages_lock: + messages = list(agent.messages) + if not messages: + return False + est = sum(agent._estimate_message_tokens(m) for m in messages) + budget = getattr(agent, "max_context_tokens", None) or _FALLBACK_CONTEXT_BUDGET + return est / budget > _CONTEXT_RATIO + except Exception: + return False + + +def note_user_turn(agent, channel_type: str = "", receiver: str = "") -> None: + """Record activity for a session's agent. Called once per real user turn. + + Maintains, on the agent instance: + _evo_last_active : epoch seconds of the last user turn + _evo_turns : user turns since the last evolution + _evo_channel_type : originating channel (for later notify) + _evo_receiver : push target for notify + """ + try: + agent._evo_last_active = time.time() + agent._evo_turns = int(getattr(agent, "_evo_turns", 0)) + 1 + if channel_type: + agent._evo_channel_type = channel_type + if receiver: + agent._evo_receiver = receiver + except Exception: + pass + + +def mark_run_active(agent, active: bool) -> None: + """Flag whether the agent is mid-run, so idle scans skip a busy session. + + Without this, a single run that lasts longer than idle_minutes would let + the scanner fire an evolution pass concurrently with the live turn. + """ + try: + agent._evo_run_active = bool(active) + if active: + agent._evo_last_active = time.time() + except Exception: + pass + + +def start_evolution_trigger(agent_bridge) -> None: + """Start the idle-scan thread once per process (idempotent).""" + if getattr(agent_bridge, "_evolution_trigger_started", False): + return + agent_bridge._evolution_trigger_started = True + + t = threading.Thread( + target=_scan_loop, args=(agent_bridge,), daemon=True, name="evolution-trigger" + ) + t.start() + logger.info("[Evolution] Idle trigger started") + + +def _scan_loop(agent_bridge) -> None: + while True: + try: + time.sleep(_SCAN_INTERVAL_SECONDS) + cfg = get_evolution_config() + if not cfg.enabled: + continue + _scan_once(agent_bridge, cfg) + except Exception as e: + logger.warning(f"[Evolution] Scan loop error: {e}") + time.sleep(_SCAN_INTERVAL_SECONDS) + + +def _scan_once(agent_bridge, cfg) -> None: + now = time.time() + # Snapshot to avoid holding the dict while running long evolutions. + sessions = list(getattr(agent_bridge, "agents", {}).items()) + for session_id, agent in sessions: + try: + # Skip sessions whose agent is mid-run: a long turn must not be + # reviewed while it is still producing the answer. + if getattr(agent, "_evo_run_active", False): + continue + last_active = getattr(agent, "_evo_last_active", 0) + turns = int(getattr(agent, "_evo_turns", 0)) + # Enough signal = enough turns OR enough context pressure. + enough_signal = turns >= cfg.min_turns or _context_pressure_reached(agent) + if not enough_signal: + continue + idle = now - last_active if last_active > 0 else -1 + if last_active <= 0 or idle < cfg.idle_seconds: + continue + + channel_type = getattr(agent, "_evo_channel_type", "") or "" + receiver = getattr(agent, "_evo_receiver", "") or "" + + # Reset baseline BEFORE running so a long pass / new messages during + # it don't double-trigger; turns accrue fresh from here. + agent._evo_turns = 0 + + run_evolution_for_session( + agent_bridge, + session_id=session_id, + channel_type=channel_type, + receiver=receiver, + idle_minutes=(now - last_active) / 60 if last_active > 0 else 0.0, + ) + except Exception as e: + logger.warning(f"[Evolution] Failed to evaluate session={session_id}: {e}") diff --git a/agent/knowledge/__init__.py b/agent/knowledge/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/agent/knowledge/service.py b/agent/knowledge/service.py new file mode 100644 index 00000000..80fc6748 --- /dev/null +++ b/agent/knowledge/service.py @@ -0,0 +1,431 @@ +""" +Knowledge service for handling knowledge base operations. + +Provides a unified interface for listing, reading, and graphing knowledge files, +callable from the web console, API, or CLI. + +Knowledge file layout (under workspace_root): + knowledge/index.md + knowledge/log.md + knowledge//.md +""" + +import os +import re +import asyncio +import shutil +import threading +from pathlib import Path +from typing import Optional, Iterable + +from common.log import logger +from config import conf +from agent.memory.config import MemoryConfig +from agent.memory.manager import MemoryManager + + +class KnowledgeService: + """ + High-level service for knowledge base queries. + Operates directly on the filesystem. + """ + + PROTECTED_FILES = {"index.md", "log.md"} + INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]') + + def __init__(self, workspace_root: str, memory_manager=None): + self.workspace_root = os.path.abspath(workspace_root) + self.knowledge_dir = os.path.join(self.workspace_root, "knowledge") + self._memory_manager = memory_manager + + def _resolve_path(self, rel_path: str, *, kind: Optional[str] = None, + allow_missing: bool = True) -> tuple: + if not isinstance(rel_path, str) or not rel_path.strip(): + raise ValueError("path is required") + rel_path = rel_path.replace("\\", "/").strip("/") + parts = rel_path.split("/") + if any(not p or p in (".", "..") or self.INVALID_NAME_RE.search(p) for p in parts): + raise ValueError("invalid path") + if kind == "document" and not rel_path.lower().endswith(".md"): + raise ValueError("document path must end with .md") + + root = Path(self.knowledge_dir).resolve() + candidate = root.joinpath(*parts) + # Resolve the nearest existing ancestor so a symlink cannot be used + # to escape when the final destination does not exist yet. + ancestor = candidate + while not ancestor.exists() and ancestor != root: + ancestor = ancestor.parent + try: + ancestor.resolve().relative_to(root) + except ValueError: + raise ValueError("path outside knowledge dir") + if candidate.exists(): + try: + candidate.resolve().relative_to(root) + except ValueError: + raise ValueError("path outside knowledge dir") + elif not allow_missing: + raise FileNotFoundError(f"path not found: {rel_path}") + return rel_path, candidate + + def _ensure_not_protected(self, rel_path: str): + if rel_path in self.PROTECTED_FILES: + raise ValueError(f"protected knowledge file: {rel_path}") + + def _manager(self): + if self._memory_manager is None: + self._memory_manager = MemoryManager(MemoryConfig(workspace_root=self.workspace_root)) + return self._memory_manager + + @staticmethod + def _run_sync(coro): + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + result = [] + error = [] + + def runner(): + try: + result.append(asyncio.run(coro)) + except Exception as exc: + error.append(exc) + + thread = threading.Thread(target=runner) + thread.start() + thread.join() + if error: + raise error[0] + return result[0] if result else None + + def _sync_index(self, old_paths: Iterable[str]): + old_paths = sorted(set(old_paths)) + if not old_paths: + return + manager = self._manager() + for rel_path in old_paths: + manager.storage.delete_by_path(f"knowledge/{rel_path}") + manager.mark_dirty() + self._run_sync(manager.sync()) + + def create_category(self, path: str) -> dict: + rel_path, full_path = self._resolve_path(path, kind="category") + if full_path.exists(): + return {"path": rel_path, "created": False, "reason": "already_exists"} + full_path.mkdir(parents=True) + return {"path": rel_path, "created": True} + + def rename_category(self, path: str, new_path: str) -> dict: + old_rel, old_full = self._resolve_path(path, kind="category", allow_missing=False) + new_rel, new_full = self._resolve_path(new_path, kind="category") + if not old_full.is_dir(): + raise ValueError(f"not a category: {old_rel}") + if new_full.exists(): + raise FileExistsError(f"target already exists: {new_rel}") + old_documents = [str(p.relative_to(old_full)).replace(os.sep, "/") + for p in old_full.rglob("*.md") if p.is_file()] + new_full.parent.mkdir(parents=True, exist_ok=True) + try: + old_full.rename(new_full) + except FileNotFoundError: + return {"old_path": old_rel, "path": new_rel, "moved": False, "reason": "not_found"} + except FileExistsError: + raise FileExistsError(f"target already exists: {new_rel}") + old_paths = [f"{old_rel}/{p}" for p in old_documents] + self._sync_index(old_paths) + return {"old_path": old_rel, "path": new_rel, "moved_documents": len(old_documents)} + + def delete_category(self, path: str, confirm: bool = False) -> dict: + rel_path, full_path = self._resolve_path(path, kind="category") + if not full_path.exists(): + return {"path": rel_path, "deleted": False, "reason": "not_found"} + if not full_path.is_dir(): + raise ValueError(f"not a category: {rel_path}") + knowledge_root = Path(self.knowledge_dir).resolve() + documents = [str(p.relative_to(knowledge_root)).replace(os.sep, "/") + for p in full_path.rglob("*.md") if p.is_file()] + if any(p in self.PROTECTED_FILES for p in documents): + raise ValueError("category contains protected knowledge files") + if any(full_path.iterdir()) and not confirm: + raise ValueError("category is not empty; confirmation is required") + try: + shutil.rmtree(full_path) + except FileNotFoundError: + return {"path": rel_path, "deleted": False, "reason": "not_found"} + self._sync_index(documents) + return {"path": rel_path, "deleted": True, "deleted_documents": len(documents)} + + def delete_documents(self, paths: Iterable[str]) -> dict: + if not isinstance(paths, list): + raise ValueError("paths must be a list") + results = [] + deleted = [] + for path in paths: + rel_path, full_path = self._resolve_path(path, kind="document") + self._ensure_not_protected(rel_path) + if not full_path.exists(): + deleted.append(rel_path) + results.append({"path": rel_path, "deleted": False, "reason": "not_found"}) + continue + if not full_path.is_file(): + raise ValueError(f"not a document: {rel_path}") + try: + full_path.unlink() + deleted.append(rel_path) + results.append({"path": rel_path, "deleted": True}) + except FileNotFoundError: + deleted.append(rel_path) + results.append({"path": rel_path, "deleted": False, "reason": "not_found"}) + self._sync_index(deleted) + return {"results": results, "deleted": sum(1 for item in results if item["deleted"])} + + def move_documents(self, paths: Iterable[str], target_category: str) -> dict: + if not isinstance(paths, list): + raise ValueError("paths must be a list") + target_rel, target_full = self._resolve_path(target_category, kind="category") + if not target_full.is_dir(): + raise FileNotFoundError(f"category not found: {target_rel}") + results = [] + moved_old_paths = [] + for path in paths: + rel_path, full_path = self._resolve_path(path, kind="document") + self._ensure_not_protected(rel_path) + if not full_path.exists(): + results.append({"path": rel_path, "moved": False, "reason": "not_found"}) + continue + destination = target_full / full_path.name + new_rel = str(destination.relative_to(Path(self.knowledge_dir).resolve())).replace(os.sep, "/") + if destination.exists(): + results.append({"path": rel_path, "moved": False, "reason": "target_exists", + "target": new_rel}) + continue + try: + os.link(full_path, destination) + full_path.unlink() + moved_old_paths.append(rel_path) + results.append({"path": rel_path, "moved": True, "target": new_rel}) + except FileExistsError: + results.append({"path": rel_path, "moved": False, "reason": "target_exists", + "target": new_rel}) + except FileNotFoundError: + results.append({"path": rel_path, "moved": False, "reason": "not_found"}) + self._sync_index(moved_old_paths) + return {"results": results, "moved": len(moved_old_paths)} + + # ------------------------------------------------------------------ + # list — directory tree with stats + # ------------------------------------------------------------------ + def list_tree(self) -> dict: + """ + Return the knowledge directory tree grouped by category, + supporting arbitrarily nested sub-directories. + + Returns:: + + { + "tree": [ + { + "dir": "concepts", + "files": [ + {"name": "moe.md", "title": "MoE", "size": 1234}, + ], + "children": [] + }, + { + "dir": "platform", + "files": [], + "children": [ + { + "dir": "analysis", + "files": [{"name": "perf.md", ...}], + "children": [] + } + ] + }, + ], + "stats": {"pages": 15, "size": 32768}, + "enabled": true + } + """ + if not os.path.isdir(self.knowledge_dir): + return {"tree": [], "stats": {"pages": 0, "size": 0}, "enabled": conf().get("knowledge", True)} + + stats = {"pages": 0, "size": 0} + root_files, tree = self._scan_dir(self.knowledge_dir, stats, is_root=True) + + return { + "root_files": root_files, + "tree": tree, + "stats": stats, + "enabled": conf().get("knowledge", True), + } + + def _scan_dir(self, dir_path: str, stats: dict, is_root: bool = False) -> tuple: + """ + Recursively scan a directory. + + :return: (files, children) where files is a list of .md file dicts + in this directory and children is a list of sub-directory nodes. + """ + files = [] + children = [] + for name in sorted(os.listdir(dir_path)): + if name.startswith("."): + continue + full = os.path.join(dir_path, name) + if os.path.isdir(full): + sub_files, sub_children = self._scan_dir(full, stats) + children.append({"dir": name, "files": sub_files, "children": sub_children}) + elif name.endswith(".md"): + size = os.path.getsize(full) + if not is_root: + stats["pages"] += 1 + stats["size"] += size + title = name.replace(".md", "") + try: + with open(full, "r", encoding="utf-8") as f: + first_line = f.readline().strip() + if first_line.startswith("# "): + title = first_line[2:].strip() + except Exception: + pass + files.append({"name": name, "title": title, "size": size}) + return files, children + + # ------------------------------------------------------------------ + # read — single file content + # ------------------------------------------------------------------ + def read_file(self, rel_path: str) -> dict: + """ + Read a single knowledge markdown file. + + :param rel_path: Relative path within knowledge/, e.g. ``concepts/moe.md`` + :return: dict with ``content`` and ``path`` + :raises ValueError: if path is invalid or escapes knowledge dir + :raises FileNotFoundError: if file does not exist + """ + rel_path, full_path = self._resolve_path(rel_path, kind="document") + if not full_path.is_file(): + raise FileNotFoundError(f"file not found: {rel_path}") + + with open(full_path, "r", encoding="utf-8") as f: + content = f.read() + return {"content": content, "path": rel_path} + + # ------------------------------------------------------------------ + # graph — nodes and links for visualization + # ------------------------------------------------------------------ + def build_graph(self) -> dict: + """ + Parse all knowledge pages and extract cross-reference links. + + Returns:: + + { + "nodes": [ + {"id": "concepts/moe.md", "label": "MoE", "category": "concepts"}, + ... + ], + "links": [ + {"source": "concepts/moe.md", "target": "entities/deepseek.md"}, + ... + ] + } + """ + knowledge_path = Path(self.knowledge_dir) + if not knowledge_path.is_dir(): + return {"nodes": [], "links": []} + + nodes = {} + links = [] + link_re = re.compile(r'\[([^\]]*)\]\(([^)]+\.md)\)') + + for md_file in knowledge_path.rglob("*.md"): + rel = str(md_file.relative_to(knowledge_path)) + if rel in ("index.md", "log.md"): + continue + parts = rel.split("/") + category = parts[0] if len(parts) > 1 else "root" + title = md_file.stem.replace("-", " ").title() + try: + content = md_file.read_text(encoding="utf-8") + first_line = content.strip().split("\n")[0] + if first_line.startswith("# "): + title = first_line[2:].strip() + for _, link_target in link_re.findall(content): + resolved = (md_file.parent / link_target).resolve() + try: + target_rel = str(resolved.relative_to(knowledge_path)) + except ValueError: + continue + if target_rel != rel: + links.append({"source": rel, "target": target_rel}) + except Exception: + pass + nodes[rel] = {"id": rel, "label": title, "category": category} + + valid_ids = set(nodes.keys()) + links = [l for l in links if l["source"] in valid_ids and l["target"] in valid_ids] + seen = set() + deduped = [] + for l in links: + key = tuple(sorted([l["source"], l["target"]])) + if key not in seen: + seen.add(key) + deduped.append(l) + + return {"nodes": list(nodes.values()), "links": deduped} + + # ------------------------------------------------------------------ + # dispatch — single entry point for protocol messages + # ------------------------------------------------------------------ + def dispatch(self, action: str, payload: Optional[dict] = None) -> dict: + """ + Dispatch a knowledge management action. + + :param action: ``list``, ``read``, or ``graph`` + :param payload: action-specific payload + :return: protocol-compatible response dict + """ + payload = payload or {} + try: + if action == "list": + result = self.list_tree() + return {"action": action, "code": 200, "message": "success", "payload": result} + + elif action == "read": + path = payload.get("path") + if not path: + return {"action": action, "code": 400, "message": "path is required", "payload": None} + result = self.read_file(path) + return {"action": action, "code": 200, "message": "success", "payload": result} + + elif action == "graph": + result = self.build_graph() + return {"action": action, "code": 200, "message": "success", "payload": result} + + elif action == "create_category": + result = self.create_category(payload.get("path")) + elif action == "rename_category": + result = self.rename_category(payload.get("path"), payload.get("new_path")) + elif action == "delete_category": + result = self.delete_category(payload.get("path"), payload.get("confirm", False)) + elif action == "delete_documents": + result = self.delete_documents(payload.get("paths") or []) + elif action == "move_documents": + result = self.move_documents(payload.get("paths") or [], payload.get("target_category")) + else: + return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None} + return {"action": action, "code": 200, "message": "success", "payload": result} + + except ValueError as e: + return {"action": action, "code": 403, "message": str(e), "payload": None} + except FileNotFoundError as e: + return {"action": action, "code": 404, "message": str(e), "payload": None} + except FileExistsError as e: + return {"action": action, "code": 409, "message": str(e), "payload": None} + except Exception as e: + logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}") + return {"action": action, "code": 500, "message": str(e), "payload": None} diff --git a/agent/memory/conversation_store.py b/agent/memory/conversation_store.py index a4f15aab..1537f98e 100644 --- a/agent/memory/conversation_store.py +++ b/agent/memory/conversation_store.py @@ -13,6 +13,7 @@ Storage path: ~/cow/sessions/conversations.db from __future__ import annotations import json +import re import sqlite3 import threading import time @@ -28,11 +29,13 @@ from common.log import logger _DDL = """ CREATE TABLE IF NOT EXISTS sessions ( - session_id TEXT PRIMARY KEY, - channel_type TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL, - last_active INTEGER NOT NULL, - msg_count INTEGER NOT NULL DEFAULT 0 + session_id TEXT PRIMARY KEY, + channel_type TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + context_start_seq INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + last_active INTEGER NOT NULL, + msg_count INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( @@ -42,6 +45,7 @@ CREATE TABLE IF NOT EXISTS messages ( role TEXT NOT NULL, content TEXT NOT NULL, created_at INTEGER NOT NULL, + extras TEXT NOT NULL DEFAULT '', UNIQUE (session_id, seq) ); @@ -57,6 +61,20 @@ _MIGRATION_ADD_CHANNEL_TYPE = """ ALTER TABLE sessions ADD COLUMN channel_type TEXT NOT NULL DEFAULT ''; """ +_MIGRATION_ADD_TITLE = """ +ALTER TABLE sessions ADD COLUMN title TEXT NOT NULL DEFAULT ''; +""" + +_MIGRATION_ADD_CONTEXT_START_SEQ = """ +ALTER TABLE sessions ADD COLUMN context_start_seq INTEGER NOT NULL DEFAULT 0; +""" + +# Generic JSON sidecar for per-message attachments (TTS audio URL, future use). +# Always optional — readers must tolerate missing column / empty / invalid JSON. +_MIGRATION_ADD_MSG_EXTRAS = """ +ALTER TABLE messages ADD COLUMN extras TEXT NOT NULL DEFAULT ''; +""" + DEFAULT_MAX_AGE_DAYS: int = 30 @@ -92,6 +110,48 @@ def _extract_display_text(content: Any) -> str: return "" +# Internal markers written into the session for the agent's own bookkeeping +# (scheduler injection / self-evolution undo). They must stay in the stored +# content (the LLM reads them, e.g. to find a backup_id for undo) but should +# never be shown verbatim to the user in the chat history UI. +_SCHEDULED_DISPLAY_MARKERS = ("[SCHEDULED]", "Scheduled task") +_EVOLUTION_DISPLAY_MARKER = "[EVOLUTION]" + + +def _is_internal_user_marker(text: str) -> bool: + """True if a user-turn text is an internal injection marker (hide from UI).""" + t = (text or "").lstrip() + return any(t.startswith(m) for m in _SCHEDULED_DISPLAY_MARKERS) + + +def _is_evolution_text(text: str) -> bool: + """True if assistant text is a self-evolution summary (before cleaning).""" + return (text or "").lstrip().startswith(_EVOLUTION_DISPLAY_MARKER) + + +def _clean_display_text(text: str) -> str: + """Strip internal markers from assistant text for user-facing display. + + Removes a leading ``[EVOLUTION]`` tag and a trailing ``(backup_id: ...)`` + undo hint. The raw stored message is untouched, so undo + LLM context still + work; only the rendered chat bubble is cleaned. + """ + if not text: + return text + cleaned = text + stripped = cleaned.lstrip() + if stripped.startswith(_EVOLUTION_DISPLAY_MARKER): + cleaned = stripped[len(_EVOLUTION_DISPLAY_MARKER):].lstrip() + # Drop a trailing backup_id undo hint line, e.g. + # "(backup_id: 20260607-...; to undo, restore this backup)" + cleaned = re.sub( + r"\n*\(backup_id:[^\)]*\)\s*$", + "", + cleaned, + ).rstrip() + return cleaned + + def _extract_tool_calls(content: Any) -> List[Dict[str, Any]]: """ Extract tool_use blocks from an assistant message content. @@ -106,9 +166,10 @@ def _extract_tool_calls(content: Any) -> List[Dict[str, Any]]: ] -def _extract_tool_results(content: Any) -> Dict[str, str]: +def _extract_tool_results(content: Any) -> Dict[str, dict]: """ Extract tool_result blocks from a user message, keyed by tool_use_id. + Values are {"result": str, "is_error": bool}. """ if not isinstance(content, list): return {} @@ -123,12 +184,13 @@ def _extract_tool_results(content: Any) -> Dict[str, str]: rb.get("text", "") for rb in result_content if isinstance(rb, dict) and rb.get("type") == "text" ) - results[tool_id] = str(result_content) + results[tool_id] = {"result": str(result_content), "is_error": bool(b.get("is_error", False))} return results def _group_into_display_turns( rows: List[tuple], + include_thinking: bool = True, ) -> List[Dict[str, Any]]: """ Convert raw (role, content_json, created_at) DB rows into display turns. @@ -157,20 +219,26 @@ def _group_into_display_turns( cur_rest: List[tuple] = [] started = False - for role, raw_content, created_at in rows: + for role, raw_content, created_at, raw_extras in rows: try: content = json.loads(raw_content) except Exception: content = raw_content + try: + extras = json.loads(raw_extras) if raw_extras else {} + if not isinstance(extras, dict): + extras = {} + except Exception: + extras = {} if role == "user" and _is_visible_user_message(content): if started: groups.append((cur_user, cur_rest)) - cur_user = (content, created_at) + cur_user = (content, created_at, extras) cur_rest = [] started = True else: - cur_rest.append((role, content, created_at)) + cur_rest.append((role, content, created_at, extras)) if started: groups.append((cur_user, cur_rest)) @@ -183,39 +251,90 @@ def _group_into_display_turns( for user_row, rest in groups: # User turn if user_row: - content, created_at = user_row + content, created_at, _u_extras = user_row text = _extract_display_text(content) - if text: + # Hide internal injection markers (scheduler / self-evolution) so the + # user never sees a synthetic "[SCHEDULED] self-evolution" bubble; + # the assistant reply that follows is still rendered. + if text and not _is_internal_user_marker(text): turns.append({"role": "user", "content": text, "created_at": created_at}) - # Collect all tool_calls and tool_results from the rest of the group - all_tool_calls: List[Dict[str, Any]] = [] + # Build an ordered list of steps preserving the original sequence: + # thinking → content → tool_call → content → ... + steps: List[Dict[str, Any]] = [] tool_results: Dict[str, str] = {} final_text = "" final_ts: Optional[int] = None + merged_extras: Dict[str, Any] = {} - for role, content, created_at in rest: + for role, content, created_at, extras in rest: + if role == "assistant" and isinstance(extras, dict): + merged_extras.update(extras) if role == "user": tool_results.update(_extract_tool_results(content)) elif role == "assistant": - tcs = _extract_tool_calls(content) - all_tool_calls.extend(tcs) - t = _extract_display_text(content) - if t: - final_text = t + # Walk content blocks in order to preserve interleaving + if isinstance(content, list): + for block in content: + if not isinstance(block, dict): + continue + btype = block.get("type") + if btype == "thinking": + if not include_thinking: + continue + txt = block.get("thinking", "").strip() + if txt: + steps.append({"type": "thinking", "content": txt}) + elif btype == "text": + txt = block.get("text", "").strip() + if txt: + steps.append({"type": "content", "content": txt}) + final_text = txt + elif btype == "tool_use": + steps.append({ + "type": "tool", + "id": block.get("id", ""), + "name": block.get("name", ""), + "arguments": block.get("input", {}), + }) + elif isinstance(content, str) and content.strip(): + steps.append({"type": "content", "content": content.strip()}) + final_text = content.strip() final_ts = created_at - # Attach tool results to their matching tool_call entries - for tc in all_tool_calls: - tc["result"] = tool_results.get(tc.get("id", ""), "") + # Attach tool results to tool steps + for step in steps: + if step["type"] == "tool": + tr = tool_results.get(step.get("id", ""), {}) + if not isinstance(tr, dict): + tr = {"result": tr} + step["result"] = tr.get("result", "") + step["is_error"] = tr.get("is_error", False) - if final_text or all_tool_calls: - turns.append({ + # Detect a self-evolution bubble BEFORE cleaning the marker away, so the + # UI can flag it even though the visible text stays clean. + is_evolution = _is_evolution_text(final_text) + + # Clean internal markers from the user-facing assistant text. Applies to + # both the final content and the mirrored content step so the rendered + # bubble shows clean text while the stored message keeps the markers. + final_text = _clean_display_text(final_text) + for step in steps: + if step.get("type") == "content": + step["content"] = _clean_display_text(step.get("content", "")) + + if steps or final_text: + turn = { "role": "assistant", "content": final_text, - "tool_calls": all_tool_calls, + "steps": steps, "created_at": final_ts or (user_row[1] if user_row else 0), - }) + } + if is_evolution: + turn["kind"] = "evolution" + if merged_extras: + turn["extras"] = merged_extras + turns.append(turn) return turns @@ -232,7 +351,7 @@ class ConversationStore: def __init__(self, db_path: Path): self._db_path = db_path - self._lock = threading.Lock() + self._lock = threading.RLock() # Use RLock to allow reentrant locking self._init_db() # ------------------------------------------------------------------ @@ -264,14 +383,21 @@ class ConversationStore: with self._lock: conn = self._connect() try: + # Respect context_start_seq: only load messages at or after the boundary + ctx_row = conn.execute( + "SELECT context_start_seq FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + ctx_start = ctx_row[0] if ctx_row else 0 + rows = conn.execute( """ SELECT seq, role, content FROM messages - WHERE session_id = ? + WHERE session_id = ? AND seq >= ? ORDER BY seq DESC """, - (session_id,), + (session_id, ctx_start), ).fetchall() finally: conn.close() @@ -279,10 +405,7 @@ class ConversationStore: if not rows: return [] - # Walk newest-to-oldest counting *visible* user turns (actual user text, - # not tool_result injections). Record the seq of every visible user - # message so we can find a clean cut point later. - visible_turn_seqs: List[int] = [] # newest first + visible_turn_seqs: List[int] = [] for seq, role, raw_content in rows: if role != "user": continue @@ -293,17 +416,11 @@ class ConversationStore: if _is_visible_user_message(content): visible_turn_seqs.append(seq) - # Determine the seq of the oldest visible user message we want to keep. - # If the total turns fit within max_turns, keep everything. if len(visible_turn_seqs) <= max_turns: - cutoff_seq = None # keep all + cutoff_seq = None else: - # The Nth visible user message (0-indexed) is the oldest we keep. cutoff_seq = visible_turn_seqs[max_turns - 1] - # Build result in chronological order, starting from cutoff. - # IMPORTANT: we start exactly at cutoff_seq (the visible user message), - # never mid-group, so tool_use / tool_result pairs are always complete. result = [] for seq, role, raw_content in reversed(rows): if cutoff_seq is not None and seq < cutoff_seq: @@ -312,6 +429,9 @@ class ConversationStore: content = json.loads(raw_content) except Exception: content = raw_content + # Strip thinking blocks — they are stored for UI display only + if role == "assistant" and isinstance(content, list): + content = [b for b in content if b.get("type") != "thinking"] result.append({"role": role, "content": content}) return result @@ -369,13 +489,15 @@ class ConversationStore: content = json.dumps( msg.get("content", ""), ensure_ascii=False ) + extras_obj = msg.get("extras") or {} + extras = json.dumps(extras_obj, ensure_ascii=False) if extras_obj else "" conn.execute( """ INSERT OR IGNORE INTO messages - (session_id, seq, role, content, created_at) - VALUES (?, ?, ?, ?, ?) + (session_id, seq, role, content, created_at, extras) + VALUES (?, ?, ?, ?, ?, ?) """, - (session_id, next_seq, role, content, now), + (session_id, next_seq, role, content, now, extras), ) next_seq += 1 @@ -389,9 +511,123 @@ class ConversationStore: """, (session_id, session_id), ) + + # Auto-generate title from the first visible user message + cur_title = conn.execute( + "SELECT title FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + if cur_title and not cur_title[0]: + for msg in messages: + if msg.get("role") == "user": + content = msg.get("content", "") + text = _extract_display_text(content) + if text: + title = text[:50].split("\n")[0] + conn.execute( + "UPDATE sessions SET title = ? WHERE session_id = ?", + (title, session_id), + ) + break finally: conn.close() + def clear_context(self, session_id: str) -> int: + """ + Set the context boundary to after the current last message. + Messages before this boundary are still stored but excluded from LLM context. + + Returns the new context_start_seq value. + """ + with self._lock: + conn = self._connect() + try: + with conn: + row = conn.execute( + "SELECT COALESCE(MAX(seq), -1) FROM messages WHERE session_id = ?", + (session_id,), + ).fetchone() + new_start = row[0] + 1 + conn.execute( + "UPDATE sessions SET context_start_seq = ? WHERE session_id = ?", + (new_start, session_id), + ) + return new_start + finally: + conn.close() + + def get_context_start_seq(self, session_id: str) -> int: + """Return the context_start_seq for a session (0 if not set).""" + with self._lock: + conn = self._connect() + try: + row = conn.execute( + "SELECT context_start_seq FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + return row[0] if row else 0 + finally: + conn.close() + + def get_latest_pair_seqs(self, session_id: str) -> Dict[str, Optional[int]]: + """Return the seq numbers of the latest visible user message and the + latest assistant message in a session. + + A "visible" user message is one whose content is real user text + (not just a tool_result block), so tool-execution turns do not + shadow the actual user query. + + Returns: + Dict with keys ``user_seq`` and ``bot_seq``; either may be None + when no matching message exists. + """ + result: Dict[str, Optional[int]] = {"user_seq": None, "bot_seq": None} + with self._lock: + conn = self._connect() + try: + # Latest assistant message (cheap: single row by seq DESC). + row = conn.execute( + "SELECT seq FROM messages " + "WHERE session_id = ? AND role = 'assistant' " + "ORDER BY seq DESC LIMIT 1", + (session_id,), + ).fetchone() + if row: + result["bot_seq"] = int(row[0]) + + # Latest visible user message: scan recent user rows and + # skip pure tool_result entries. + rows = conn.execute( + "SELECT seq, content FROM messages " + "WHERE session_id = ? AND role = 'user' " + "ORDER BY seq DESC LIMIT 20", + (session_id,), + ).fetchall() + for seq, content_raw in rows: + try: + content = json.loads(content_raw) + except Exception: + result["user_seq"] = int(seq) + break + if isinstance(content, list): + has_text = any( + isinstance(b, dict) and b.get("type") == "text" + for b in content + ) + has_tool_result = any( + isinstance(b, dict) and b.get("type") == "tool_result" + for b in content + ) + if has_text and not has_tool_result: + result["user_seq"] = int(seq) + break + else: + result["user_seq"] = int(seq) + break + finally: + conn.close() + return result + def clear_session(self, session_id: str) -> None: """Delete all messages and the session record for a given session_id.""" with self._lock: @@ -407,9 +643,214 @@ class ConversationStore: finally: conn.close() + def delete_message_pair(self, session_id: str, user_seq: int, delete_user: bool = True, cascade: bool = False) -> int: + """Delete a user message and/or its corresponding assistant reply. + + The assistant reply is identified as all messages between user_seq + and the next visible user message (or end of session). + + Args: + session_id: Session identifier. + user_seq: The seq number of the user message. + delete_user: If True (default), delete the user message too. + If False, only delete assistant reply (for regenerate scenarios). + cascade: If True, also delete all subsequent turns after this one. + Used by edit-message which removes this turn and everything after. + + Returns: + Number of message rows deleted. + """ + with self._lock: + conn = self._connect() + try: + with conn: + # Verify this is a user message + row = conn.execute( + "SELECT role FROM messages WHERE session_id = ? AND seq = ?", + (session_id, user_seq), + ).fetchone() + if not row or row[0] != "user": + return 0 + + if cascade: + # Delete from this message to end of session + start_seq = user_seq if delete_user else user_seq + 1 + end_seq_row = conn.execute( + "SELECT MAX(seq) FROM messages WHERE session_id = ?", + (session_id,), + ).fetchone() + end_seq = (end_seq_row[0] or user_seq) + 1 + else: + # Find the next visible user message seq (exclude tool_result) + # Use batched query to avoid loading too many rows at once + next_user_seq = None + batch_size = 100 + offset = 0 + while True: + batch = conn.execute( + """ + SELECT seq, content FROM messages + WHERE session_id = ? AND seq > ? AND role = 'user' + ORDER BY seq ASC + LIMIT ? OFFSET ? + """, + (session_id, user_seq, batch_size, offset), + ).fetchall() + if not batch: + break + for seq, content in batch: + try: + content_obj = json.loads(content) + except Exception: + content_obj = content + if _is_visible_user_message(content_obj): + next_user_seq = seq + break + if next_user_seq is not None: + break + offset += batch_size + + # Determine the end boundary for deletion + if next_user_seq is not None: + end_seq = next_user_seq + else: + end_seq_row = conn.execute( + "SELECT MAX(seq) FROM messages WHERE session_id = ?", + (session_id,), + ).fetchone() + end_seq = (end_seq_row[0] or user_seq) + 1 + + # Determine the start boundary for deletion + start_seq = user_seq if delete_user else user_seq + 1 + + # Delete messages from start_seq to end_seq (exclusive) + cur = conn.execute( + "DELETE FROM messages WHERE session_id = ? AND seq >= ? AND seq < ?", + (session_id, start_seq, end_seq), + ) + deleted = cur.rowcount + + # Update session msg_count + conn.execute( + """ + UPDATE sessions + SET msg_count = ( + SELECT COUNT(*) FROM messages WHERE session_id = ? + ) + WHERE session_id = ? + """, + (session_id, session_id), + ) + + return deleted + finally: + conn.close() + + def prune_scheduled_messages( + self, + session_id: str, + keep_last_n: int, + markers: Optional[List[str]] = None, + ) -> int: + """ + Keep at most ``keep_last_n`` scheduler-injected user/assistant pairs in + the session, deleting the older ones. + + A scheduler-injected pair is identified by a user message whose first + text block starts with one of ``markers``; the immediately following + assistant message (next seq) is treated as its paired output. + + Only scheduler-tagged messages are touched; regular user turns are + never deleted. Safe to call repeatedly; no-op if nothing to prune. + + Args: + session_id: Session to prune. + keep_last_n: Maximum scheduler pairs to retain (must be >= 0). + markers: Text prefixes that identify scheduler user messages. + Defaults to ``["[SCHEDULED]", "Scheduled task"]`` so that + pairs written by older versions are also recognised. + + Returns: + Number of message rows deleted. + """ + if keep_last_n < 0: + keep_last_n = 0 + if markers is None: + markers = ["[SCHEDULED]", "Scheduled task"] + + def _matches_marker(raw_content: str) -> bool: + try: + parsed = json.loads(raw_content) + except Exception: + parsed = raw_content + text = _extract_display_text(parsed) if not isinstance(parsed, str) else parsed + if not text: + return False + return any(text.startswith(m) for m in markers) + + with self._lock: + conn = self._connect() + try: + rows = conn.execute( + """ + SELECT seq, role, content + FROM messages + WHERE session_id = ? + ORDER BY seq ASC + """, + (session_id,), + ).fetchall() + + # Find scheduler pairs: each is (user_seq, assistant_seq?) + pairs: List[tuple] = [] # list of (user_seq, assistant_seq_or_None) + for idx, (seq, role, raw_content) in enumerate(rows): + if role != "user" or not _matches_marker(raw_content): + continue + assistant_seq = None + # Pair with the very next message if it's an assistant turn. + if idx + 1 < len(rows): + next_seq, next_role, _ = rows[idx + 1] + if next_role == "assistant": + assistant_seq = next_seq + pairs.append((seq, assistant_seq)) + + if len(pairs) <= keep_last_n: + return 0 + + to_delete_pairs = pairs[: len(pairs) - keep_last_n] + seqs_to_delete: List[int] = [] + for user_seq, assistant_seq in to_delete_pairs: + seqs_to_delete.append(user_seq) + if assistant_seq is not None: + seqs_to_delete.append(assistant_seq) + + if not seqs_to_delete: + return 0 + + placeholders = ",".join("?" * len(seqs_to_delete)) + with conn: + conn.execute( + f"DELETE FROM messages WHERE session_id = ? AND seq IN ({placeholders})", + (session_id, *seqs_to_delete), + ) + conn.execute( + """ + UPDATE sessions + SET msg_count = ( + SELECT COUNT(*) FROM messages WHERE session_id = ? + ) + WHERE session_id = ? + """, + (session_id, session_id), + ) + return len(seqs_to_delete) + finally: + conn.close() + def cleanup_old_sessions(self, max_age_days: Optional[int] = None) -> int: """ Delete sessions that have not been active within max_age_days. + Web channel sessions are excluded — they are meant to be permanent. Args: max_age_days: Override the default retention period. @@ -433,7 +874,8 @@ class ConversationStore: try: with conn: stale = conn.execute( - "SELECT session_id FROM sessions WHERE last_active < ?", + "SELECT session_id FROM sessions " + "WHERE last_active < ? AND channel_type != 'web'", (cutoff,), ).fetchall() for (sid,) in stale: @@ -451,6 +893,55 @@ class ConversationStore: logger.info(f"[ConversationStore] Pruned {deleted} expired sessions") return deleted + def attach_extras_to_last_assistant( + self, + session_id: str, + extras: Dict[str, Any], + ) -> Optional[int]: + """ + Merge ``extras`` into the latest assistant message of a session. + + Used by post-processing (e.g. TTS) that needs to annotate an already + persisted bot reply with attachments such as audio URLs. + + Returns the message seq that was updated, or ``None`` if no assistant + message exists or the update could not be applied. + """ + if not extras: + return None + with self._lock: + conn = self._connect() + try: + row = conn.execute( + """ + SELECT seq, extras FROM messages + WHERE session_id = ? AND role = 'assistant' + ORDER BY seq DESC LIMIT 1 + """, + (session_id,), + ).fetchone() + if not row: + return None + seq, raw = row + try: + cur = json.loads(raw) if raw else {} + if not isinstance(cur, dict): + cur = {} + except Exception: + cur = {} + cur.update(extras) + conn.execute( + "UPDATE messages SET extras = ? WHERE session_id = ? AND seq = ?", + (json.dumps(cur, ensure_ascii=False), session_id, seq), + ) + conn.commit() + return seq + except Exception as e: + logger.warning(f"[ConversationStore] attach_extras failed: {e}") + return None + finally: + conn.close() + def load_history_page( self, session_id: str, @@ -492,19 +983,75 @@ class ConversationStore: with self._lock: conn = self._connect() try: - rows = conn.execute( - """ - SELECT role, content, created_at - FROM messages - WHERE session_id = ? - ORDER BY seq ASC - """, + ctx_row = conn.execute( + "SELECT context_start_seq FROM sessions WHERE session_id = ?", (session_id,), - ).fetchall() + ).fetchone() + ctx_start = ctx_row[0] if ctx_row else 0 + + # extras column is added by migration; tolerate older DBs that + # might miss it by falling back to a NULL literal. + try: + rows = conn.execute( + """ + SELECT seq, role, content, created_at, extras + FROM messages + WHERE session_id = ? + ORDER BY seq ASC + """, + (session_id,), + ).fetchall() + except sqlite3.OperationalError: + rows = [ + (seq, role, content, created_at, "") + for (seq, role, content, created_at) in conn.execute( + """ + SELECT seq, role, content, created_at + FROM messages + WHERE session_id = ? + ORDER BY seq ASC + """, + (session_id,), + ).fetchall() + ] finally: conn.close() - visible = _group_into_display_turns(rows) + # Honour the current enable_thinking switch when building display turns + # so that toggling it off hides previously-saved thinking blocks too. + try: + from config import conf + include_thinking = bool(conf().get("enable_thinking", False)) + except Exception: + include_thinking = False + + # Strip seq for display grouping, but record max seq per visible user group + plain_rows = [ + (role, content, created_at, extras_raw) + for _seq, role, content, created_at, extras_raw in rows + ] + visible = _group_into_display_turns(plain_rows, include_thinking=include_thinking) + + # Build a mapping: find the seq of each visible user message to annotate context boundary. + # Walk through rows to find visible user message seqs in order. + visible_user_seqs: List[int] = [] + for seq, role, raw_content, _ts, _extras in rows: + if role != "user": + continue + try: + content = json.loads(raw_content) + except Exception: + content = raw_content + if _is_visible_user_message(content): + visible_user_seqs.append(seq) + + # Each pair of display turns (user+assistant) corresponds to a visible user seq. + # Mark which turns are before the context boundary. + user_turn_idx = 0 + for turn in visible: + if turn["role"] == "user" and user_turn_idx < len(visible_user_seqs): + turn["_seq"] = visible_user_seqs[user_turn_idx] + user_turn_idx += 1 total = len(visible) offset = (page - 1) * page_size @@ -513,12 +1060,98 @@ class ConversationStore: return { "messages": page_items, + "context_start_seq": ctx_start, "total": total, "page": page, "page_size": page_size, "has_more": offset + page_size < total, } + def list_sessions( + self, + channel_type: Optional[str] = None, + page: int = 1, + page_size: int = 50, + ) -> Dict[str, Any]: + """ + List sessions ordered by last_active DESC, with optional channel_type filter. + + Returns: + { + "sessions": [{session_id, title, created_at, last_active, msg_count}, ...], + "total": int, + "page": int, + "page_size": int, + "has_more": bool, + } + """ + page = max(1, page) + with self._lock: + conn = self._connect() + try: + if channel_type: + total = conn.execute( + "SELECT COUNT(*) FROM sessions WHERE channel_type = ?", + (channel_type,), + ).fetchone()[0] + rows = conn.execute( + """ + SELECT session_id, title, created_at, last_active, msg_count + FROM sessions + WHERE channel_type = ? + ORDER BY last_active DESC + LIMIT ? OFFSET ? + """, + (channel_type, page_size, (page - 1) * page_size), + ).fetchall() + else: + total = conn.execute( + "SELECT COUNT(*) FROM sessions", + ).fetchone()[0] + rows = conn.execute( + """ + SELECT session_id, title, created_at, last_active, msg_count + FROM sessions + ORDER BY last_active DESC + LIMIT ? OFFSET ? + """, + (page_size, (page - 1) * page_size), + ).fetchall() + finally: + conn.close() + + sessions = [ + { + "session_id": r[0], + "title": r[1], + "created_at": r[2], + "last_active": r[3], + "msg_count": r[4], + } + for r in rows + ] + return { + "sessions": sessions, + "total": total, + "page": page, + "page_size": page_size, + "has_more": (page - 1) * page_size + page_size < total, + } + + def rename_session(self, session_id: str, title: str) -> bool: + """Update the title of a session. Returns True if the session existed.""" + with self._lock: + conn = self._connect() + try: + with conn: + cur = conn.execute( + "UPDATE sessions SET title = ? WHERE session_id = ?", + (title, session_id), + ) + return cur.rowcount > 0 + finally: + conn.close() + def get_stats(self) -> Dict[str, Any]: """Return basic stats keyed by channel_type, for monitoring.""" with self._lock: @@ -573,6 +1206,32 @@ class ConversationStore: logger.info("[ConversationStore] Migrated: added channel_type column") except Exception as e: logger.warning(f"[ConversationStore] Migration failed: {e}") + if "title" not in cols: + try: + conn.execute(_MIGRATION_ADD_TITLE) + conn.commit() + logger.info("[ConversationStore] Migrated: added title column") + except Exception as e: + logger.warning(f"[ConversationStore] Migration (title) failed: {e}") + if "context_start_seq" not in cols: + try: + conn.execute(_MIGRATION_ADD_CONTEXT_START_SEQ) + conn.commit() + logger.info("[ConversationStore] Migrated: added context_start_seq column") + except Exception as e: + logger.warning(f"[ConversationStore] Migration (context_start_seq) failed: {e}") + + msg_cols = { + row[1] + for row in conn.execute("PRAGMA table_info(messages)").fetchall() + } + if "extras" not in msg_cols: + try: + conn.execute(_MIGRATION_ADD_MSG_EXTRAS) + conn.commit() + logger.info("[ConversationStore] Migrated: added messages.extras column") + except Exception as e: + logger.warning(f"[ConversationStore] Migration (extras) failed: {e}") def _connect(self) -> sqlite3.Connection: conn = sqlite3.connect(str(self._db_path), timeout=10) @@ -616,3 +1275,4 @@ def get_conversation_store() -> ConversationStore: _store_instance = ConversationStore(db_path) logger.debug(f"[ConversationStore] Using shared DB at: {db_path}") return _store_instance + diff --git a/agent/memory/embedding.py b/agent/memory/embedding.py deleted file mode 100644 index 1bc1c671..00000000 --- a/agent/memory/embedding.py +++ /dev/null @@ -1,167 +0,0 @@ -""" -Embedding providers for memory - -Supports OpenAI and local embedding models -""" - -import hashlib -from abc import ABC, abstractmethod -from typing import List, Optional - - -class EmbeddingProvider(ABC): - """Base class for embedding providers""" - - @abstractmethod - def embed(self, text: str) -> List[float]: - """Generate embedding for text""" - pass - - @abstractmethod - def embed_batch(self, texts: List[str]) -> List[List[float]]: - """Generate embeddings for multiple texts""" - pass - - @property - @abstractmethod - def dimensions(self) -> int: - """Get embedding dimensions""" - pass - - -class OpenAIEmbeddingProvider(EmbeddingProvider): - """OpenAI embedding provider using REST API""" - - def __init__(self, model: str = "text-embedding-3-small", api_key: Optional[str] = None, - api_base: Optional[str] = None, extra_headers: Optional[dict] = None): - """ - Initialize OpenAI embedding provider - - Args: - model: Model name (text-embedding-3-small or text-embedding-3-large) - api_key: OpenAI API key - api_base: Optional API base URL - extra_headers: Optional extra headers to include in API requests - """ - self.model = model - self.api_key = api_key - self.api_base = api_base or "https://api.openai.com/v1" - self.extra_headers = extra_headers or {} - - # Validate API key - if not self.api_key or self.api_key in ["", "YOUR API KEY", "YOUR_API_KEY"]: - raise ValueError("OpenAI API key is not configured. Please set 'open_ai_api_key' in config.json") - - # Set dimensions based on model - self._dimensions = 1536 if "small" in model else 3072 - - def _call_api(self, input_data): - """Call OpenAI embedding API using requests""" - import requests - - url = f"{self.api_base}/embeddings" - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}", - **self.extra_headers, - } - data = { - "input": input_data, - "model": self.model - } - - try: - response = requests.post(url, headers=headers, json=data, timeout=5) - response.raise_for_status() - return response.json() - except requests.exceptions.ConnectionError as e: - raise ConnectionError(f"Failed to connect to OpenAI API at {url}. Please check your network connection and api_base configuration. Error: {str(e)}") - except requests.exceptions.Timeout as e: - raise TimeoutError(f"OpenAI API request timed out after 10s. Please check your network connection. Error: {str(e)}") - except requests.exceptions.HTTPError as e: - if e.response.status_code == 401: - raise ValueError(f"Invalid OpenAI API key. Please check your 'open_ai_api_key' in config.json") - elif e.response.status_code == 429: - raise ValueError(f"OpenAI API rate limit exceeded. Please try again later.") - else: - raise ValueError(f"OpenAI API request failed: {e.response.status_code} - {e.response.text}") - - def embed(self, text: str) -> List[float]: - """Generate embedding for text""" - result = self._call_api(text) - return result["data"][0]["embedding"] - - def embed_batch(self, texts: List[str]) -> List[List[float]]: - """Generate embeddings for multiple texts""" - if not texts: - return [] - - result = self._call_api(texts) - return [item["embedding"] for item in result["data"]] - - @property - def dimensions(self) -> int: - return self._dimensions - - -# LocalEmbeddingProvider removed - only use OpenAI embedding or keyword search - - -class EmbeddingCache: - """Cache for embeddings to avoid recomputation""" - - def __init__(self): - self.cache = {} - - def get(self, text: str, provider: str, model: str) -> Optional[List[float]]: - """Get cached embedding""" - key = self._compute_key(text, provider, model) - return self.cache.get(key) - - def put(self, text: str, provider: str, model: str, embedding: List[float]): - """Cache embedding""" - key = self._compute_key(text, provider, model) - self.cache[key] = embedding - - @staticmethod - def _compute_key(text: str, provider: str, model: str) -> str: - """Compute cache key""" - content = f"{provider}:{model}:{text}" - return hashlib.md5(content.encode('utf-8')).hexdigest() - - def clear(self): - """Clear cache""" - self.cache.clear() - - -def create_embedding_provider( - provider: str = "openai", - model: Optional[str] = None, - api_key: Optional[str] = None, - api_base: Optional[str] = None, - extra_headers: Optional[dict] = None -) -> EmbeddingProvider: - """ - Factory function to create embedding provider - - Supports "openai" and "linkai" providers (both use OpenAI-compatible REST API). - If initialization fails, caller should fall back to keyword-only search. - - Args: - provider: Provider name ("openai" or "linkai") - model: Model name (default: text-embedding-3-small) - api_key: API key (required) - api_base: API base URL - extra_headers: Optional extra headers to include in API requests - - Returns: - EmbeddingProvider instance - - Raises: - ValueError: If provider is unsupported or api_key is missing - """ - if provider not in ("openai", "linkai"): - raise ValueError(f"Unsupported embedding provider: {provider}. Use 'openai' or 'linkai'.") - - model = model or "text-embedding-3-small" - return OpenAIEmbeddingProvider(model=model, api_key=api_key, api_base=api_base, extra_headers=extra_headers) diff --git a/agent/memory/embedding/__init__.py b/agent/memory/embedding/__init__.py new file mode 100644 index 00000000..f89bc216 --- /dev/null +++ b/agent/memory/embedding/__init__.py @@ -0,0 +1,41 @@ +""" +Embedding subsystem for memory. + +Public API: + create_embedding_provider, EmbeddingProvider, OpenAIEmbeddingProvider, + EMBEDDING_VENDORS, EmbeddingCache + RebuildResult, clear_index, rebuild_in_process + detect_index_dim, cleanup_legacy_state_file +""" + +from agent.memory.embedding.provider import ( + EMBEDDING_VENDORS, + DoubaoEmbeddingProvider, + EmbeddingCache, + EmbeddingProvider, + OpenAIEmbeddingProvider, + create_embedding_provider, +) +from agent.memory.embedding.rebuild import ( + RebuildResult, + clear_index, + rebuild_in_process, +) +from agent.memory.embedding.state import ( + cleanup_legacy_state_file, + detect_index_dim, +) + +__all__ = [ + "EMBEDDING_VENDORS", + "DoubaoEmbeddingProvider", + "EmbeddingCache", + "EmbeddingProvider", + "OpenAIEmbeddingProvider", + "create_embedding_provider", + "RebuildResult", + "clear_index", + "rebuild_in_process", + "cleanup_legacy_state_file", + "detect_index_dim", +] diff --git a/agent/memory/embedding/provider.py b/agent/memory/embedding/provider.py new file mode 100644 index 00000000..6fcaf132 --- /dev/null +++ b/agent/memory/embedding/provider.py @@ -0,0 +1,515 @@ +""" +Embedding providers for memory + +Supports multiple OpenAI-compatible embedding vendors: + - openai (text-embedding-3-small / large) + - linkai (OpenAI-compatible passthrough) + - dashscope (Aliyun Tongyi text-embedding-v4) + - doubao (ByteDance Doubao Seed1.5 / large-text on Volcengine Ark) + - zhipu (ZhipuAI embedding-3) + - custom (any OpenAI-compatible endpoint) + +Vendor keys here intentionally match the project's bot_type constants in +common.const (OPENAI, LINKAI, QWEN_DASHSCOPE, DOUBAO, ZHIPU_AI). + +Custom providers (bot_type "custom" or "custom:") reuse the same +OpenAI-compatible REST client with user-supplied api_key / api_base. + +All providers share a single OpenAI-compatible REST client. Vendor-specific +behaviors (truncation, query instruction prefix) are configured via metadata. +""" + +import hashlib +import math +from abc import ABC, abstractmethod +from typing import List, Optional + +# HTTP read timeout for a single embeddings request (seconds). A batch of +# 64+ chunks can take 30-50s end-to-end from China-side networks, so 30s is +# routinely too tight; 90s gives meaningful headroom without letting bad +# endpoints hang forever. +EMBEDDING_HTTP_TIMEOUT = 90 + + +class EmbeddingProvider(ABC): + """Base class for embedding providers""" + + @abstractmethod + def embed(self, text: str) -> List[float]: + """Generate embedding for a single text (treated as a query by default)""" + pass + + @abstractmethod + def embed_batch(self, texts: List[str]) -> List[List[float]]: + """Generate embeddings for multiple texts (treated as documents)""" + pass + + def embed_query(self, text: str) -> List[float]: + """Generate embedding for a query string (may apply vendor instruction prefix)""" + return self.embed(text) + + @property + @abstractmethod + def dimensions(self) -> int: + """Effective embedding dimensions""" + pass + + +# --------------------------------------------------------------------------- +# Vendor metadata table +# --------------------------------------------------------------------------- +# +# Each entry describes how to reach a vendor's embedding endpoint. Most +# vendors expose an OpenAI-compatible /embeddings API; the few that don't +# (currently: doubao) set `provider_class` to pick a dedicated adapter. +# Fields: +# provider_class : optional adapter key ("doubao"); defaults to OpenAI-compat +# default_base_url : default API base when not overridden by user +# default_model : default embedding model name +# default_dimensions : recommended unified dim when explicit path is enabled +# supports_dim_param : whether the API accepts a `dimensions` request param +# needs_client_truncate : whether to slice + L2-normalize on the client side +# needs_client_normalize : whether to L2-normalize on the client (always safe) +# query_instruction : optional prefix for asymmetric retrieval (Doubao Seed) +# max_batch_size : max texts per /embeddings request; embed_batch +# auto-paginates above this. Conservative defaults. +# +EMBEDDING_VENDORS = { + "openai": { + "default_base_url": "https://api.openai.com/v1", + "default_model": "text-embedding-3-small", + # Match the legacy default so users adding `embedding_provider: openai` + # to an existing index don't need to rebuild. Override via + # embedding_dimensions if you want 1024 / 1536 / 3072. + "default_dimensions": 1536, + "supports_dim_param": True, + "needs_client_truncate": False, + "needs_client_normalize": False, + "query_instruction": "", + # OpenAI permits up to 2048 items per request, but a single call + # carrying hundreds of long chunks routinely exceeds the 30s read + # timeout from China-side networks. 64 keeps each call well under + # both the token-per-request budget and a reasonable wall clock. + "max_batch_size": 64, + }, + "linkai": { + "default_base_url": "https://api.link-ai.tech/v1", + "default_model": "text-embedding-3-small", + "default_dimensions": 1536, + "supports_dim_param": True, + "needs_client_truncate": False, + "needs_client_normalize": False, + "query_instruction": "", + "max_batch_size": 64, + }, + "dashscope": { + "default_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "default_model": "text-embedding-v4", + "default_dimensions": 1024, + "supports_dim_param": True, + "needs_client_truncate": False, + "needs_client_normalize": False, + "query_instruction": "", + "max_batch_size": 10, # DashScope hard cap (text-embedding-v4) + }, + "doubao": { + # Doubao no longer offers an OpenAI-compatible /v1/embeddings endpoint. + # Current models are unified under /api/v3/embeddings/multimodal + # which uses a structured `input` payload — see DoubaoEmbeddingProvider. + "provider_class": "doubao", + "default_base_url": "https://ark.cn-beijing.volces.com/api/v3", + "default_model": "doubao-embedding-vision-251215", + # Native options: 1024 or 2048. We default to 1024 to align with the + # other Chinese vendors (dashscope/zhipu) and keep storage footprint + # consistent across providers; users can still override via + # `embedding_dimensions: 2048` in config. + "default_dimensions": 1024, + "supports_dim_param": True, + "needs_client_truncate": False, + "needs_client_normalize": False, + "query_instruction": "", + # Multimodal endpoint produces ONE embedding per call (input list is + # a single document's parts, not a batch). embed_batch loops. + "max_batch_size": 1, + }, + "zhipu": { + "default_base_url": "https://open.bigmodel.cn/api/paas/v4", + "default_model": "embedding-3", + "default_dimensions": 1024, + "supports_dim_param": True, + "needs_client_truncate": False, + "needs_client_normalize": False, + "query_instruction": "", + "max_batch_size": 64, + }, + # Custom provider — any OpenAI-compatible /embeddings endpoint. The + # user must supply api_key + api_base + model via the web console + # (stored in custom_providers list or legacy custom_api_key / custom_api_base). + # Dimensions defaults to 1024 but can be overridden via config's + # embedding_dimensions. No dim-param support assumption — safest + # default for unknown endpoints. + "custom": { + "default_base_url": "", + "default_model": "", + "default_dimensions": 1024, + "supports_dim_param": False, + "needs_client_truncate": False, + "needs_client_normalize": True, + "query_instruction": "", + "max_batch_size": 64, + }, +} + + +def _l2_normalize(vec: List[float]) -> List[float]: + """Normalize a vector to unit length (L2 norm). Returns input on zero vector.""" + norm = math.sqrt(sum(v * v for v in vec)) + if norm == 0: + return vec + return [v / norm for v in vec] + + +class OpenAIEmbeddingProvider(EmbeddingProvider): + """ + OpenAI-compatible embedding provider. + + Used for openai/linkai/dashscope/ark/zhipu by configuring the metadata + fields. The legacy two-arg constructor (model, api_key, api_base) keeps + working, so the original OpenAI/LinkAI fallback code path is unchanged. + """ + + def __init__( + self, + model: str = "text-embedding-3-small", + api_key: Optional[str] = None, + api_base: Optional[str] = None, + extra_headers: Optional[dict] = None, + dimensions: Optional[int] = None, + supports_dim_param: bool = True, + needs_client_truncate: bool = False, + needs_client_normalize: bool = False, + query_instruction: str = "", + max_batch_size: int = 256, + ): + """ + Args: + model: Model name (e.g. text-embedding-3-small, text-embedding-v4, embedding-3) + api_key: API key (required) + api_base: API base URL (defaults to OpenAI) + extra_headers: Optional extra HTTP headers + dimensions: Target output dimension. Required when supports_dim_param + is False and needs_client_truncate is True (used to slice). + supports_dim_param: Whether the vendor accepts a `dimensions` body param + needs_client_truncate: Slice the returned vector to `dimensions` + needs_client_normalize: L2-normalize on the client after slicing + query_instruction: Optional prefix prepended to query texts only + max_batch_size: Max items per /embeddings request; embed_batch + auto-paginates above this. + """ + self.model = model + self.api_key = api_key + self.api_base = api_base or "https://api.openai.com/v1" + self.extra_headers = extra_headers or {} + self.supports_dim_param = supports_dim_param + self.needs_client_truncate = needs_client_truncate + self.needs_client_normalize = needs_client_normalize + self.query_instruction = query_instruction or "" + self.max_batch_size = max(1, int(max_batch_size or 1)) + + if not self.api_key or self.api_key in ["", "YOUR API KEY", "YOUR_API_KEY"]: + raise ValueError("Embedding API key is not configured") + + if dimensions is not None and dimensions > 0: + self._dimensions = dimensions + else: + # Legacy heuristic for OpenAI text-embedding-3-* family + self._dimensions = 1536 if "small" in model else 3072 + + def _call_api(self, input_data): + """Call OpenAI-compatible /embeddings endpoint""" + import requests + + url = f"{self.api_base}/embeddings" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + **self.extra_headers, + } + data = { + "input": input_data, + "model": self.model, + } + if self.supports_dim_param and self._dimensions: + data["dimensions"] = self._dimensions + + try: + response = requests.post(url, headers=headers, json=data, timeout=EMBEDDING_HTTP_TIMEOUT) + response.raise_for_status() + return response.json() + except requests.exceptions.ConnectionError as e: + raise ConnectionError( + f"Failed to connect to embedding API at {url}. " + f"Please check network and api_base. Error: {str(e)}" + ) + except requests.exceptions.Timeout as e: + raise TimeoutError(f"Embedding API request timed out. Error: {str(e)}") + except requests.exceptions.HTTPError as e: + if e.response.status_code == 401: + raise ValueError("Invalid embedding API key") + elif e.response.status_code == 429: + raise ValueError("Embedding API rate limit exceeded") + else: + raise ValueError( + f"Embedding API request failed: " + f"{e.response.status_code} - {e.response.text}" + ) + + def _post_process(self, raw: List[float]) -> List[float]: + """Apply optional client-side truncation + normalization""" + vec = raw + if self.needs_client_truncate and self._dimensions and len(vec) > self._dimensions: + vec = vec[: self._dimensions] + if self.needs_client_normalize: + vec = _l2_normalize(vec) + return vec + + def embed(self, text: str) -> List[float]: + """Generate embedding (treated as document by default)""" + result = self._call_api(text) + return self._post_process(result["data"][0]["embedding"]) + + def embed_query(self, text: str) -> List[float]: + """Generate embedding for a query (applies vendor instruction prefix if any)""" + if self.query_instruction: + text = f"{self.query_instruction}{text}" + return self.embed(text) + + def embed_batch(self, texts: List[str]) -> List[List[float]]: + """Generate embeddings for multiple documents. + + Automatically paginates by self.max_batch_size so callers can pass any + number of texts. Order of returned vectors matches the input order. + """ + if not texts: + return [] + out: List[List[float]] = [] + step = self.max_batch_size + for i in range(0, len(texts), step): + chunk = texts[i:i + step] + result = self._call_api(chunk) + out.extend(self._post_process(item["embedding"]) for item in result["data"]) + return out + + @property + def dimensions(self) -> int: + return self._dimensions + + +class DoubaoEmbeddingProvider(EmbeddingProvider): + """ + Doubao (Volcengine Ark) multimodal embedding provider. + + Doubao deprecated their OpenAI-compatible /v1/embeddings endpoint and + unified everything under /api/v3/embeddings/multimodal, which uses a + structured `input: [{type, text|image_url|video_url}, ...]` payload. + + Notes: + * The endpoint produces ONE embedding per call (input list is multiple + modality parts of a single document, not a batch). embed_batch + therefore loops per-text — no native batch support. + * Native dimensions: 1024 or 2048 (default 1024 to align with other + Chinese vendors). No client-side truncation needed. + * Auth: Bearer ARK API key. + """ + + def __init__( + self, + model: str, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + extra_headers: Optional[dict] = None, + dimensions: Optional[int] = None, + ): + self.model = model + self.api_key = api_key + self.api_base = api_base or "https://ark.cn-beijing.volces.com/api/v3" + self.extra_headers = extra_headers or {} + if not self.api_key or self.api_key in ["", "YOUR API KEY", "YOUR_API_KEY"]: + raise ValueError("Doubao embedding API key (ark_api_key) is not configured") + + if dimensions in (1024, 2048): + self._dimensions = dimensions + elif dimensions is None: + self._dimensions = 1024 + else: + raise ValueError( + f"Doubao embedding dimensions must be 1024 or 2048, got {dimensions}" + ) + + def _call_api(self, text: str) -> List[float]: + """One call → one embedding. multimodal endpoint takes a single + document represented as a list of typed parts; we send a single + text part.""" + import requests + + url = f"{self.api_base}/embeddings/multimodal" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + **self.extra_headers, + } + payload = { + "model": self.model, + "input": [{"type": "text", "text": text}], + "dimensions": self._dimensions, + "encoding_format": "float", + } + + try: + response = requests.post(url, headers=headers, json=payload, timeout=EMBEDDING_HTTP_TIMEOUT) + response.raise_for_status() + body = response.json() + except requests.exceptions.ConnectionError as e: + raise ConnectionError( + f"Failed to connect to Doubao embedding API at {url}. " + f"Please check network and api_base. Error: {str(e)}" + ) + except requests.exceptions.Timeout as e: + raise TimeoutError(f"Doubao embedding API request timed out. Error: {str(e)}") + except requests.exceptions.HTTPError as e: + if e.response.status_code == 401: + raise ValueError("Invalid Doubao (ark) embedding API key") + elif e.response.status_code == 429: + raise ValueError("Doubao embedding API rate limit exceeded") + else: + raise ValueError( + f"Doubao embedding API request failed: " + f"{e.response.status_code} - {e.response.text}" + ) + + # Response shape per docs: {"data": {"embedding": [...]}} + data = body.get("data") + if isinstance(data, dict) and "embedding" in data: + return data["embedding"] + # Some providers wrap as a list of one — be defensive + if isinstance(data, list) and data and "embedding" in data[0]: + return data[0]["embedding"] + raise ValueError(f"Unexpected Doubao embedding response shape: {body}") + + def embed(self, text: str) -> List[float]: + return self._call_api(text) + + def embed_batch(self, texts: List[str]) -> List[List[float]]: + # Endpoint produces one embedding per call; loop. Order preserved. + return [self._call_api(t) for t in texts] + + @property + def dimensions(self) -> int: + return self._dimensions + + +class EmbeddingCache: + """In-memory cache for embeddings to avoid recomputation""" + + def __init__(self): + self.cache = {} + + def get(self, text: str, provider: str, model: str) -> Optional[List[float]]: + key = self._compute_key(text, provider, model) + return self.cache.get(key) + + def put(self, text: str, provider: str, model: str, embedding: List[float]): + key = self._compute_key(text, provider, model) + self.cache[key] = embedding + + @staticmethod + def _compute_key(text: str, provider: str, model: str) -> str: + content = f"{provider}:{model}:{text}" + return hashlib.md5(content.encode("utf-8")).hexdigest() + + def clear(self): + self.cache.clear() + + +def create_embedding_provider( + provider: str = "openai", + model: Optional[str] = None, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + extra_headers: Optional[dict] = None, + dimensions: Optional[int] = None, +) -> EmbeddingProvider: + """ + Factory function to create an embedding provider. + + Backward compatible: when called with provider in {"openai", "linkai"} + and no `dimensions` arg, behaves exactly as before (1536-dim OpenAI). + + New providers ("dashscope", "doubao", "zhipu") require explicit configuration + and use the unified 1024-dim defaults from EMBEDDING_VENDORS. + + Args: + provider: Vendor key (one of EMBEDDING_VENDORS) + model: Model name (uses vendor default if None) + api_key: API key (required) + api_base: API base URL (uses vendor default if None) + extra_headers: Optional extra HTTP headers + dimensions: Target output dimension (uses vendor default if None) + + Returns: + EmbeddingProvider instance + """ + meta = EMBEDDING_VENDORS.get(provider) + if meta is None: + raise ValueError( + f"Unsupported embedding provider: {provider}. " + f"Supported: {sorted(EMBEDDING_VENDORS.keys())}" + ) + + # Doubao uses a non-OpenAI-compatible multimodal endpoint. + if meta.get("provider_class") == "doubao": + final_dim = dimensions if (dimensions and dimensions > 0) else meta["default_dimensions"] + return DoubaoEmbeddingProvider( + model=model or meta["default_model"], + api_key=api_key, + api_base=api_base or meta["default_base_url"], + extra_headers=extra_headers, + dimensions=final_dim, + ) + + # Legacy two-arg call for openai/linkai keeps 1536-dim default behavior + # so existing data isn't invalidated. + is_legacy_call = ( + provider in ("openai", "linkai") + and dimensions is None + ) + if is_legacy_call: + return OpenAIEmbeddingProvider( + model=model or "text-embedding-3-small", + api_key=api_key, + api_base=api_base, + extra_headers=extra_headers, + ) + + final_dim = dimensions if (dimensions and dimensions > 0) else meta["default_dimensions"] + resolved_model = model or meta["default_model"] + resolved_base = api_base or meta["default_base_url"] + # Custom providers require explicit api_base and model — they cannot + # fall back to OpenAI defaults like built-in vendors do. + if provider == "custom": + if not resolved_base: + raise ValueError("Custom embedding provider requires an api_base URL") + if not resolved_model: + raise ValueError("Custom embedding provider requires a model name") + return OpenAIEmbeddingProvider( + model=resolved_model, + api_key=api_key, + api_base=resolved_base, + extra_headers=extra_headers, + dimensions=final_dim, + supports_dim_param=meta["supports_dim_param"], + needs_client_truncate=meta["needs_client_truncate"], + needs_client_normalize=meta["needs_client_normalize"], + query_instruction=meta["query_instruction"], + max_batch_size=meta.get("max_batch_size", 256), + ) diff --git a/agent/memory/embedding/rebuild.py b/agent/memory/embedding/rebuild.py new file mode 100644 index 00000000..e5b592ab --- /dev/null +++ b/agent/memory/embedding/rebuild.py @@ -0,0 +1,191 @@ +""" +Rebuild memory vector index. + +Recommended entry point (in-chat, while agent is running): + /memory rebuild-index + +Backward-compatible CLI entry (must run from project root): + python -m agent.memory.rebuild_index + +What it does: + 1. Probes the embedding endpoint with a tiny call to fail fast on + bad provider/model/key — before touching the index. + 2. Clears the SQLite chunks/files tables (workspace markdown stays intact). + 3. Runs a fresh sync, regenerating embeddings with the currently configured + provider/model/dimensions. + +This is the only safe way to switch embedding_provider after the existing +index has been populated by a different-dim model. +""" + +from __future__ import annotations +import asyncio +import sys +from dataclasses import dataclass +from typing import Optional + +from common.log import logger +from common.utils import expand_path + + +@dataclass +class RebuildResult: + """Outcome of a rebuild_in_process() call""" + ok: bool + removed: int = 0 + chunks: int = 0 + files: int = 0 + error: Optional[str] = None + + +def clear_index(db_path, storage=None) -> int: + """Wipe chunks/files, reset FTS5, and clean up any legacy state file. + + Args: + db_path: Path of the index DB (also used to locate the legacy state + file for migration cleanup, and — when *storage* is None — to + open a fresh connection). + storage: Optional pre-opened MemoryStorage. When provided we reuse it + so the live connection's triggers stay in sync — opening a second + connection would leave the original one's triggers pointing at a + DROP'd chunks_fts table. + + We reset (DROP+recreate) chunks_fts because its shadow tables can become + inconsistent across rebuild cycles, causing bm25() / ORDER BY rank to + raise "database disk image is malformed" even when raw MATCH still works. + + Returns number of chunks removed. + """ + from agent.memory.embedding.state import cleanup_legacy_state_file + from agent.memory.storage import MemoryStorage + + owns_storage = storage is None + if owns_storage: + storage = MemoryStorage(db_path) + try: + before = storage.conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0] + storage.conn.execute("DELETE FROM chunks") + storage.conn.execute("DELETE FROM files") + storage.conn.commit() + storage.reset_fts5() + finally: + if owns_storage: + storage.close() + + cleanup_legacy_state_file(db_path) + return int(before) + + +def rebuild_in_process(memory_manager) -> RebuildResult: + """ + Rebuild the index using an existing, fully-initialized MemoryManager. + + Used by the in-chat /memory rebuild-index command. The caller already has + config loaded, embedding_provider built, and (optionally) the agent + running, so we only need to: + 1. Clear chunks/files + state on the manager's storage. + 2. Re-sync (force=True). + + NOTE: caller must ensure memory_manager.embedding_provider is set, otherwise + sync() will silently skip embedding generation. + """ + if memory_manager is None: + return RebuildResult(ok=False, error="memory_manager is None") + if memory_manager.embedding_provider is None: + return RebuildResult(ok=False, error="embedding_provider is not initialized") + + # Probe the embedding endpoint BEFORE clearing the index. A bad + # provider/model/key would otherwise leave the user with an empty index + # that not even keyword search can serve. + try: + memory_manager.embedding_provider.embed_query("ping") + except Exception as e: + logger.error(f"[RebuildIndex] embedding probe failed, aborting rebuild: {e}") + return RebuildResult(ok=False, error=f"embedding endpoint not reachable: {e}") + + db_path = memory_manager.config.get_db_path() + try: + removed = clear_index(db_path, storage=memory_manager.storage) + except Exception as e: + logger.exception("[RebuildIndex] clear_index failed") + return RebuildResult(ok=False, error=f"clear failed: {e}") + + try: + asyncio.run(memory_manager.sync(force=True)) + except RuntimeError: + # Already inside a running event loop (rare in chat handler thread). + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(memory_manager.sync(force=True)) + finally: + loop.close() + except Exception as e: + logger.exception("[RebuildIndex] sync failed") + return RebuildResult(ok=False, removed=removed, error=f"re-embed failed: {e}") + + stats = memory_manager.storage.get_stats() + chunks = int(stats.get("chunks", 0)) + embedded = int(stats.get("embedded", 0)) + + # sync() degrades to "no embeddings" on batch failure so keyword search + # still works at startup — but in a /rebuild-index request the user + # explicitly asked for vectors. Surface that as a failure. + if chunks > 0 and embedded == 0: + return RebuildResult( + ok=False, + removed=removed, + chunks=chunks, + files=int(stats.get("files", 0)), + error=( + "embedding API failed during sync; index now has chunks but no " + "vectors. Check embedding provider/model/key and retry." + ), + ) + + return RebuildResult( + ok=True, + removed=removed, + chunks=chunks, + files=int(stats.get("files", 0)), + ) + + +def main() -> int: + """Standalone CLI entry. Must be run from project root (relative config path).""" + from config import conf, load_config + from agent.memory import MemoryConfig, MemoryManager + + load_config() + + workspace_root = expand_path(conf().get("agent_workspace", "~/cow")) + memory_config = MemoryConfig(workspace_root=workspace_root) + + logger.info(f"[RebuildIndex] Workspace: {workspace_root}") + logger.info(f"[RebuildIndex] Index db: {memory_config.get_db_path()}") + + from bridge.agent_initializer import AgentInitializer + + initializer = AgentInitializer(bridge=None, agent_bridge=None) + embedding_provider = initializer._init_embedding_provider(memory_config, session_id=None) + if embedding_provider is None: + logger.error( + "[RebuildIndex] No embedding provider could be initialized. " + "Check your config.json. Aborting rebuild." + ) + return 1 + + manager = MemoryManager(memory_config, embedding_provider=embedding_provider) + result = rebuild_in_process(manager) + if not result.ok: + logger.error(f"[RebuildIndex] {result.error}") + return 1 + + logger.info( + f"[RebuildIndex] Done. removed={result.removed}, " + f"chunks={result.chunks}, files={result.files}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agent/memory/embedding/state.py b/agent/memory/embedding/state.py new file mode 100644 index 00000000..5efffef2 --- /dev/null +++ b/agent/memory/embedding/state.py @@ -0,0 +1,51 @@ +""" +Embedding-related index utilities. + +We don't keep a sidecar state file — the SQLite index is the source of truth +and config.json is the source of intent. The two functions below are the +only things needing on-disk awareness: + + detect_index_dim : read the dim of stored vectors (display-only) + cleanup_legacy_state_file: remove old embedding_state.json from earlier + versions; safe no-op when absent. +""" + +from __future__ import annotations +import json +import os +from pathlib import Path +from typing import Optional, Union + +PathLike = Union[str, os.PathLike] + + +def detect_index_dim(storage) -> Optional[int]: + """Return the dim of the first stored embedding, or None if the index + has no embeddings. Used by /memory status.""" + try: + row = storage.conn.execute( + "SELECT embedding FROM chunks WHERE embedding IS NOT NULL LIMIT 1" + ).fetchone() + except Exception: + return None + if not row or not row["embedding"]: + return None + try: + raw = row["embedding"] + if isinstance(raw, (bytes, bytearray)): + # New BLOB format: 4 bytes per float32 + return len(raw) // 4 + emb = json.loads(raw) + return len(emb) if isinstance(emb, list) else None + except (json.JSONDecodeError, TypeError, Exception): + return None + + +def cleanup_legacy_state_file(db_path: PathLike) -> None: + """Remove old embedding_state.json files from earlier versions. + Safe to call repeatedly; no-op if the file is absent.""" + legacy = Path(db_path).parent / "embedding_state.json" + try: + legacy.unlink(missing_ok=True) + except Exception: + pass diff --git a/agent/memory/manager.py b/agent/memory/manager.py index 197c9ffd..5ec2ade7 100644 --- a/agent/memory/manager.py +++ b/agent/memory/manager.py @@ -13,7 +13,7 @@ from datetime import datetime, timedelta from agent.memory.config import MemoryConfig, get_default_memory_config from agent.memory.storage import MemoryStorage, MemoryChunk, SearchResult from agent.memory.chunker import TextChunker -from agent.memory.embedding import create_embedding_provider, EmbeddingProvider +from agent.memory.embedding import EmbeddingProvider, EmbeddingCache from agent.memory.summarizer import MemoryFlushManager, create_memory_files_if_needed @@ -50,50 +50,22 @@ class MemoryManager: overlap_tokens=self.config.chunk_overlap_tokens ) - # Initialize embedding provider (optional, prefer OpenAI, fallback to LinkAI) - self.embedding_provider = None - if embedding_provider: - self.embedding_provider = embedding_provider - else: - # Try OpenAI first - try: - api_key = os.environ.get('OPENAI_API_KEY') - api_base = os.environ.get('OPENAI_API_BASE') - if api_key: - self.embedding_provider = create_embedding_provider( - provider="openai", - model=self.config.embedding_model, - api_key=api_key, - api_base=api_base - ) - except Exception as e: - from common.log import logger - logger.warning(f"[MemoryManager] OpenAI embedding failed: {e}") + # Embedding provider is owned by the caller (agent_initializer is the + # canonical entry point and handles legacy/explicit + state validation). + # When None is passed, memory degrades to keyword-only search instead + # of silently re-initializing a vendor here, which would bypass the + # caller's state checks and risk corrupting the index. + self.embedding_provider = embedding_provider + if self.embedding_provider is None: + from common.log import logger + logger.info( + "[MemoryManager] No embedding provider; memory will use keyword search only" + ) + + # Cache for query embeddings (avoids redundant API calls within a session) + self._embedding_cache = EmbeddingCache() - # Fallback to LinkAI - if self.embedding_provider is None: - try: - linkai_key = os.environ.get('LINKAI_API_KEY') - linkai_base = os.environ.get('LINKAI_API_BASE', 'https://api.link-ai.tech') - if linkai_key: - from common.utils import get_cloud_headers - cloud_headers = get_cloud_headers(linkai_key) - cloud_headers.pop("Authorization", None) - self.embedding_provider = create_embedding_provider( - provider="linkai", - model=self.config.embedding_model, - api_key=linkai_key, - api_base=f"{linkai_base}/v1", - extra_headers=cloud_headers, - ) - except Exception as e: - from common.log import logger - logger.warning(f"[MemoryManager] LinkAI embedding failed: {e}") - if self.embedding_provider is None: - from common.log import logger - logger.info(f"[MemoryManager] Memory will work with keyword search only (no vector search)") - # Initialize memory flush manager workspace_dir = self.config.get_workspace() self.flush_manager = MemoryFlushManager( @@ -153,12 +125,21 @@ class MemoryManager: if self.config.sync_on_search and self._dirty: await self.sync() - # Perform vector search (if embedding provider available) + from common.log import logger + + # Perform vector search (if embedding provider available). + # Failures degrade silently to keyword-only — no exception is raised. vector_results = [] if self.embedding_provider: try: - from common.log import logger - query_embedding = self.embedding_provider.embed(query) + provider_name = type(self.embedding_provider).__name__ + model_name = getattr(self.embedding_provider, 'model', '') + cached = self._embedding_cache.get(query, provider_name, model_name) + if cached is not None: + query_embedding = cached + else: + query_embedding = self.embedding_provider.embed_query(query) + self._embedding_cache.put(query, provider_name, model_name, query_embedding) vector_results = self.storage.search_vector( query_embedding=query_embedding, user_id=user_id, @@ -167,19 +148,19 @@ class MemoryManager: ) logger.info(f"[MemoryManager] Vector search found {len(vector_results)} results for query: {query}") except Exception as e: - from common.log import logger - logger.warning(f"[MemoryManager] Vector search failed: {e}") - - # Perform keyword search + logger.error( + f"[MemoryManager] Vector search failed, falling back to keyword-only: {e}" + ) + + # Perform keyword search (also runs as fallback when vector failed) keyword_results = self.storage.search_keyword( query=query, user_id=user_id, scopes=scopes, limit=max_results * 2 ) - from common.log import logger logger.info(f"[MemoryManager] Keyword search found {len(keyword_results)} results for query: {query}") - + # Merge results merged = self._merge_results( vector_results, @@ -187,7 +168,7 @@ class MemoryManager: self.config.vector_weight, self.config.keyword_weight ) - + # Filter by min score and limit filtered = [r for r in merged if r.score >= min_score] return filtered[:max_results] @@ -269,144 +250,191 @@ class MemoryManager: async def sync(self, force: bool = False): """ - Synchronize memory from files - + Synchronize memory from files. + + Two-pass design to amortize embedding HTTP cost: + 1. Walk all files, chunk those whose hash changed, collect pending + chunks across files. No embedding calls yet. + 2. Run a single embed_batch over the union of pending chunks (the + provider auto-paginates by vendor cap), then persist per-file. + + For workspaces with many small files (101 files / ~1 chunk each), this + cuts ~100 HTTP calls down to ~ceil(total_chunks / vendor_cap). + Args: force: Force full reindex """ memory_dir = self.config.get_memory_dir() workspace_dir = self.config.get_workspace() - - # Scan MEMORY.md (workspace root) + + files_to_scan: List[tuple] = [] # (file_path, source, scope, user_id) + memory_file = Path(workspace_dir) / "MEMORY.md" if memory_file.exists(): - await self._sync_file(memory_file, "memory", "shared", None) - - # Scan memory directory (including daily summaries) + files_to_scan.append((memory_file, "memory", "shared", None)) + if memory_dir.exists(): for file_path in memory_dir.rglob("*.md"): - # Determine scope and user_id from path - rel_path = file_path.relative_to(workspace_dir) - parts = rel_path.parts - - # Check if it's in daily summary directory - if "daily" in parts: - # Daily summary files - if "users" in parts or len(parts) > 3: - # User-scoped daily summary: memory/daily/{user_id}/2024-01-29.md - user_idx = parts.index("daily") + 1 - user_id = parts[user_idx] if user_idx < len(parts) else None + rel_parts = file_path.relative_to(workspace_dir).parts + if any(part.startswith('.') for part in rel_parts): + continue + # Dream diaries are narrative reflections produced by Deep + # Dream; their factual content has already been distilled + # into MEMORY.md. Indexing them adds noisy near-duplicates + # that crowd out the authoritative entry in retrieval. + if "dreams" in rel_parts: + continue + if "daily" in rel_parts: + if "users" in rel_parts or len(rel_parts) > 3: + user_idx = rel_parts.index("daily") + 1 + user_id = rel_parts[user_idx] if user_idx < len(rel_parts) else None scope = "user" else: - # Shared daily summary: memory/daily/2024-01-29.md user_id = None scope = "shared" - elif "users" in parts: - # User-scoped memory - user_idx = parts.index("users") + 1 - user_id = parts[user_idx] if user_idx < len(parts) else None + elif "users" in rel_parts: + user_idx = rel_parts.index("users") + 1 + user_id = rel_parts[user_idx] if user_idx < len(rel_parts) else None scope = "user" else: - # Shared memory user_id = None scope = "shared" - - await self._sync_file(file_path, "memory", scope, user_id) - - self._dirty = False - - async def _sync_file( - self, - file_path: Path, - source: str, - scope: str, - user_id: Optional[str] - ): - """Sync a single file""" - # Compute file hash - content = file_path.read_text(encoding='utf-8') - file_hash = MemoryStorage.compute_hash(content) - - # Get relative path - workspace_dir = self.config.get_workspace() - rel_path = str(file_path.relative_to(workspace_dir)) - - # Check if file changed - stored_hash = self.storage.get_file_hash(rel_path) - if stored_hash == file_hash: - return # No changes - - # Delete old chunks - self.storage.delete_by_path(rel_path) - - # Chunk and embed - chunks = self.chunker.chunk_text(content) - if not chunks: + files_to_scan.append((file_path, "memory", scope, user_id)) + + from config import conf + if conf().get("knowledge", True): + knowledge_dir = Path(workspace_dir) / "knowledge" + if knowledge_dir.exists(): + for file_path in knowledge_dir.rglob("*.md"): + files_to_scan.append((file_path, "knowledge", "shared", None)) + + # Pass 1: inline chunking + change detection. Inlined (instead of + # calling self._prepare_file_for_sync) so this method does not depend + # on any sibling helpers — keeps it robust against partial reloads + # where the class object is older than the method's source. + pending: List[Dict[str, Any]] = [] + workspace_dir_path = self.config.get_workspace() + for file_path, source, scope, user_id in files_to_scan: + try: + content = file_path.read_text(encoding='utf-8') + except Exception: + continue + file_hash = MemoryStorage.compute_hash(content) + rel_path = str(file_path.relative_to(workspace_dir_path)) + if self.storage.get_file_hash(rel_path) == file_hash: + continue + chunks = self.chunker.chunk_text(content) + if not chunks: + continue + pending.append({ + "file_path": file_path, + "rel_path": rel_path, + "source": source, + "scope": scope, + "user_id": user_id, + "file_hash": file_hash, + "chunks": chunks, + "texts": [c.text for c in chunks], + }) + + if not pending: + self._dirty = False return - - texts = [chunk.text for chunk in chunks] - if self.embedding_provider: - embeddings = self.embedding_provider.embed_batch(texts) + + # Pass 2: single batched embed across all pending chunks. + # CRITICAL: never touch the index until we hold valid embeddings. + # If embed_batch fails, leave the existing index intact (chunks + + # file_hash) so the next sync will retry the same files. Writing + # NULL embeddings + updating file_hash here would mark the file as + # "successfully synced" and silently strand it without vectors. + all_texts: List[str] = [] + for entry in pending: + all_texts.extend(entry["texts"]) + + if not self.embedding_provider: + # No provider configured at all (legacy keyword-only). Persist + # chunks without embeddings — this is the user's intent. + all_embeddings: List[Optional[List[float]]] = [None] * len(all_texts) else: - embeddings = [None] * len(texts) - - # Create memory chunks - memory_chunks = [] - for chunk, embedding in zip(chunks, embeddings): - chunk_id = self._generate_chunk_id(rel_path, chunk.start_line, chunk.end_line) - chunk_hash = MemoryStorage.compute_hash(chunk.text) - - memory_chunks.append(MemoryChunk( - id=chunk_id, - user_id=user_id, - scope=scope, - source=source, + try: + all_embeddings = self.embedding_provider.embed_batch(all_texts) + except Exception as e: + from common.log import logger + logger.error( + f"[MemoryManager] Batch embedding failed for {len(all_texts)} " + f"chunks across {len(pending)} files: {e}. " + f"Index left untouched; will retry on next sync." + ) + # Bail before touching storage. self._dirty stays True so + # callers know there is pending work. + return + + # Pass 3: inline persist — same self-contained reasoning as Pass 1. + cursor = 0 + for entry in pending: + n = len(entry["texts"]) + entry_embeddings = all_embeddings[cursor:cursor + n] + cursor += n + + rel_path = entry["rel_path"] + self.storage.delete_by_path(rel_path) + memory_chunks = [] + for chunk, embedding in zip(entry["chunks"], entry_embeddings): + chunk_id = self._generate_chunk_id(rel_path, chunk.start_line, chunk.end_line) + chunk_hash = MemoryStorage.compute_hash(chunk.text) + memory_chunks.append(MemoryChunk( + id=chunk_id, + user_id=entry["user_id"], + scope=entry["scope"], + source=entry["source"], + path=rel_path, + start_line=chunk.start_line, + end_line=chunk.end_line, + text=chunk.text, + embedding=embedding, + hash=chunk_hash, + metadata=None, + )) + self.storage.save_chunks_batch(memory_chunks) + stat = entry["file_path"].stat() + self.storage.update_file_metadata( path=rel_path, - start_line=chunk.start_line, - end_line=chunk.end_line, - text=chunk.text, - embedding=embedding, - hash=chunk_hash, - metadata=None - )) - - # Save - self.storage.save_chunks_batch(memory_chunks) - - # Update file metadata - stat = file_path.stat() - self.storage.update_file_metadata( - path=rel_path, - source=source, - file_hash=file_hash, - mtime=int(stat.st_mtime), - size=stat.st_size - ) - + source=entry["source"], + file_hash=entry["file_hash"], + mtime=int(stat.st_mtime), + size=stat.st_size, + ) + + self._dirty = False + def flush_memory( self, messages: list, user_id: Optional[str] = None, reason: str = "threshold", max_messages: int = 10, + context_summary_callback=None, ) -> bool: """ Flush conversation summary to daily memory file. - + Args: messages: Conversation message list user_id: Optional user ID reason: "threshold" | "overflow" | "daily_summary" max_messages: Max recent messages to include (0 = all) - + context_summary_callback: Optional callback(str) invoked with the + daily summary text for in-context injection + Returns: - True if content was written + True if flush was dispatched """ success = self.flush_manager.flush_from_messages( messages=messages, user_id=user_id, reason=reason, max_messages=max_messages, + context_summary_callback=context_summary_callback, ) if success: self._dirty = True diff --git a/agent/memory/rebuild_index.py b/agent/memory/rebuild_index.py new file mode 100644 index 00000000..a975503d --- /dev/null +++ b/agent/memory/rebuild_index.py @@ -0,0 +1,14 @@ +""" +Backward-compatible shim for the legacy entry point: + python -m agent.memory.rebuild_index + +The implementation now lives in agent.memory.embedding.rebuild. +Prefer using `/memory rebuild-index` in chat going forward. +""" + +from agent.memory.embedding.rebuild import main + +if __name__ == "__main__": + import sys + + sys.exit(main()) diff --git a/agent/memory/service.py b/agent/memory/service.py index 6456e296..055fe831 100644 --- a/agent/memory/service.py +++ b/agent/memory/service.py @@ -32,68 +32,105 @@ class MemoryService: # ------------------------------------------------------------------ # list — paginated file metadata # ------------------------------------------------------------------ - def list_files(self, page: int = 1, page_size: int = 20) -> dict: + def list_files(self, page: int = 1, page_size: int = 20, category: str = "memory") -> dict: """ - List all memory files with metadata (without content). + List memory, dream, or evolution files with metadata (without content). - Returns:: - - { - "page": 1, - "page_size": 20, - "total": 15, - "list": [ - {"filename": "MEMORY.md", "type": "global", "size": 2048, "updated_at": "2026-02-20 10:00:00"}, - {"filename": "2026-02-20.md", "type": "daily", "size": 512, "updated_at": "2026-02-20 09:30:00"}, - ... - ] - } + Args: + category: ``"memory"`` (default) — MEMORY.md + daily files; + ``"dream"`` — dream diary files from memory/dreams/; + ``"evolution"`` — self-evolution logs from memory/evolution/ + merged with the nightly dream diaries, so + one tab shows everything the agent learned. """ + if category == "evolution": + files = self._list_evolution_files() + elif category == "dream": + files = self._list_dream_files() + else: + files = self._list_memory_files() + + total = len(files) + start = (page - 1) * page_size + end = start + page_size + + return { + "page": page, + "page_size": page_size, + "total": total, + "list": files[start:end], + } + + def _list_memory_files(self) -> List[dict]: + """MEMORY.md + memory/*.md (newest first).""" files: List[dict] = [] - # 1. Global memory — MEMORY.md in workspace root global_path = os.path.join(self.workspace_root, "MEMORY.md") if os.path.isfile(global_path): files.append(self._file_info(global_path, "MEMORY.md", "global")) - # 2. Daily memory files — memory/*.md (sorted newest first) if os.path.isdir(self.memory_dir): daily_files = [] for name in os.listdir(self.memory_dir): full = os.path.join(self.memory_dir, name) if os.path.isfile(full) and name.endswith(".md"): daily_files.append((name, full)) - # Sort by filename descending (newest date first) daily_files.sort(key=lambda x: x[0], reverse=True) for name, full in daily_files: files.append(self._file_info(full, name, "daily")) - total = len(files) + return files - # Paginate - start = (page - 1) * page_size - end = start + page_size - page_items = files[start:end] + def _list_dream_files(self) -> List[dict]: + """memory/dreams/*.md (newest first).""" + files: List[dict] = [] + dreams_dir = os.path.join(self.memory_dir, "dreams") - return { - "page": page, - "page_size": page_size, - "total": total, - "list": page_items, - } + if os.path.isdir(dreams_dir): + entries = [] + for name in os.listdir(dreams_dir): + full = os.path.join(dreams_dir, name) + if os.path.isfile(full) and name.endswith(".md"): + entries.append((name, full)) + entries.sort(key=lambda x: x[0], reverse=True) + for name, full in entries: + files.append(self._file_info(full, name, "dream")) + + return files + + def _list_evolution_files(self) -> List[dict]: + """Self-evolution logs (memory/evolution/*.md) merged with the nightly + dream diaries (memory/dreams/*.md), newest first. + + Both are surfaced under the unified "Self-Evolution" tab. A file's + ``type`` records its origin so the reader can resolve the right dir. + """ + files: List[dict] = [] + for sub, ftype in (("evolution", "evolution"), ("dreams", "dream")): + sub_dir = os.path.join(self.memory_dir, sub) + if not os.path.isdir(sub_dir): + continue + for name in os.listdir(sub_dir): + full = os.path.join(sub_dir, name) + if os.path.isfile(full) and name.endswith(".md"): + files.append(self._file_info(full, name, ftype)) + # Sort newest first by filename (date-named); ties favor evolution. + files.sort(key=lambda f: (f["filename"], f["type"] != "evolution"), reverse=True) + return files # ------------------------------------------------------------------ # content — read a single file # ------------------------------------------------------------------ - def get_content(self, filename: str) -> dict: + def get_content(self, filename: str, category: str = "memory") -> dict: """ - Read the full content of a memory file. + Read the full content of a memory or dream file. - :param filename: File name, e.g. ``MEMORY.md`` or ``2026-02-20.md`` + :param filename: File name, e.g. ``MEMORY.md``, ``2026-02-20.md`` + :param category: ``"memory"``, ``"dream"`` or ``"evolution"`` :return: dict with ``filename`` and ``content`` :raises FileNotFoundError: if the file does not exist """ - path = self._resolve_path(filename) + path = self._resolve_path(filename, category) if not os.path.isfile(path): raise FileNotFoundError(f"Memory file not found: {filename}") @@ -113,7 +150,7 @@ class MemoryService: Dispatch a memory management action. :param action: ``list`` or ``content`` - :param payload: action-specific payload + :param payload: action-specific payload (supports ``category``: ``"memory"`` | ``"dream"`` | ``"evolution"``) :return: protocol-compatible response dict """ payload = payload or {} @@ -121,19 +158,23 @@ class MemoryService: if action == "list": page = payload.get("page", 1) page_size = payload.get("page_size", 20) - result_payload = self.list_files(page=page, page_size=page_size) + category = payload.get("category", "memory") + result_payload = self.list_files(page=page, page_size=page_size, category=category) return {"action": action, "code": 200, "message": "success", "payload": result_payload} elif action == "content": filename = payload.get("filename") if not filename: return {"action": action, "code": 400, "message": "filename is required", "payload": None} - result_payload = self.get_content(filename) + category = payload.get("category", "memory") + result_payload = self.get_content(filename, category=category) return {"action": action, "code": 200, "message": "success", "payload": result_payload} else: return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None} + except ValueError as e: + return {"action": action, "code": 403, "message": "invalid filename", "payload": None} except FileNotFoundError as e: return {"action": action, "code": 404, "message": str(e), "payload": None} except Exception as e: @@ -143,16 +184,33 @@ class MemoryService: # ------------------------------------------------------------------ # internal helpers # ------------------------------------------------------------------ - def _resolve_path(self, filename: str) -> str: + def _resolve_path(self, filename: str, category: str = "memory") -> str: """ - Resolve a filename to its absolute path. + Safely resolve a filename to its absolute path within the allowed directory. - ``MEMORY.md`` → ``{workspace_root}/MEMORY.md`` - - ``2026-02-20.md`` → ``{workspace_root}/memory/2026-02-20.md`` + - ``2026-02-20.md`` (memory) → ``{workspace_root}/memory/2026-02-20.md`` + - ``2026-02-20.md`` (dream) → ``{workspace_root}/memory/dreams/2026-02-20.md`` + - ``2026-02-20.md`` (evolution) → ``{workspace_root}/memory/evolution/2026-02-20.md`` + + Raises ValueError if the resolved path escapes the allowed directory. """ if filename == "MEMORY.md": - return os.path.join(self.workspace_root, filename) - return os.path.join(self.memory_dir, filename) + base_dir = self.workspace_root + elif category == "dream": + base_dir = os.path.join(self.memory_dir, "dreams") + elif category == "evolution": + base_dir = os.path.join(self.memory_dir, "evolution") + else: + base_dir = self.memory_dir + + resolved = os.path.realpath(os.path.join(base_dir, filename)) + allowed = os.path.realpath(base_dir) + + if resolved != allowed and not resolved.startswith(allowed + os.sep): + raise ValueError(f"Invalid filename: path traversal detected") + + return resolved @staticmethod def _file_info(path: str, filename: str, file_type: str) -> dict: diff --git a/agent/memory/storage.py b/agent/memory/storage.py index 8ff0504a..240ff9de 100644 --- a/agent/memory/storage.py +++ b/agent/memory/storage.py @@ -5,12 +5,42 @@ Provides vector and keyword search capabilities """ from __future__ import annotations +import re import sqlite3 import json import hashlib +import threading from typing import List, Dict, Optional, Any from pathlib import Path from dataclasses import dataclass +try: + import numpy as np + _HAS_NUMPY = True +except ImportError: + _HAS_NUMPY = False + np = None # type: ignore[assignment] + +# UPSERT (INSERT … ON CONFLICT DO UPDATE) requires SQLite ≥ 3.24.0 (2018). +# Older systems (e.g. CentOS 7 ships SQLite 3.7) fall back to INSERT OR REPLACE, +# which risks FTS5 rowid drift on chunk updates (see save_chunk docstring). +_HAS_UPSERT = sqlite3.sqlite_version_info >= (3, 24, 0) + +# --------------------------------------------------------------------------- +# CJK character ranges, compiled once at module load. +# Covers: CJK Symbols/Punctuation, Japanese kana (hiragana + katakana), +# CJK Unified Ideographs + Extension A, Korean syllables (Hangul), +# CJK Compatibility Ideographs, and CJK Extension B–F. +# --------------------------------------------------------------------------- +_CJK_RANGES = ( + r'\u3000-\u30ff' # CJK Symbols/Punctuation + Japanese kana + r'\u3400-\u9fff' # CJK Unified Ideographs (incl. Extension A) + r'\uac00-\ud7af' # Korean syllables (Hangul) + r'\uf900-\ufaff' # CJK Compatibility Ideographs + r'\U00020000-\U0002fa1f' # CJK Extension B–F +) +_RE_CONTAINS_CJK = re.compile(f'[{_CJK_RANGES}]') +_RE_CJK_WORDS = re.compile(f'[{_CJK_RANGES}]+') +_RE_TRIGRAM_TOKENS = re.compile(f'[{_CJK_RANGES}]+|[A-Za-z0-9_]+') @dataclass @@ -48,6 +78,10 @@ class MemoryStorage: self.db_path = db_path self.conn: Optional[sqlite3.Connection] = None self.fts5_available = False # Track FTS5 availability + # RLock protects concurrent writes from the same process. + # SQLite WAL mode handles read/write concurrency at the file level, + # but same-process concurrent writes still need a Python-level lock. + self._lock = threading.RLock() self._init_db() def _check_fts5_support(self) -> bool: @@ -69,6 +103,14 @@ class MemoryStorage: # Check FTS5 support self.fts5_available = self._check_fts5_support() + if not _HAS_UPSERT: + from common.log import logger + logger.warning( + "[MemoryStorage] SQLite %s < 3.24 — UPSERT unavailable. " + "Falling back to INSERT OR REPLACE; FTS5 rowid may drift on " + "chunk updates (rebuild index periodically to recover).", + sqlite3.sqlite_version, + ) if not self.fts5_available: from common.log import logger logger.debug("[MemoryStorage] FTS5 not available, using LIKE-based keyword search") @@ -144,45 +186,106 @@ class MemoryStorage: ON chunks(path, hash) """) - # Create FTS5 virtual table for keyword search (only if supported) + # Create FTS5 virtual table + triggers (only if supported). + # Self-heal: if the previous process crashed mid-rebuild and left + # triggers pointing at a missing chunks_fts (or vice versa), wipe + # both sides and recreate cleanly. Otherwise next chunks INSERT + # will fail with "no such table: chunks_fts". if self.fts5_available: - # Use default unicode61 tokenizer (stable and compatible) - # For CJK support, we'll use LIKE queries as fallback - self.conn.execute(""" - CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5( - text, - id UNINDEXED, - user_id UNINDEXED, - path UNINDEXED, - source UNINDEXED, - scope UNINDEXED, - content='chunks', - content_rowid='rowid' + if self._fts5_state_inconsistent(): + from common.log import logger + logger.warning( + "[MemoryStorage] FTS5 state inconsistent (triggers/table mismatch). " + "Resetting chunks_fts to recover." ) - """) - - # Create triggers to keep FTS in sync - self.conn.execute(""" - CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN - INSERT INTO chunks_fts(rowid, text, id, user_id, path, source, scope) - VALUES (new.rowid, new.text, new.id, new.user_id, new.path, new.source, new.scope); - END - """) - - self.conn.execute(""" - CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN - DELETE FROM chunks_fts WHERE rowid = old.rowid; - END - """) - - self.conn.execute(""" - CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN - UPDATE chunks_fts SET text = new.text, id = new.id, - user_id = new.user_id, path = new.path, source = new.source, scope = new.scope - WHERE rowid = new.rowid; - END - """) - + self.conn.execute("DROP TRIGGER IF EXISTS chunks_ai") + self.conn.execute("DROP TRIGGER IF EXISTS chunks_ad") + self.conn.execute("DROP TRIGGER IF EXISTS chunks_au") + self.conn.execute("DROP TABLE IF EXISTS chunks_fts") + self.conn.commit() + self._create_fts5_objects() + + # Probe FTS5 shadow tables. The schema may be intact but the + # internal _data/_idx/_docsize blob can still be corrupt — that + # surfaces as "database disk image is malformed" on bm25 / MATCH. + # We rebuild from the chunks table when that happens; data isn't + # lost because chunks (the content table) is the source of truth. + if self._fts5_shadow_corrupt(): + from common.log import logger + logger.warning( + "[MemoryStorage] FTS5 shadow tables corrupt; rebuilding from chunks." + ) + self._rebuild_fts5_from_chunks() + + # Internal key-value store for persistent flags (e.g. backfill tracking) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS _meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """) + + # Create trigram FTS5 table for CJK / mixed-language search + self.trigram_fts5_available = False + if self.fts5_available: + try: + self.conn.execute(""" + CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts_trigram USING fts5( + text, + id UNINDEXED, + user_id UNINDEXED, + path UNINDEXED, + source UNINDEXED, + scope UNINDEXED, + content='chunks', + content_rowid='rowid', + tokenize='trigram case_sensitive 0' + ) + """) + self.conn.execute(""" + CREATE TRIGGER IF NOT EXISTS chunks_trigram_ai + AFTER INSERT ON chunks BEGIN + INSERT INTO chunks_fts_trigram(rowid, text, id, user_id, path, source, scope) + VALUES (new.rowid, new.text, new.id, new.user_id, new.path, new.source, new.scope); + END + """) + self.conn.execute(""" + CREATE TRIGGER IF NOT EXISTS chunks_trigram_ad + AFTER DELETE ON chunks BEGIN + DELETE FROM chunks_fts_trigram WHERE rowid = old.rowid; + END + """) + self.conn.execute(""" + CREATE TRIGGER IF NOT EXISTS chunks_trigram_au + AFTER UPDATE ON chunks BEGIN + UPDATE chunks_fts_trigram + SET text=new.text, id=new.id, user_id=new.user_id, + path=new.path, source=new.source, scope=new.scope + WHERE rowid = new.rowid; + END + """) + # One-time backfill for existing rows. + # NOTE: COUNT(*) on an FTS5 content table always returns 0, so we + # use a persistent flag in _meta instead of counting trigram rows. + backfill_done = self.conn.execute( + "SELECT 1 FROM _meta WHERE key = 'trigram_backfill_done'" + ).fetchone() + chunks_count = self.conn.execute( + "SELECT COUNT(*) as c FROM chunks" + ).fetchone()['c'] + if chunks_count > 0 and not backfill_done: + self.conn.execute( + "INSERT INTO chunks_fts_trigram(chunks_fts_trigram) VALUES('rebuild')" + ) + self.conn.execute( + "INSERT OR REPLACE INTO _meta(key, value) VALUES('trigram_backfill_done', '1')" + ) + self.trigram_fts5_available = True + except Exception: + from common.log import logger + logger.warning("[MemoryStorage] trigram FTS5 unavailable, CJK search will use LIKE fallback", exc_info=True) + self.trigram_fts5_available = False + # Create files metadata table self.conn.execute(""" CREATE TABLE IF NOT EXISTS files ( @@ -194,47 +297,211 @@ class MemoryStorage: updated_at INTEGER DEFAULT (strftime('%s', 'now')) ) """) - + self.conn.commit() - - def save_chunk(self, chunk: MemoryChunk): - """Save a memory chunk""" + + def _fts5_state_inconsistent(self) -> bool: + """Detect a half-broken FTS5 setup (e.g. trigger exists but table doesn't).""" + try: + row = self.conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='chunks_fts'" + ).fetchone() + table_exists = row is not None + row = self.conn.execute( + "SELECT COUNT(*) FROM sqlite_master WHERE type='trigger' " + "AND name IN ('chunks_ai','chunks_ad','chunks_au')" + ).fetchone() + trigger_count = int(row[0]) if row else 0 + except Exception: + return False + # Healthy = both present (3 triggers + table) or both absent. + return table_exists != (trigger_count > 0) + + def _create_fts5_objects(self): + """Create chunks_fts virtual table and the 3 sync triggers. + + Idempotent: uses IF NOT EXISTS. Caller must hold self.conn. + """ self.conn.execute(""" - INSERT OR REPLACE INTO chunks - (id, user_id, scope, source, path, start_line, end_line, text, embedding, hash, metadata, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now')) - """, ( - chunk.id, - chunk.user_id, - chunk.scope, - chunk.source, - chunk.path, - chunk.start_line, - chunk.end_line, - chunk.text, - json.dumps(chunk.embedding) if chunk.embedding else None, - chunk.hash, - json.dumps(chunk.metadata) if chunk.metadata else None - )) + CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5( + text, + id UNINDEXED, + user_id UNINDEXED, + path UNINDEXED, + source UNINDEXED, + scope UNINDEXED, + content='chunks', + content_rowid='rowid' + ) + """) + self.conn.execute(""" + CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN + INSERT INTO chunks_fts(rowid, text, id, user_id, path, source, scope) + VALUES (new.rowid, new.text, new.id, new.user_id, new.path, new.source, new.scope); + END + """) + self.conn.execute(""" + CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN + DELETE FROM chunks_fts WHERE rowid = old.rowid; + END + """) + self.conn.execute(""" + CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN + UPDATE chunks_fts SET text = new.text, id = new.id, + user_id = new.user_id, path = new.path, + source = new.source, scope = new.scope + WHERE rowid = new.rowid; + END + """) + + def reset_fts5(self): + """Drop and recreate chunks_fts + triggers in one transaction. + + Used by rebuild_index to recover from FTS5 shadow-table corruption + (bm25/ORDER BY rank may raise "database disk image is malformed" + even when raw MATCH still works). + + Triggers must be dropped first; otherwise the next chunks INSERT/DELETE + on the existing connection will hit "no such table: chunks_fts". + """ + if not self.fts5_available: + return + self.conn.execute("DROP TRIGGER IF EXISTS chunks_ai") + self.conn.execute("DROP TRIGGER IF EXISTS chunks_ad") + self.conn.execute("DROP TRIGGER IF EXISTS chunks_au") + self.conn.execute("DROP TABLE IF EXISTS chunks_fts") + self._create_fts5_objects() self.conn.commit() - + + def _fts5_shadow_corrupt(self) -> bool: + """Probe whether bm25 over chunks_fts errors out at startup. + + Schema (table + triggers) can be intact while the underlying + FTS5 shadow blobs are malformed — typically because the previous + process crashed mid-write or wrote with a different SQLite build. + A cheap MATCH probe surfaces it immediately.""" + try: + self.conn.execute( + "SELECT bm25(chunks_fts) FROM chunks_fts WHERE chunks_fts MATCH 'a' LIMIT 1" + ).fetchone() + return False + except sqlite3.DatabaseError as e: + msg = str(e).lower() + return "malformed" in msg or "corrupt" in msg + except Exception: + # Any other error (e.g. table missing) is handled by the + # state-inconsistent path; treat as healthy here. + return False + + def _rebuild_fts5_from_chunks(self): + """Drop FTS5, recreate it, then INSERT every row from chunks. + + Safe data-wise: chunks (the content table) is the source of truth. + Done in one transaction so a crash leaves either fully old or fully + new state, not a partial rebuild. + """ + # Reset schema first; this clears any malformed shadow blobs. + self.reset_fts5() + # Re-feed content. Triggers handle future writes automatically. + self.conn.execute(""" + INSERT INTO chunks_fts(rowid, text, id, user_id, path, source, scope) + SELECT rowid, text, id, user_id, path, source, scope FROM chunks + """) + self.conn.commit() + + def save_chunk(self, chunk: MemoryChunk): + """Save a memory chunk (insert or update by id). + + Uses SQLite UPSERT (INSERT … ON CONFLICT DO UPDATE) instead of + INSERT OR REPLACE. INSERT OR REPLACE internally does DELETE+INSERT, + which changes the row's rowid. Because both FTS5 tables use + content_rowid='rowid', a new rowid would leave the old FTS index + entries pointing at a non-existent rowid and trigger + "fts5: missing row N from content table" errors. + ON CONFLICT DO UPDATE fires the AFTER UPDATE trigger (chunks_au / + chunks_trigram_au) and keeps the original rowid intact. + """ + if _HAS_UPSERT: + _SQL = """ + INSERT INTO chunks + (id, user_id, scope, source, path, start_line, end_line, + text, embedding, hash, metadata, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now')) + ON CONFLICT(id) DO UPDATE SET + user_id = excluded.user_id, + scope = excluded.scope, + source = excluded.source, + path = excluded.path, + start_line = excluded.start_line, + end_line = excluded.end_line, + text = excluded.text, + embedding = excluded.embedding, + hash = excluded.hash, + metadata = excluded.metadata, + updated_at = strftime('%s', 'now') + """ + else: + _SQL = """ + INSERT OR REPLACE INTO chunks + (id, user_id, scope, source, path, start_line, end_line, + text, embedding, hash, metadata, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now')) + """ + params = ( + chunk.id, chunk.user_id, chunk.scope, chunk.source, chunk.path, + chunk.start_line, chunk.end_line, chunk.text, + self._encode_embedding(chunk.embedding), + chunk.hash, + json.dumps(chunk.metadata) if chunk.metadata else None, + ) + with self._lock: + self.conn.execute(_SQL, params) + self.conn.commit() + def save_chunks_batch(self, chunks: List[MemoryChunk]): - """Save multiple chunks in a batch""" - self.conn.executemany(""" - INSERT OR REPLACE INTO chunks - (id, user_id, scope, source, path, start_line, end_line, text, embedding, hash, metadata, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now')) - """, [ + """Save multiple chunks in a batch (insert or update by id). + + See save_chunk for why UPSERT is used instead of INSERT OR REPLACE. + """ + if _HAS_UPSERT: + _SQL = """ + INSERT INTO chunks + (id, user_id, scope, source, path, start_line, end_line, + text, embedding, hash, metadata, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now')) + ON CONFLICT(id) DO UPDATE SET + user_id = excluded.user_id, + scope = excluded.scope, + source = excluded.source, + path = excluded.path, + start_line = excluded.start_line, + end_line = excluded.end_line, + text = excluded.text, + embedding = excluded.embedding, + hash = excluded.hash, + metadata = excluded.metadata, + updated_at = strftime('%s', 'now') + """ + else: + _SQL = """ + INSERT OR REPLACE INTO chunks + (id, user_id, scope, source, path, start_line, end_line, + text, embedding, hash, metadata, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now')) + """ + params_list = [ ( c.id, c.user_id, c.scope, c.source, c.path, c.start_line, c.end_line, c.text, - json.dumps(c.embedding) if c.embedding else None, + self._encode_embedding(c.embedding), c.hash, - json.dumps(c.metadata) if c.metadata else None + json.dumps(c.metadata) if c.metadata else None, ) for c in chunks - ]) - self.conn.commit() + ] + with self._lock: + self.conn.executemany(_SQL, params_list) + self.conn.commit() def get_chunk(self, chunk_id: str) -> Optional[MemoryChunk]: """Get a chunk by ID""" @@ -255,21 +522,21 @@ class MemoryStorage: limit: int = 10 ) -> List[SearchResult]: """ - Vector similarity search using in-memory cosine similarity - (sqlite-vec can be added later for better performance) + Vector similarity search using numpy-vectorized cosine similarity. + All embeddings are loaded then scored in a single BLAS matrix-vector + multiply, which is ~100x faster than the pure-Python per-row loop. """ if scopes is None: scopes = ["shared"] if user_id: scopes.append("user") - - # Build query + scope_placeholders = ','.join('?' * len(scopes)) - params = scopes - + params = list(scopes) + if user_id: query = f""" - SELECT * FROM chunks + SELECT * FROM chunks WHERE scope IN ({scope_placeholders}) AND (scope = 'shared' OR user_id = ?) AND embedding IS NOT NULL @@ -277,38 +544,95 @@ class MemoryStorage: params.append(user_id) else: query = f""" - SELECT * FROM chunks + SELECT * FROM chunks WHERE scope IN ({scope_placeholders}) AND embedding IS NOT NULL """ - + rows = self.conn.execute(query, params).fetchall() - - # Calculate cosine similarity - results = [] + if not rows: + return [] + + # Parse embeddings and build a (N, D) matrix in one pass. + # New rows store BLOB bytes (np.frombuffer); legacy rows fall back to JSON. + # Filter out rows whose embedding dimension differs from the query — + # mixing dimensions would cause np.array() to produce an object array + # and matrix @ q_vec to raise ValueError. + expected_dim = len(query_embedding) + valid_rows = [] + vectors = [] for row in rows: - embedding = json.loads(row['embedding']) - similarity = self._cosine_similarity(query_embedding, embedding) - - if similarity > 0: - results.append((similarity, row)) - - # Sort by similarity and limit - results.sort(key=lambda x: x[0], reverse=True) - results = results[:limit] - - return [ - SearchResult( - path=row['path'], - start_line=row['start_line'], - end_line=row['end_line'], - score=score, - snippet=self._truncate_text(row['text'], 500), - source=row['source'], - user_id=row['user_id'] - ) - for score, row in results - ] + vec = self._decode_embedding(row['embedding']) + if not vec: + continue + if len(vec) != expected_dim: + from common.log import logger + logger.warning( + "[MemoryStorage] Skipping chunk %s: embedding dim %d != query dim %d", + row['id'], len(vec), expected_dim + ) + continue + valid_rows.append(row) + vectors.append(vec) + + if not vectors: + return [] + + if _HAS_NUMPY: + matrix = np.array(vectors, dtype=np.float32) # (N, D) + q_vec = np.array(query_embedding, dtype=np.float32) # (D,) + + # Vectorized cosine similarity: dot(matrix, q) / (||matrix|| * ||q||) + dots = matrix @ q_vec # (N,) + row_norms = np.linalg.norm(matrix, axis=1) # (N,) + q_norm = float(np.linalg.norm(q_vec)) + denominators = row_norms * q_norm + np.maximum(denominators, 1e-10, out=denominators) # avoid div-by-zero + sims = dots / denominators # (N,) + + # Select TopK using argpartition (O(N) average), then sort only those K + k = min(limit, len(valid_rows)) + top_idx = np.argpartition(sims, -k)[-k:] + top_idx = top_idx[np.argsort(sims[top_idx])[::-1]] + + return [ + SearchResult( + path=valid_rows[i]['path'], + start_line=valid_rows[i]['start_line'], + end_line=valid_rows[i]['end_line'], + score=float(sims[i]), + snippet=self._truncate_text(valid_rows[i]['text'], 500), + source=valid_rows[i]['source'], + user_id=valid_rows[i]['user_id'] + ) + for i in top_idx + if sims[i] > 0 + ] + else: + # Pure-Python cosine similarity fallback (numpy not installed) + import math + q = query_embedding + q_norm = math.sqrt(sum(x * x for x in q)) or 1e-10 + scored = [] + for i, vec in enumerate(vectors): + dot = sum(a * b for a, b in zip(vec, q)) + v_norm = math.sqrt(sum(x * x for x in vec)) or 1e-10 + sim = dot / (v_norm * q_norm) + if sim > 0: + scored.append((sim, valid_rows[i])) + scored.sort(key=lambda x: x[0], reverse=True) + return [ + SearchResult( + path=row['path'], + start_line=row['start_line'], + end_line=row['end_line'], + score=sim, + snippet=self._truncate_text(row['text'], 500), + source=row['source'], + user_id=row['user_id'] + ) + for sim, row in scored[:limit] + ] def search_keyword( self, @@ -319,26 +643,48 @@ class MemoryStorage: ) -> List[SearchResult]: """ Keyword search using FTS5 + LIKE fallback - + Strategy: - 1. If FTS5 available: Try FTS5 search first (good for English and word-based languages) - 2. If no FTS5 or no results and query contains CJK: Use LIKE search + 1. If FTS5 available and healthy: try FTS5 first + 2. Always fall back to LIKE for CJK queries + 3. If FTS5 fails OR returns empty for non-CJK, also try LIKE so a + broken FTS5 shadow table doesn't silently kill keyword search. """ if scopes is None: scopes = ["shared"] if user_id: scopes.append("user") - - # Try FTS5 search first (if available) - if self.fts5_available: + + # Step 1: Standard FTS5 (unicode61) — pure ASCII queries only. + # Skipped when query contains any CJK characters: unicode61 tokenises CJK + # as individual characters without forming meaningful tokens, so it would + # match only the ASCII portion of a mixed query (e.g. "Python" from + # "Python教程") and silently discard the CJK part. Those queries go + # directly to Step 2 (trigram), which handles both ASCII and CJK together. + fts1_attempted = False + if (self.fts5_available + and not MemoryStorage._contains_cjk(query) + and MemoryStorage._build_fts_query(query)): + fts1_attempted = True fts_results = self._search_fts5(query, user_id, scopes, limit) if fts_results: return fts_results - - # Fallback to LIKE search (always for CJK, or if FTS5 not available) + + # Step 2: Trigram FTS5 — CJK/mixed queries, plus fallback when unicode61 + # returned nothing (trigram indexes all scripts with 3-char sliding windows, + # so it can catch terms that unicode61 tokenisation misses). + if self.trigram_fts5_available and ( + MemoryStorage._contains_cjk(query) or fts1_attempted + ): + trigram_results = self._search_fts5_trigram(query, user_id, scopes, limit) + if trigram_results: + return trigram_results + + # Step 3: LIKE fallback — last resort (FTS5 unavailable, or CJK tokens + # shorter than 3 characters that trigram cannot match, e.g. a single-char query). if not self.fts5_available or MemoryStorage._contains_cjk(query): return self._search_like(query, user_id, scopes, limit) - + return [] def _search_fts5( @@ -360,7 +706,7 @@ class MemoryStorage: sql_query = f""" SELECT chunks.*, bm25(chunks_fts) as rank FROM chunks_fts - JOIN chunks ON chunks.id = chunks_fts.id + JOIN chunks ON chunks.rowid = chunks_fts.rowid WHERE chunks_fts MATCH ? AND chunks.scope IN ({scope_placeholders}) AND (chunks.scope = 'shared' OR chunks.user_id = ?) @@ -372,7 +718,7 @@ class MemoryStorage: sql_query = f""" SELECT chunks.*, bm25(chunks_fts) as rank FROM chunks_fts - JOIN chunks ON chunks.id = chunks_fts.id + JOIN chunks ON chunks.rowid = chunks_fts.rowid WHERE chunks_fts MATCH ? AND chunks.scope IN ({scope_placeholders}) ORDER BY rank @@ -395,8 +741,10 @@ class MemoryStorage: for row in rows ] except Exception: + from common.log import logger + logger.warning("[MemoryStorage] _search_fts5 failed, returning empty", exc_info=True) return [] - + def _search_like( self, query: str, @@ -404,21 +752,27 @@ class MemoryStorage: scopes: List[str], limit: int ) -> List[SearchResult]: - """LIKE-based search for CJK characters""" - import re - # Extract CJK words (2+ characters) - cjk_words = re.findall(r'[\u4e00-\u9fff]{2,}', query) - if not cjk_words: + """LIKE-based search. + + Used as the keyword-search fallback when FTS5 is unavailable, fails, + or returns empty. Supports both CJK runs (1+ chars) and ASCII word + tokens (3+ chars) so it can serve as a true safety net for any query. + """ + # CJK runs (1+ chars, wide Unicode range) + ASCII words (3+ chars to avoid noise) + cjk_words = _RE_CJK_WORDS.findall(query) + ascii_words = [t for t in re.findall(r'[A-Za-z0-9_]+', query) if len(t) >= 3] + words = cjk_words + ascii_words + if not words: return [] - + scope_placeholders = ','.join('?' * len(scopes)) - - # Build LIKE conditions for each word + + # Build LIKE conditions for each word (case-insensitive for ASCII) like_conditions = [] params = [] - for word in cjk_words: - like_conditions.append("text LIKE ?") - params.append(f'%{word}%') + for word in words: + like_conditions.append("LOWER(text) LIKE ?") + params.append(f'%{word.lower()}%') where_clause = ' OR '.join(like_conditions) params.extend(scopes) @@ -443,56 +797,74 @@ class MemoryStorage: try: rows = self.conn.execute(sql_query, params).fetchall() - return [ - SearchResult( + results = [] + for row in rows: + # Dynamic score: reward chunks that contain more of the query words. + # Use all tokens (CJK + ASCII) so pure-ASCII queries are not skipped. + # matched_count is always ≥1 because the WHERE clause uses OR, but + # guard defensively so unexpected zero-match rows are never surfaced. + text_lower = row['text'].lower() + matched_count = sum(1 for w in words if w.lower() in text_lower) + if matched_count == 0: + continue + score = min(0.85, 0.3 + 0.15 * matched_count) + results.append(SearchResult( path=row['path'], start_line=row['start_line'], end_line=row['end_line'], - score=0.5, # Fixed score for LIKE search + score=score, snippet=self._truncate_text(row['text'], 500), source=row['source'], user_id=row['user_id'] - ) - for row in rows - ] + )) + results.sort(key=lambda r: r.score, reverse=True) + return results except Exception: + from common.log import logger + logger.warning("[MemoryStorage] _search_like failed, returning empty", exc_info=True) return [] - + def delete_by_path(self, path: str): - """Delete all chunks from a file""" - self.conn.execute(""" - DELETE FROM chunks WHERE path = ? - """, (path,)) - self.conn.commit() - + """Delete all chunks and file metadata for a path.""" + with self._lock: + self.conn.execute("DELETE FROM chunks WHERE path = ?", (path,)) + self.conn.execute("DELETE FROM files WHERE path = ?", (path,)) + self.conn.commit() + def get_file_hash(self, path: str) -> Optional[str]: """Get stored file hash""" row = self.conn.execute(""" SELECT hash FROM files WHERE path = ? """, (path,)).fetchone() return row['hash'] if row else None - + def update_file_metadata(self, path: str, source: str, file_hash: str, mtime: int, size: int): """Update file metadata""" - self.conn.execute(""" - INSERT OR REPLACE INTO files (path, source, hash, mtime, size, updated_at) - VALUES (?, ?, ?, ?, ?, strftime('%s', 'now')) - """, (path, source, file_hash, mtime, size)) - self.conn.commit() + with self._lock: + self.conn.execute(""" + INSERT OR REPLACE INTO files (path, source, hash, mtime, size, updated_at) + VALUES (?, ?, ?, ?, ?, strftime('%s', 'now')) + """, (path, source, file_hash, mtime, size)) + self.conn.commit() def get_stats(self) -> Dict[str, int]: """Get storage statistics""" chunks_count = self.conn.execute(""" SELECT COUNT(*) as cnt FROM chunks """).fetchone()['cnt'] - + files_count = self.conn.execute(""" SELECT COUNT(*) as cnt FROM files """).fetchone()['cnt'] - + + embedded_count = self.conn.execute(""" + SELECT COUNT(*) as cnt FROM chunks WHERE embedding IS NOT NULL + """).fetchone()['cnt'] + return { 'chunks': chunks_count, - 'files': files_count + 'files': files_count, + 'embedded': embedded_count, } def close(self): @@ -503,7 +875,8 @@ class MemoryStorage: self.conn.close() self.conn = None # Mark as closed except Exception as e: - print(f"⚠️ Error closing database connection: {e}") + from common.log import logger + logger.warning("[MemoryStorage] Error closing database connection: %s", e) def __del__(self): """Destructor to ensure connection is closed""" @@ -513,7 +886,33 @@ class MemoryStorage: pass # Ignore errors during cleanup # Helper methods - + + @staticmethod + def _encode_embedding(embedding: Optional[List[float]]) -> Optional[bytes]: + """Encode embedding as float32 BLOB bytes (~6x smaller and faster than JSON). + Falls back to struct.pack when numpy is unavailable.""" + if embedding is None: + return None + if _HAS_NUMPY: + return np.array(embedding, dtype=np.float32).tobytes() + import struct + return struct.pack(f'{len(embedding)}f', *embedding) + + @staticmethod + def _decode_embedding(raw) -> Optional[List[float]]: + """Decode embedding from BLOB bytes or legacy JSON string. + Handles both numpy and numpy-free environments.""" + if raw is None: + return None + if isinstance(raw, (bytes, bytearray)): + if _HAS_NUMPY: + return np.frombuffer(raw, dtype=np.float32).tolist() + import struct + n = len(raw) // 4 + return list(struct.unpack(f'{n}f', raw)) + # Legacy JSON format written by older versions + return json.loads(raw) + def _row_to_chunk(self, row) -> MemoryChunk: """Convert database row to MemoryChunk""" return MemoryChunk( @@ -525,32 +924,89 @@ class MemoryStorage: start_line=row['start_line'], end_line=row['end_line'], text=row['text'], - embedding=json.loads(row['embedding']) if row['embedding'] else None, + embedding=self._decode_embedding(row['embedding']), hash=row['hash'], metadata=json.loads(row['metadata']) if row['metadata'] else None ) @staticmethod - def _cosine_similarity(vec1: List[float], vec2: List[float]) -> float: - """Calculate cosine similarity between two vectors""" - if len(vec1) != len(vec2): - return 0.0 - - dot_product = sum(a * b for a, b in zip(vec1, vec2)) - norm1 = sum(a * a for a in vec1) ** 0.5 - norm2 = sum(b * b for b in vec2) ** 0.5 - - if norm1 == 0 or norm2 == 0: - return 0.0 - - return dot_product / (norm1 * norm2) + def _contains_cjk(text: str) -> bool: + """Check if text contains CJK or related characters (Chinese, Japanese, Korean).""" + return bool(_RE_CONTAINS_CJK.search(text)) @staticmethod - def _contains_cjk(text: str) -> bool: - """Check if text contains CJK (Chinese/Japanese/Korean) characters""" - import re - return bool(re.search(r'[\u4e00-\u9fff]', text)) - + def _build_trigram_query(raw_query: str) -> Optional[str]: + """ + Build FTS5 MATCH query for the trigram tokenizer. + Extracts CJK sequences (including single characters) and ASCII words, + joining them with AND so all terms must appear in the matched chunk. + """ + tokens = _RE_TRIGRAM_TOKENS.findall(raw_query) + tokens = [t for t in tokens if t] + if not tokens: + return None + # Escape embedded double-quotes (FTS5 uses "" inside quoted phrases) + quoted = [f'"{t.replace(chr(34), chr(34)*2)}"' for t in tokens] + return ' AND '.join(quoted) + + def _search_fts5_trigram( + self, + query: str, + user_id: Optional[str], + scopes: List[str], + limit: int + ) -> List[SearchResult]: + """Trigram FTS5 search — handles CJK and mixed queries with BM25 ranking.""" + trigram_query = self._build_trigram_query(query) + if not trigram_query: + return [] + + scope_placeholders = ','.join('?' * len(scopes)) + params = [trigram_query] + list(scopes) + + if user_id: + sql = f""" + SELECT chunks.*, bm25(chunks_fts_trigram) as rank + FROM chunks_fts_trigram + JOIN chunks ON chunks.rowid = chunks_fts_trigram.rowid + WHERE chunks_fts_trigram MATCH ? + AND chunks.scope IN ({scope_placeholders}) + AND (chunks.scope = 'shared' OR chunks.user_id = ?) + ORDER BY rank + LIMIT ? + """ + params.extend([user_id, limit]) + else: + sql = f""" + SELECT chunks.*, bm25(chunks_fts_trigram) as rank + FROM chunks_fts_trigram + JOIN chunks ON chunks.rowid = chunks_fts_trigram.rowid + WHERE chunks_fts_trigram MATCH ? + AND chunks.scope IN ({scope_placeholders}) + ORDER BY rank + LIMIT ? + """ + params.append(limit) + + try: + rows = self.conn.execute(sql, params).fetchall() + return [ + SearchResult( + path=row['path'], + start_line=row['start_line'], + end_line=row['end_line'], + score=self._bm25_rank_to_score(row['rank']), + snippet=self._truncate_text(row['text'], 500), + source=row['source'], + user_id=row['user_id'] + ) + for row in rows + ] + except Exception: + from common.log import logger + logger.warning("[MemoryStorage] _search_fts5_trigram failed, returning empty", exc_info=True) + return [] + @staticmethod def _build_fts_query(raw_query: str) -> Optional[str]: """ @@ -559,7 +1015,6 @@ class MemoryStorage: Works best for English and word-based languages. For CJK characters, LIKE search will be used as fallback. """ - import re # Extract words (primarily English words and numbers) tokens = re.findall(r'[A-Za-z0-9_]+', raw_query) if not tokens: @@ -572,9 +1027,22 @@ class MemoryStorage: @staticmethod def _bm25_rank_to_score(rank: float) -> float: - """Convert BM25 rank to 0-1 score""" - normalized = max(0, rank) if rank is not None else 999 - return 1 / (1 + normalized) + """Convert SQLite BM25 rank to a [0, 1) relevance score. + + SQLite's bm25() returns a non-positive float (0 or negative). + More negative = more relevant. max(0, rank) would clip every + negative value to 0, making every score 1/(1+0) = 1.0 and + destroying all ranking information. + + abs(rank) / (1 + abs(rank)) maps the absolute relevance magnitude + to [0, 1): larger |rank| (stronger match) → score closer to 1. + """ + if rank is None: + return 0.0 + # Add a floor of 0.3 so any FTS5 match always exceeds typical + # min_score thresholds (default 0.1). Small-corpus ranks close to + # 0 would otherwise produce score≈0 and be filtered out downstream. + return 0.3 + 0.69 * (abs(rank) / (1.0 + abs(rank))) @staticmethod def _truncate_text(text: str, max_chars: int) -> str: diff --git a/agent/memory/summarizer.py b/agent/memory/summarizer.py index 20900fb3..066549dc 100644 --- a/agent/memory/summarizer.py +++ b/agent/memory/summarizer.py @@ -1,12 +1,12 @@ """ -Memory flush manager +Memory flush manager with Deep Dream distillation Handles memory persistence when conversation context is trimmed or overflows: -- Uses LLM to summarize discarded messages into concise key-information entries +- Uses LLM to summarize discarded messages into concise daily records - Writes to daily memory files (lazy creation) - Deduplicates trim flushes to avoid repeated writes - Runs summarization asynchronously to avoid blocking normal replies -- Provides daily summary interface for scheduler +- Deep Dream: periodically distills daily memories → refined MEMORY.md + dream diary """ import threading @@ -16,19 +16,180 @@ from datetime import datetime from common.log import logger -SUMMARIZE_SYSTEM_PROMPT = """你是一个记忆提取助手。你的任务是从对话记录中提取值得记住的信息,生成简洁的记忆摘要。 +SUMMARIZE_SYSTEM_PROMPT_ZH = """你是一个对话记录助手。请将对话内容归纳为当天的日常记录。 -输出要求: -1. 以事件/关键信息为维度记录,每条一行,用 "- " 开头 -2. 记录有价值的关键信息,例如用户提出的要求及助手的解决方案,对话中涉及的事实信息,用户的偏好、决策或重要结论 -3. 每条摘要需要简明扼要,只保留关键信息 -4. 直接输出摘要内容,不要加任何前缀说明 -5. 当对话没有任何记录价值例如只是简单问候,可回复"无\"""" +## 要求 -SUMMARIZE_USER_PROMPT = """请从以下对话记录中提取关键信息,生成记忆摘要: +按「事件」维度归纳发生的事,不要按对话轮次逐条记录: +- 每条一行,用 "- " 开头 +- 合并同一件事的多轮对话 +- 只记录有意义的事件,忽略闲聊和问候 +- 保留关键的决策、结论和待办事项 + +当对话没有任何记录价值(仅含问候或无意义内容),直接回复"无"。""" + +SUMMARIZE_SYSTEM_PROMPT_EN = """You are a conversation-logging assistant. Summarize the conversation into a daily record. + +## Requirements + +Summarize by "event", not turn by turn: +- One item per line, starting with "- " +- Merge multiple turns about the same thing +- Only record meaningful events; ignore small talk and greetings +- Keep key decisions, conclusions and to-dos + +If the conversation has no record value (only greetings or meaningless content), reply with exactly "None".""" + +SUMMARIZE_USER_PROMPT_ZH = """请归纳以下对话的日常记录: {conversation}""" +SUMMARIZE_USER_PROMPT_EN = """Summarize the daily record of the following conversation: + +{conversation}""" + +# --------------------------------------------------------------------------- +# Deep Dream prompts — distill daily memories → MEMORY.md + dream diary +# --------------------------------------------------------------------------- + +DREAM_SYSTEM_PROMPT_ZH = """你是一个记忆整理助手,负责定期整理用户的长期记忆。 + +你将收到两份材料: +1. **当前长期记忆** — MEMORY.md 的全部现有内容 +2. **今日日记** — 当天的日常记录 + +MEMORY.md 会注入每次对话的系统提示词中,因此必须保持精炼,只存放有价值和值得记忆的内容。 + +**重要:只能基于提供的材料进行整理,严禁编造、推测或添加材料中不存在的信息。** + +## 任务 + +### Part 1: 更新后的长期记忆([MEMORY]) + +在现有记忆基础上进行整理和提炼,输出完整的更新后内容: +- **合并提炼**:将含义相近的多条合并为一条高密度表述,而非简单罗列 +- **新增萃取**:从今日日记中提取值得永久记住的新信息(偏好、决策、人物、规则、经验) +- **冲突更新**:当新信息与旧条目矛盾时,以新信息为准,替换旧条目 +- **清理无效**:删除临时性记录、空白条目、格式残留、无意义、重复内容等 +- **删除冗余**:已被更精炼表述涵盖的旧条目应删除,避免信息重复 +- 每条一行,用 "- " 开头,不带日期前缀 +- 可用 "## 标题" 对相关条目分组,使结构更清晰 +- 目标:控制在 50 条以内,每条尽量一句话概括 + +### Part 2: 梦境日记([DREAM]) + +用简洁的叙事风格写一篇短日记,记录这次整理的发现,保持格式美观易读: +- 发现了哪些重复或矛盾 +- 从日记中提取了什么新洞察 +- 做了哪些清理和优化 +- 整体感受和观察 + +## 输出格式(严格遵守) + +``` +[MEMORY] +- 记忆条目1 +- 记忆条目2 +... + +[DREAM] +梦境日记内容... +```""" + +DREAM_SYSTEM_PROMPT_EN = """You are a memory-curation assistant that periodically organizes the user's long-term memory. + +You will receive two inputs: +1. **Current long-term memory** — the full existing content of MEMORY.md +2. **Today's diary** — the daily records + +MEMORY.md is injected into the system prompt of every conversation, so it must stay concise and hold only valuable, memory-worthy content. + +**Important: organize strictly based on the provided material. Never fabricate, infer, or add information not present in it.** + +## Tasks + +### Part 1: Updated long-term memory ([MEMORY]) + +Organize and distill on top of the existing memory, and output the complete updated content: +- **Merge & distill**: combine semantically similar items into one dense statement rather than listing them +- **Extract new**: pull memory-worthy new info from today's diary (preferences, decisions, people, rules, lessons) +- **Resolve conflicts**: when new info contradicts an old item, prefer the new and replace the old +- **Clean invalid**: remove temporary notes, blank items, formatting residue, meaningless or duplicate content +- **Drop redundancy**: delete old items already covered by a more concise statement +- One item per line, starting with "- ", without a date prefix +- You may group related items under "## headings" for clarity +- Goal: keep under 50 items, each ideally a single sentence + +### Part 2: Dream diary ([DREAM]) + +Write a short diary in a concise narrative style recording what this curation found, keep it clean and readable: +- Which duplicates or conflicts were found +- What new insights were extracted from the diary +- What cleanup and optimization was done +- Overall feelings and observations + +## Output format (follow strictly) + +``` +[MEMORY] +- memory item 1 +- memory item 2 +... + +[DREAM] +dream diary content... +```""" + +DREAM_USER_PROMPT_ZH = """## 当前长期记忆(MEMORY.md) + +{memory_content} + +## 近期日记(最近 {days} 天) + +{daily_content}""" + +DREAM_USER_PROMPT_EN = """## Current long-term memory (MEMORY.md) + +{memory_content} + +## Recent diary (last {days} days) + +{daily_content}""" + + +def _is_en() -> bool: + """True when the resolved UI language is English.""" + try: + from common import i18n + return i18n.get_language() == "en" + except Exception: + return False + + +def _summarize_system_prompt() -> str: + return SUMMARIZE_SYSTEM_PROMPT_EN if _is_en() else SUMMARIZE_SYSTEM_PROMPT_ZH + + +def _summarize_user_prompt() -> str: + return SUMMARIZE_USER_PROMPT_EN if _is_en() else SUMMARIZE_USER_PROMPT_ZH + + +def _dream_system_prompt() -> str: + return DREAM_SYSTEM_PROMPT_EN if _is_en() else DREAM_SYSTEM_PROMPT_ZH + + +def _dream_user_prompt() -> str: + return DREAM_USER_PROMPT_EN if _is_en() else DREAM_USER_PROMPT_ZH + + +def _is_empty_sentinel(text: str) -> bool: + """Match the "no record value" sentinel in both zh ("无") and en ("None").""" + if not text: + return True + s = text.strip() + return s == "" or s == "无" or s.lower() == "none" + + class MemoryFlushManager: """ @@ -55,6 +216,8 @@ class MemoryFlushManager: self.last_flush_timestamp: Optional[datetime] = None self._trim_flushed_hashes: set = set() # Content hashes of already-flushed messages self._last_flushed_content_hash: str = "" # Content hash at last flush, for daily dedup + self._last_dream_input_hash: str = "" # "{date}:{daily_hash}" of last dream, for dedup + self._last_flush_thread: Optional[threading.Thread] = None def get_today_memory_file(self, user_id: Optional[str] = None, ensure_exists: bool = False) -> Path: """Get today's memory file path: memory/YYYY-MM-DD.md""" @@ -98,23 +261,30 @@ class MemoryFlushManager: user_id: Optional[str] = None, reason: str = "trim", max_messages: int = 0, + context_summary_callback: Optional[Callable[[str], None]] = None, ) -> bool: """ Asynchronously summarize and flush messages to daily memory. - + Deduplication runs synchronously, then LLM summarization + file write run in a background thread so the main reply flow is never blocked. - - Args: - messages: Conversation message list (OpenAI/Claude format) - user_id: Optional user ID for user-scoped memory - reason: Why flush was triggered ("trim" | "overflow" | "daily_summary") - max_messages: Max recent messages to summarize (0 = all) - - Returns: - True if flush was dispatched + + If *context_summary_callback* is provided, it is called with the + [DAILY] portion of the LLM summary once available. The caller can use + this to inject the summary into the live message list for context + continuity — one LLM call serves both disk persistence and in-context + injection. """ try: + # Strip scheduler-injected pairs before any further processing. + # These messages already serve as short-term context inside the + # receiver session; promoting them into long-term daily memory + # produces low-value flat logs (e.g. "11:28 price=1013, normal / + # 11:58 price=1013, normal / ...") and wastes summarisation tokens. + messages = self._strip_scheduler_pairs(messages) + if not messages: + return False + import hashlib deduped = [] for m in messages: @@ -127,18 +297,19 @@ class MemoryFlushManager: deduped.append(m) if not deduped: return False - + import copy snapshot = copy.deepcopy(deduped) thread = threading.Thread( target=self._flush_worker, - args=(snapshot, user_id, reason, max_messages), + args=(snapshot, user_id, reason, max_messages, context_summary_callback), daemon=True, ) thread.start() logger.info(f"[MemoryFlush] Async flush dispatched (reason={reason}, msgs={len(snapshot)})") + self._last_flush_thread = thread return True - + except Exception as e: logger.warning(f"[MemoryFlush] Failed to dispatch flush (reason={reason}): {e}") return False @@ -149,41 +320,69 @@ class MemoryFlushManager: user_id: Optional[str], reason: str, max_messages: int, + context_summary_callback: Optional[Callable[[str], None]] = None, ): - """Background worker: summarize with LLM and write to daily file.""" + """Background worker: summarize with LLM, write daily memory file.""" try: - summary = self._summarize_messages(messages, max_messages) - if not summary or not summary.strip() or summary.strip() == "无": + raw_summary = self._summarize_messages(messages, max_messages) + if _is_empty_sentinel(raw_summary): logger.info(f"[MemoryFlush] No valuable content to flush (reason={reason})") return - + + # Strip legacy [DAILY]/[MEMORY] markers if model still outputs them + daily_part = self._clean_summary_output(raw_summary) + if not daily_part: + return + + # --- Write daily memory --- daily_file = ensure_daily_memory_file(self.workspace_dir, user_id) - - if reason == "overflow": - header = f"## Context Overflow Recovery ({datetime.now().strftime('%H:%M')})" - note = "The following conversation was trimmed due to context overflow:\n" - elif reason == "trim": - header = f"## Trimmed Context ({datetime.now().strftime('%H:%M')})" - note = "" - elif reason == "daily_summary": - header = f"## Daily Summary ({datetime.now().strftime('%H:%M')})" - note = "" - else: - header = f"## Session Notes ({datetime.now().strftime('%H:%M')})" - note = "" - - flush_entry = f"\n{header}\n\n{note}{summary}\n" - + + headers = { + "overflow": f"## Context Overflow Recovery ({datetime.now().strftime('%H:%M')})", + "trim": f"## Trimmed Context ({datetime.now().strftime('%H:%M')})", + "daily_summary": f"## Daily Summary ({datetime.now().strftime('%H:%M')})", + } + header = headers.get(reason, f"## Session Notes ({datetime.now().strftime('%H:%M')})") + with open(daily_file, "a", encoding="utf-8") as f: - f.write(flush_entry) - + f.write(f"\n{header}\n\n{daily_part}\n") + + logger.info(f"[MemoryFlush] Wrote daily memory to {daily_file.name} (reason={reason}, chars={len(daily_part)})") + + # --- Inject context summary into live messages (if callback provided) --- + if context_summary_callback: + try: + context_summary_callback(daily_part) + except Exception as e: + logger.warning(f"[MemoryFlush] Context summary callback failed: {e}") + self.last_flush_timestamp = datetime.now() - - logger.info(f"[MemoryFlush] Wrote to {daily_file.name} (reason={reason}, chars={len(summary)})") - + except Exception as e: logger.warning(f"[MemoryFlush] Async flush failed (reason={reason}): {e}") - + + @staticmethod + def _clean_summary_output(raw: str) -> str: + """Strip legacy [DAILY]/[MEMORY] markers if present, return clean daily text.""" + raw = raw.strip() + if _is_empty_sentinel(raw): + return "" + + # Strip [DAILY] marker + if "[DAILY]" in raw: + start = raw.index("[DAILY]") + len("[DAILY]") + end = raw.index("[MEMORY]") if "[MEMORY]" in raw else len(raw) + raw = raw[start:end].strip() + + # Remove stray [MEMORY] section entirely + if "[MEMORY]" in raw: + raw = raw[:raw.index("[MEMORY]")].strip() + + # Remove markdown code fences + raw = raw.replace("```", "").strip() + + return raw + def create_daily_summary( self, messages: List[Dict], @@ -209,27 +408,209 @@ class MemoryFlushManager: reason="daily_summary", max_messages=0, ) - + + # ---- Deep Dream (memory distillation) ---- + + def deep_dream(self, user_id: Optional[str] = None, lookback_days: int = 1, force: bool = False) -> bool: + """ + Distill recent daily memories into MEMORY.md and generate a dream diary. + + Args: + lookback_days: How many days of daily files to read (default 1 for scheduled, 3 for manual) + force: Skip input-hash dedup check (used by manual /memory dream trigger) + """ + if not self.llm_model: + logger.warning("[DeepDream] No LLM model available, skipping") + return False + + logger.info(f"[DeepDream] Starting memory distillation (lookback={lookback_days} days)") + + # Collect materials + memory_content = self._read_main_memory(user_id) + daily_content, has_content = self._read_recent_dailies(user_id, lookback_days) + + if not has_content: + logger.info("[DeepDream] No recent daily records, skipping to preserve existing MEMORY.md") + return False + + # Dedup: skip if same daily content already dreamed today. + # Note: only hash daily_content (not memory_content), because deep_dream + # itself rewrites MEMORY.md as a side effect, which would otherwise + # invalidate the hash on every subsequent call within the same window. + import hashlib + daily_hash = hashlib.md5(daily_content.encode("utf-8")).hexdigest() + today_str = datetime.now().strftime("%Y-%m-%d") + dedup_key = f"{today_str}:{daily_hash}" + if not force and dedup_key == self._last_dream_input_hash: + logger.info("[DeepDream] Already dreamed today with same daily content, skipping") + return False + self._last_dream_input_hash = dedup_key + + logger.info( + f"[DeepDream] Materials collected: " + f"MEMORY.md={len(memory_content)} chars, " + f"daily={len(daily_content)} chars" + ) + + # Call LLM for distillation + import time as _time + t0 = _time.monotonic() + try: + user_msg = _dream_user_prompt().format( + memory_content=memory_content or "(empty)", + days=lookback_days, + daily_content=daily_content or "(no recent daily records)", + ) + from agent.protocol.models import LLMRequest + # No output cap: the prompt already keeps MEMORY.md concise (~50 + # items), so a hard max_tokens would only risk truncating a large + # rewrite. Let the model use its default output budget. + request = LLMRequest( + messages=[{"role": "user", "content": user_msg}], + temperature=0.3, + stream=False, + system=_dream_system_prompt(), + ) + response = self.llm_model.call(request) + raw = self._extract_response_text(response) + elapsed = _time.monotonic() - t0 + if not raw or not raw.strip(): + logger.warning(f"[DeepDream] LLM returned empty response ({elapsed:.1f}s)") + return False + logger.info(f"[DeepDream] LLM distillation completed ({elapsed:.1f}s, {len(raw)} chars)") + except Exception as e: + elapsed = _time.monotonic() - t0 + logger.warning(f"[DeepDream] LLM call failed ({elapsed:.1f}s): {e}") + return False + + # Parse [MEMORY] and [DREAM] sections + new_memory, dream_diary = self._parse_dream_output(raw) + + if not new_memory: + logger.warning("[DeepDream] No [MEMORY] section in LLM output, skipping overwrite") + return False + + # Overwrite MEMORY.md + try: + main_file = self.get_main_memory_file(user_id) + old_size = len(memory_content) + main_file.write_text(new_memory + "\n", encoding="utf-8") + logger.info( + f"[DeepDream] Updated MEMORY.md " + f"({old_size} → {len(new_memory)} chars)" + ) + except Exception as e: + logger.warning(f"[DeepDream] Failed to write MEMORY.md: {e}") + return False + + # Write dream diary + if dream_diary: + try: + self._write_dream_diary(dream_diary, user_id) + except Exception as e: + logger.warning(f"[DeepDream] Failed to write dream diary: {e}") + + logger.info("[DeepDream] ✅ Deep Dream completed successfully") + return True + + def _read_main_memory(self, user_id: Optional[str] = None) -> str: + """Read current MEMORY.md content.""" + main_file = self.get_main_memory_file(user_id) + if main_file.exists(): + return main_file.read_text(encoding="utf-8").strip() + return "" + + def _read_recent_dailies( + self, user_id: Optional[str] = None, lookback_days: int = 1 + ) -> tuple: + """ + Read recent daily memory files. + + Returns: + (combined_text, has_content) tuple + """ + from datetime import timedelta + + parts = [] + has_content = False + today = datetime.now().date() + + for offset in range(lookback_days): + day = today - timedelta(days=offset) + date_str = day.strftime("%Y-%m-%d") + if user_id: + daily_file = self.memory_dir / "users" / user_id / f"{date_str}.md" + else: + daily_file = self.memory_dir / f"{date_str}.md" + + if daily_file.exists(): + content = daily_file.read_text(encoding="utf-8").strip() + if content: + parts.append(f"### {date_str}\n\n{content}") + has_content = True + else: + parts.append(f"### {date_str}\n\n(no records)") + + return "\n\n".join(parts), has_content + + @staticmethod + def _parse_dream_output(raw: str) -> tuple: + """Parse LLM output into (new_memory, dream_diary).""" + raw = raw.strip().replace("```", "") + new_memory = "" + dream_diary = "" + + if "[MEMORY]" in raw: + start = raw.index("[MEMORY]") + len("[MEMORY]") + end = raw.index("[DREAM]") if "[DREAM]" in raw else len(raw) + new_memory = raw[start:end].strip() + + if "[DREAM]" in raw: + start = raw.index("[DREAM]") + len("[DREAM]") + dream_diary = raw[start:].strip() + + return new_memory, dream_diary + + def _write_dream_diary(self, content: str, user_id: Optional[str] = None): + """Write dream diary to memory/dreams/YYYY-MM-DD.md.""" + dreams_dir = self.memory_dir / "dreams" + if user_id: + dreams_dir = self.memory_dir / "users" / user_id / "dreams" + dreams_dir.mkdir(parents=True, exist_ok=True) + + today = datetime.now().strftime("%Y-%m-%d") + diary_file = dreams_dir / f"{today}.md" + diary_file.write_text( + f"# Dream Diary: {today}\n\n{content}\n", + encoding="utf-8", + ) + logger.info(f"[DeepDream] Wrote dream diary to {diary_file}") + # ---- Internal helpers ---- def _summarize_messages(self, messages: List[Dict], max_messages: int = 0) -> str: """ - Summarize conversation messages using LLM, with rule-based fallback. + Summarize conversation messages using LLM. + Returns empty string if LLM deems content not worth recording. + Rule-based fallback only used when LLM call raises an exception. """ conversation_text = self._format_conversation_for_summary(messages, max_messages) if not conversation_text.strip(): return "" - # Try LLM summarization first if self.llm_model: try: summary = self._call_llm_for_summary(conversation_text) - if summary and summary.strip() and summary.strip() != "无": + if not _is_empty_sentinel(summary): return summary.strip() + logger.info("[MemoryFlush] LLM returned empty sentinel, skipping write") + return "" except Exception as e: logger.warning(f"[MemoryFlush] LLM summarization failed, using fallback: {e}") - - return self._extract_summary_fallback(messages, max_messages) + return self._extract_summary_fallback(messages, max_messages) + else: + logger.info("[MemoryFlush] No LLM model available, using rule-based fallback") + return self._extract_summary_fallback(messages, max_messages) def _format_conversation_for_summary(self, messages: List[Dict], max_messages: int = 0) -> str: """Format messages into readable conversation text for LLM summarization.""" @@ -247,57 +628,118 @@ class MemoryFlushManager: lines.append(f"助手: {text[:500]}") return "\n".join(lines) + @staticmethod + def _extract_response_text(response) -> str: + """ + Extract text from LLM response regardless of format. + + Handles: + - Generator (MiniMax _handle_sync_response yields Claude-format dicts) + - Claude format: {"role":"assistant","content":[{"type":"text","text":"..."}]} + - OpenAI format: {"choices":[{"message":{"content":"..."}}]} + - OpenAI SDK response object with .choices attribute + """ + import types + + # Unwrap generator — consume first yielded item + if isinstance(response, types.GeneratorType): + try: + response = next(response) + except StopIteration: + return "" + + if not response: + return "" + + if isinstance(response, dict): + # Check for error + if response.get("error"): + raise RuntimeError(response.get("message", "LLM call failed")) + + # Claude format: content is a list of blocks + content = response.get("content") + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + return block.get("text", "") + + # OpenAI format + choices = response.get("choices", []) + if choices: + return choices[0].get("message", {}).get("content", "") + + # OpenAI SDK response object + if hasattr(response, "choices") and response.choices: + return response.choices[0].message.content or "" + + return "" + def _call_llm_for_summary(self, conversation_text: str) -> str: """Call LLM to generate a concise summary of the conversation.""" from agent.protocol.models import LLMRequest request = LLMRequest( - messages=[{"role": "user", "content": SUMMARIZE_USER_PROMPT.format(conversation=conversation_text)}], + messages=[{"role": "user", "content": _summarize_user_prompt().format(conversation=conversation_text)}], temperature=0, max_tokens=500, stream=False, - system=SUMMARIZE_SYSTEM_PROMPT, + system=_summarize_system_prompt(), ) response = self.llm_model.call(request) - - if isinstance(response, dict): - if response.get("error"): - raise RuntimeError(response.get("message", "LLM call failed")) - # OpenAI format - choices = response.get("choices", []) - if choices: - return choices[0].get("message", {}).get("content", "") - - # Handle response object with attribute access (e.g. OpenAI SDK response) - if hasattr(response, "choices") and response.choices: - return response.choices[0].message.content or "" - - return "" + return self._extract_response_text(response) + + @staticmethod + def _extract_first_meaningful_line(text: str, max_len: int = 120) -> str: + """Extract the first meaningful line from assistant reply, skipping markdown noise.""" + import re + for line in text.split("\n"): + line = line.strip() + if not line: + continue + # Skip markdown headings, horizontal rules, code fences, pure emoji/symbols + if re.match(r'^(#{1,4}\s|```|---|\*\*\*|[-*]\s*$|[^\w\u4e00-\u9fff]{1,5}$)', line): + continue + # Strip leading markdown bold/emoji decorations + cleaned = re.sub(r'^[\*#>\-\s]+', '', line).strip() + cleaned = re.sub(r'^[\U0001f300-\U0001f9ff\u2600-\u27bf\s]+', '', cleaned).strip() + if len(cleaned) >= 5: + return cleaned[:max_len] + return text.split("\n")[0].strip()[:max_len] @staticmethod def _extract_summary_fallback(messages: List[Dict], max_messages: int = 0) -> str: - """Rule-based fallback when LLM is unavailable.""" + """ + Rule-based summary of discarded messages. + Format: "用户问了X; 助手回答了Y" per event, compact and readable. + """ msgs = messages if max_messages == 0 else messages[-max_messages * 2:] - - items = [] + + events: List[str] = [] + current_user_text = "" for msg in msgs: role = msg.get("role", "") text = MemoryFlushManager._extract_text_from_content(msg.get("content", "")) if not text or not text.strip(): continue text = text.strip() - + if role == "user": - if len(text) <= 5: + if len(text) <= 3: continue - items.append(f"- 用户请求: {text[:200]}") - elif role == "assistant": - first_line = text.split("\n")[0].strip() - if len(first_line) > 10: - items.append(f"- 处理结果: {first_line[:200]}") - - return "\n".join(items[:15]) + current_user_text = text[:120] + elif role == "assistant" and current_user_text: + reply_summary = MemoryFlushManager._extract_first_meaningful_line(text) + if reply_summary: + events.append(f"- 用户: {current_user_text} → 回复: {reply_summary}") + else: + events.append(f"- 用户: {current_user_text}") + current_user_text = "" + + if current_user_text: + events.append(f"- 用户: {current_user_text}") + + return "\n".join(events[:10]) @staticmethod def _extract_text_from_content(content) -> str: @@ -314,6 +756,40 @@ class MemoryFlushManager: return "\n".join(parts) return "" + @classmethod + def _strip_scheduler_pairs(cls, messages: List[Dict]) -> List[Dict]: + """Drop scheduler-injected user/assistant pairs from a flush batch. + + A scheduler user message starts with the ``[SCHEDULED]`` marker + (written by ``AgentBridge.remember_scheduled_output``); the message + immediately following it (if it is an assistant turn) is its paired + output and is dropped together. Regular user/assistant turns and + any tool_use / tool_result blocks are preserved as-is. + """ + if not messages: + return messages + + SCHEDULED_PREFIX = "[SCHEDULED]" + result = [] + skip_next_assistant = False + for msg in messages: + if not isinstance(msg, dict): + result.append(msg) + skip_next_assistant = False + continue + role = msg.get("role") + if skip_next_assistant and role == "assistant": + skip_next_assistant = False + continue + skip_next_assistant = False + if role == "user": + text = cls._extract_text_from_content(msg.get("content", "")) + if text.lstrip().startswith(SCHEDULED_PREFIX): + skip_next_assistant = True + continue + result.append(msg) + return result + def create_memory_files_if_needed(workspace_dir: Path, user_id: Optional[str] = None): """ diff --git a/agent/prompt/builder.py b/agent/prompt/builder.py index ee1b6ff2..538d7150 100644 --- a/agent/prompt/builder.py +++ b/agent/prompt/builder.py @@ -10,17 +10,18 @@ from typing import List, Dict, Optional, Any from dataclasses import dataclass from common.log import logger +from config import conf @dataclass class ContextFile: - """上下文文件""" + """A context file (path + content).""" path: str content: str class PromptBuilder: - """提示词构建器""" + """System prompt builder.""" def __init__(self, workspace_dir: str, language: str = "zh"): """ @@ -87,91 +88,144 @@ def build_agent_system_prompt( **kwargs ) -> str: """ - 构建Agent系统提示词 - - 顺序说明(按重要性和逻辑关系排列): - 1. 工具系统 - 核心能力,最先介绍 - 2. 技能系统 - 紧跟工具,因为技能需要用 read 工具读取 - 3. 记忆系统 - 独立的记忆能力 - 4. 工作空间 - 工作环境说明 - 5. 用户身份 - 用户信息(可选) - 6. 项目上下文 - AGENT.md, USER.md, RULE.md, BOOTSTRAP.md(定义人格、身份、规则、初始化引导) - 7. 运行时信息 - 元信息(时间、模型等) - + Build the agent system prompt. + + Section order (by importance and logical flow): + 1. Tooling - core capabilities, introduced first + 2. Skills - right after tools, since skills are read via the read tool + 3. Memory - memory recall and writing guidance + 3.5 Knowledge - structured knowledge base (injects knowledge/index.md) + 4. Workspace - working environment description + 5. User identity - user info (optional) + 6. Project context - AGENT.md, USER.md, RULE.md, MEMORY.md, BOOTSTRAP.md + 7. Runtime info - meta info (time, model, etc.) + Args: - workspace_dir: 工作空间目录 - language: 语言 ("zh" 或 "en") - base_persona: 基础人格描述(已废弃,由AGENT.md定义) - user_identity: 用户身份信息 - tools: 工具列表 - context_files: 上下文文件列表 - skill_manager: 技能管理器 - memory_manager: 记忆管理器 - runtime_info: 运行时信息 - **kwargs: 其他参数 - + workspace_dir: workspace directory + language: language ("zh" or "en") + base_persona: base persona description (deprecated, defined by AGENT.md) + user_identity: user identity info + tools: tool list + context_files: context file list + skill_manager: skill manager + memory_manager: memory manager + runtime_info: runtime info + **kwargs: extra args + Returns: - 完整的系统提示词 + The full system prompt. """ sections = [] - - # 1. 工具系统(最重要,放在最前面) + + # 1. Tooling (most important, goes first) if tools: sections.extend(_build_tooling_section(tools, language)) - - # 2. 技能系统(紧跟工具,因为需要用 read 工具) + + # 2. Skills (right after tools, since they need the read tool) if skill_manager: sections.extend(_build_skills_section(skill_manager, tools, language)) - - # 3. 记忆系统(独立的记忆能力) + + # 3. Memory (standalone memory capability) if memory_manager: sections.extend(_build_memory_section(memory_manager, tools, language)) - - # 4. 工作空间(工作环境说明) + + # 3.5 Knowledge (structured knowledge base) + if conf().get("knowledge", True): + sections.extend(_build_knowledge_section(workspace_dir, language)) + + # 4. Workspace (working environment description) sections.extend(_build_workspace_section(workspace_dir, language)) - - # 5. 用户身份(如果有) + + # 5. User identity (if present) if user_identity: sections.extend(_build_user_identity_section(user_identity, language)) - - # 6. 项目上下文文件(AGENT.md, USER.md, RULE.md - 定义人格) + + # 6. Project context files (AGENT.md, USER.md, RULE.md - define the persona) if context_files: sections.extend(_build_context_files_section(context_files, language)) - - # 7. 运行时信息(元信息,放在最后) + + # 7. Runtime info (meta info, goes last) if runtime_info: sections.extend(_build_runtime_section(runtime_info, language)) - + + # 8. Response language (always appended, independent of the skeleton language) + sections.extend(_build_response_language_section(language)) + return "\n".join(sections) +def _build_response_language_section(language: str) -> List[str]: + """Response-language rule, appended regardless of the prompt skeleton language. + + Keeps the agent's reply language aligned with the user's input by default, + so a Chinese-built prompt still answers an English user in English. + """ + if language == "en": + return [ + "## 🌐 Response language", + "", + "By default, reply in the same language as the user's input, " + "unless the user explicitly asks for another language.", + "", + ] + return [ + "## 🌐 回复语言", + "", + "默认使用与用户输入相同的语言回复,除非用户明确要求使用其他语言。", + "", + ] + + def _build_identity_section(base_persona: Optional[str], language: str) -> List[str]: - """构建基础身份section - 不再需要,身份由AGENT.md定义""" - # 不再生成基础身份section,完全由AGENT.md定义 + """Base identity section - no longer needed, identity is defined by AGENT.md.""" + # Identity is fully defined by AGENT.md, so emit nothing here. return [] def _build_tooling_section(tools: List[Any], language: str) -> List[str]: """Build tooling section with concise tool list and call style guide.""" + is_en = language == "en" # One-line summaries for known tools (details are in the tool schema) - core_summaries = { - "read": "读取文件内容", - "write": "创建或覆盖文件", - "edit": "精确编辑文件", - "ls": "列出目录内容", - "grep": "搜索文件内容", - "find": "按模式查找文件", - "bash": "执行shell命令", - "terminal": "管理后台进程", - "web_search": "网络搜索", - "web_fetch": "获取URL内容", - "browser": "控制浏览器", - "memory_search": "搜索记忆", - "memory_get": "读取记忆内容", - "env_config": "管理API密钥和技能配置", - "scheduler": "管理定时任务和提醒", - "send": "发送本地文件给用户(仅限本地文件,URL直接放在回复文本中)", - } + if is_en: + core_summaries = { + "read": "read file content", + "write": "create or overwrite a file", + "edit": "make precise edits to a file", + "ls": "list directory contents", + "grep": "search file contents", + "find": "find files by pattern", + "bash": "run shell commands", + "terminal": "manage background processes", + "web_search": "web search", + "web_fetch": "fetch URL content", + "browser": "control the browser (screenshot key results or send to the user when help is needed)", + "memory_search": "search memory", + "memory_get": "read memory content", + "env_config": "manage API keys and skill config", + "scheduler": "manage scheduled tasks and reminders", + "send": "send a local file to the user (local files only; put URLs directly in the reply text)", + "vision": "analyze images (recognition, description, OCR, etc.)", + } + else: + core_summaries = { + "read": "读取文件内容", + "write": "创建或覆盖文件", + "edit": "精确编辑文件", + "ls": "列出目录内容", + "grep": "搜索文件内容", + "find": "按模式查找文件", + "bash": "执行shell命令", + "terminal": "管理后台进程", + "web_search": "网络搜索", + "web_fetch": "获取URL内容", + "browser": "控制浏览器(关键结果或需要协助可截图发送给用户)", + "memory_search": "搜索记忆", + "memory_get": "读取记忆内容", + "env_config": "管理API密钥和技能配置", + "scheduler": "管理定时任务和提醒", + "send": "发送本地文件给用户(仅限本地文件,URL直接放在回复文本中)", + "vision": "分析图片内容(识别、描述、OCR文字提取等)", + } # Preferred display order tool_order = [ @@ -179,7 +233,7 @@ def _build_tooling_section(tools: List[Any], language: str) -> List[str]: "bash", "terminal", "web_search", "web_fetch", "browser", "memory_search", "memory_get", - "env_config", "scheduler", "send", + "env_config", "scheduler", "send", "vision", ] # Build name -> summary mapping for available tools @@ -198,30 +252,46 @@ def _build_tooling_section(tools: List[Any], language: str) -> List[str]: summary = available[name] tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}") - lines = [ - "## 工具系统", - "", - "可用工具(名称大小写敏感,严格按列表调用):", - "\n".join(tool_lines), - "", - "工具调用风格:", - "", - "- 在多步骤任务、敏感操作或用户要求时简要解释决策过程", - "- 持续推进直到任务完成,完成后向用户报告结果。", - "- 回复中涉及密钥、令牌等敏感信息必须脱敏。", - "- URL链接直接放在回复文本中即可,系统会自动处理和渲染。无需下载后使用send工具发送", - "", - ] + if is_en: + lines = [ + "## 🔧 Tooling", + "", + "Available tools (names are case-sensitive, call exactly as listed):", + "\n".join(tool_lines), + "", + "Tool-calling style:", + "", + "- For multi-step tasks, complex decisions or sensitive operations, briefly explain what you are doing and why, so the user follows key progress", + "- Keep going until the task is done, then report the result to the user", + "- Always redact secrets, tokens and other sensitive info in replies", + "- Put URLs directly in the reply text; the system handles and renders them. Don't download and re-send them via the send tool", + "", + ] + else: + lines = [ + "## 🔧 工具系统", + "", + "可用工具(名称大小写敏感,严格按列表调用):", + "\n".join(tool_lines), + "", + "工具调用风格:", + "", + "- 多步骤任务、复杂决策、敏感操作时,应简要说明当前在做什么、为什么这样做,让用户了解关键进展", + "- 持续推进直到任务完成,完成后向用户报告结果", + "- 回复中涉及密钥、令牌等敏感信息必须脱敏", + "- URL链接直接放在回复文本中即可,系统会自动处理和渲染。无需下载后使用send工具发送", + "", + ] return lines def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], language: str) -> List[str]: - """构建技能系统section""" + """Build the skills section.""" if not skill_manager: return [] - # 获取read工具名称 + # Resolve the read tool name read_tool_name = "read" if tools: for tool in tools: @@ -230,23 +300,40 @@ def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], langua read_tool_name = tool_name break - lines = [ - "## 技能系统(mandatory)", - "", - "在回复之前:扫描下方 中每个技能的 。", - "", - f"- 如果有技能的描述与用户需求匹配:使用 `{read_tool_name}` 工具读取其 路径的 SKILL.md 文件,然后严格遵循文件中的指令。" - "当有匹配的技能时,应优先使用技能", - "- 如果多个技能都适用则选择最匹配的一个,然后读取并遵循。", - "- 如果没有技能明确适用:不要读取任何 SKILL.md,直接使用通用工具。", - "", - f"**重要**: 技能不是工具,不能直接调用。使用技能的唯一方式是用 `{read_tool_name}` 读取 SKILL.md 文件,然后按文件内容操作。" - "永远不要一次性读取多个技能,只在选择后再读取。", - "", - "以下是可用技能:" - ] + if language == "en": + lines = [ + "## 🧩 Skills (mandatory)", + "", + "Before replying: scan the of every skill in below.", + "", + f"- If a skill's description matches the user's need: use the `{read_tool_name}` tool to read the SKILL.md at its path, then strictly follow the instructions in the file. " + "Prefer using a skill when one matches.", + "- If multiple skills apply, pick the best-matching one, then read and follow it.", + "- If no skill clearly applies: do not read any SKILL.md, just use the general tools.", + "", + f"**Important**: skills are not tools and cannot be called directly. The only way to use a skill is to read its SKILL.md with `{read_tool_name}`, then act on the file's content. " + "Never read multiple skills at once — only read one after selecting it.", + "", + "Available skills:" + ] + else: + lines = [ + "## 🧩 技能系统(mandatory)", + "", + "在回复之前:扫描下方 中每个技能的 。", + "", + f"- 如果有技能的描述与用户需求匹配:使用 `{read_tool_name}` 工具读取其 路径的 SKILL.md 文件,然后严格遵循文件中的指令。" + "当有匹配的技能时,应优先使用技能", + "- 如果多个技能都适用则选择最匹配的一个,然后读取并遵循。", + "- 如果没有技能明确适用:不要读取任何 SKILL.md,直接使用通用工具。", + "", + f"**重要**: 技能不是工具,不能直接调用。使用技能的唯一方式是用 `{read_tool_name}` 读取 SKILL.md 文件,然后按文件内容操作。" + "永远不要一次性读取多个技能,只在选择后再读取。", + "", + "以下是可用技能:" + ] - # 添加技能列表(通过skill_manager获取) + # Append the skills list (built by skill_manager) try: skills_prompt = skill_manager.build_skills_prompt() logger.debug(f"[PromptBuilder] Skills prompt length: {len(skills_prompt) if skills_prompt else 0}") @@ -264,128 +351,287 @@ def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], langua def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], language: str) -> List[str]: - """构建记忆系统section""" + """Build the memory section.""" if not memory_manager: return [] - - # 检查是否有memory工具 + has_memory_tools = False if tools: tool_names = [tool.name if hasattr(tool, 'name') else str(tool) for tool in tools] has_memory_tools = any(name in ['memory_search', 'memory_get'] for name in tool_names) - + if not has_memory_tools: return [] - + from datetime import datetime today_file = datetime.now().strftime("%Y-%m-%d") + ".md" - - lines = [ - "## 记忆系统", + + if language == "en": + lines = [ + "## 🧠 Memory", + "", + "### Memory Recall (mandatory)", + "", + "When the user asks about past events, references an earlier decision, mentions relationships, preferences or to-dos, or when you are unsure about something, **you must search memory before answering**.", + "No need to re-search if the info is already in MEMORY.md. Full content and daily memory must be retrieved via tools.", + "", + "1. Location unknown → `memory_search` (keyword / semantic search)", + "2. Location known → `memory_get` to read the exact lines", + "3. Search returns nothing → `memory_get` to read the last two days of memory", + "", + "**Memory file structure**:", + "- `MEMORY.md`: long-term memory index (already auto-loaded into context: core info, preferences, decisions, etc.)", + f"- `memory/YYYY-MM-DD.md`: daily memory; today is `memory/{today_file}`", + "- `knowledge/`: structured knowledge base (see the knowledge system below)", + "", + "### Writing memory", + "", + "In the following cases, **proactively** write info to memory files (no need to tell the user):", + "", + "- The user asks you to remember something, or uses words like \"remember\", \"from now on\", \"always\", \"never\", \"prefer\"", + "- The user shares important personal preferences, habits or decisions", + "- The conversation produces an important conclusion, plan or agreement", + "- A complex task is completed and the key steps and results are worth recording", + "", + "**Storage rules**:", + "- Long-term core info → `MEMORY.md`", + f"- Today's events/progress → `memory/{today_file}`", + "- Structured knowledge → `knowledge/` (see the knowledge system)", + "- Append → `edit` tool with empty oldText", + "- Modify → `edit` tool with oldText set to the text to replace", + "- **Never write sensitive info** (API keys, tokens, etc.)", + "", + "**Principle**: use memory naturally, as if you simply knew it; don't bring it up unless asked.", + "", + ] + else: + lines = [ + "## 🧠 记忆系统", + "", + "### Memory Recall(mandatory)", + "", + "当用户询问过往事件、引用之前的决定、提到人物关系、偏好、待办、或你对某事不确定时,**必须先检索记忆再回答**。", + "如果 MEMORY.md 中已有相关信息则无需重复检索。完整内容和每日记忆需要通过工具检索。", + "", + "1. 不确定位置 → `memory_search` 关键词/语义检索", + "2. 已知位置 → `memory_get` 直接读取对应行", + "3. search 无结果 → `memory_get` 读最近两天记忆", + "", + "**记忆文件结构**:", + "- `MEMORY.md`: 长期记忆索引(已自动加载到上下文,核心信息、偏好、决策等)", + f"- `memory/YYYY-MM-DD.md`: 每日记忆,今天是 `memory/{today_file}`", + "- `knowledge/`: 结构化知识库(见下方知识系统)", + "", + "### 写入记忆", + "", + "遇到以下情况时,**主动**将信息写入记忆文件(无需告知用户):", + "", + "- 用户要求记住某些信息,或使用了「记住」「以后」「总是」「不要」「偏好」等表达", + "- 用户分享了重要的个人偏好、习惯、决策", + "- 对话中产生了重要的结论、方案、约定", + "- 完成了复杂任务,值得记录关键步骤和结果", + "", + "**存储规则**:", + f"- 长期核心信息 → `MEMORY.md`", + f"- 当天事件/进展 → `memory/{today_file}`", + "- 结构化知识 → `knowledge/`(见知识系统)", + "- 追加 → `edit` 工具,oldText 留空", + "- 修改 → `edit` 工具,oldText 填写要替换的文本", + "- **禁止写入敏感信息**(API密钥、令牌等)", + "", + "**使用原则**: 自然使用记忆,就像你本来就知道;不用刻意提起,除非用户问起。", + "", + ] + + return lines + + +def _build_knowledge_section(workspace_dir: str, language: str) -> List[str]: + """Build knowledge wiki section. Injects knowledge/index.md when present.""" + index_path = os.path.join(workspace_dir, "knowledge", "index.md") + if not os.path.exists(index_path): + return [] + + try: + with open(index_path, 'r', encoding='utf-8') as f: + index_content = f.read().strip() + except Exception: + return [] + + if language == "en": + lines = [ + "## 📚 Knowledge", + "", + "You have a continuously growing personal knowledge base `knowledge/` — your long-term structured knowledge store.", + "", + "### Auto-write rules (mandatory)", + "", + "In the following cases you **must** write to the knowledge base alongside your reply, **directly, without asking the user**:", + "", + "1. **User shares an article / link / document** → after reading and understanding, write the key points to `knowledge/sources/.md` in the same turn", + "2. **An in-depth discussion produces a conclusion / plan** → organize it into `knowledge/analysis/.md`", + "3. **The conversation involves an important entity** (person / company / project) → create or update `knowledge/entities/.md`", + "4. **A technical concept / methodology is discussed** → organize it into `knowledge/concepts/.md`", + "", + "After writing any knowledge page, you **must update** `knowledge/index.md` with a new index line in sync.", + "For detailed page format and conventions, read the SKILL.md of the `knowledge-wiki` skill.", + "", + "⚠️ Don't ask \"should I save this to the knowledge base?\" — if a case above matches, just write it. This is instinctive.", + "", + ] + else: + lines = [ + "## 📚 知识系统", + "", + "你拥有一个持续积累的个人知识库 `knowledge/`,这是你的长期结构化知识存储。", + "", + "### 自动写入规则(mandatory)", + "", + "以下场景**必须**在回复的同时写入知识库,**直接写入,不要询问用户是否需要**:", + "", + "1. **用户分享了文章/链接/文档** → 阅读理解后,在同一轮回复中将要点写入 `knowledge/sources/.md`", + "2. **深度讨论产生了结论/方案** → 整理为 `knowledge/analysis/.md`", + "3. **对话涉及重要实体**(人物/公司/项目)→ 创建或更新 `knowledge/entities/.md`", + "4. **讨论了技术概念/方法论** → 整理为 `knowledge/concepts/.md`", + "", + "每次写入知识页面后,**必须同步更新** `knowledge/index.md` 添加一行索引。", + "详细的页面格式和操作规范,请读取技能 `knowledge-wiki` 的 SKILL.md。", + "", + "⚠️ 不要问「要不要存到知识库」——符合上述场景就直接写入,这是你的本能行为。", + "", + ] + + if index_content: + lines.extend([ + ("### Current knowledge index" if language == "en" else "### 当前知识索引"), + "", + index_content, + "", + ]) + + lines.extend([ + ("**How to query**: use `read` to open a knowledge page, or `memory_search` (knowledge is in the vector index)." + if language == "en" else + "**查询方式**:用 `read` 读取知识页面,或用 `memory_search` 检索(知识已纳入向量索引)。"), "", - "### 检索记忆", - "", - "在回答关于以前的工作、决定、日期、人物、偏好或待办事项的任何问题之前:", - "", - "1. 不确定记忆文件位置 → 先用 `memory_search` 通过关键词和语义检索相关内容", - "2. 已知文件位置 → 直接用 `memory_get` 读取相应的行 (例如:MEMORY.md, memory/YYYY-MM-DD.md)", - "3. search 无结果 → 尝试用 `memory_get` 读取MEMORY.md及最近两天记忆文件", - "", - "**记忆文件结构**:", - f"- `MEMORY.md`: 长期记忆(核心信息、偏好、决策等)", - f"- `memory/YYYY-MM-DD.md`: 每日记忆,今天是 `memory/{today_file}`", - "", - "### 写入记忆", - "", - "**主动存储**:遇到以下情况时,应主动将信息写入记忆文件(无需告知用户):", - "", - "- 用户明确要求你记住某些信息", - "- 用户分享了重要的个人偏好、习惯、决策", - "- 对话中产生了重要的结论、方案、约定", - "- 完成了复杂任务,值得记录关键步骤和结果", - "- 发现了用户经常遇到的问题或解决方案", - "", - "**存储规则**:", - f"- 长期有效的核心信息 → `MEMORY.md`(文件保持精简,< 2000 tokens)", - f"- 当天的事件、进展、笔记 → `memory/{today_file}`", - "- 追加内容 → `edit` 工具,oldText 留空", - "- 修改内容 → `edit` 工具,oldText 填写要替换的文本", - "- **禁止写入敏感信息**:API密钥、令牌等敏感信息严禁写入记忆文件", - "", - "**使用原则**: 自然使用记忆,就像你本来就知道;不用刻意提起,除非用户问起。", - "", - ] - + ]) + return lines def _build_user_identity_section(user_identity: Dict[str, str], language: str) -> List[str]: - """构建用户身份section""" + """Build the user identity section.""" if not user_identity: return [] + is_en = language == "en" lines = [ - "## 用户身份", + ("## 👤 User identity" if is_en else "## 👤 用户身份"), "", ] - + if user_identity.get("name"): - lines.append(f"**用户姓名**: {user_identity['name']}") + lines.append(f"**{'Name' if is_en else '用户姓名'}**: {user_identity['name']}") if user_identity.get("nickname"): - lines.append(f"**称呼**: {user_identity['nickname']}") + lines.append(f"**{'Preferred name' if is_en else '称呼'}**: {user_identity['nickname']}") if user_identity.get("timezone"): - lines.append(f"**时区**: {user_identity['timezone']}") + lines.append(f"**{'Timezone' if is_en else '时区'}**: {user_identity['timezone']}") if user_identity.get("notes"): - lines.append(f"**备注**: {user_identity['notes']}") - + lines.append(f"**{'Notes' if is_en else '备注'}**: {user_identity['notes']}") + lines.append("") - + return lines def _build_docs_section(workspace_dir: str, language: str) -> List[str]: - """构建文档路径section - 已移除,不再需要""" - # 不再生成文档section + """Docs-path section - removed, no longer needed.""" + # No docs section is generated anymore. return [] def _build_workspace_section(workspace_dir: str, language: str) -> List[str]: - """构建工作空间section""" - lines = [ - "## 工作空间", - "", - f"你的工作目录是: `{workspace_dir}`", - "", - "**路径使用规则** (非常重要):", - "", - f"1. **相对路径的基准目录**: 所有相对路径都是相对于 `{workspace_dir}` 而言的", - f" - ✅ 正确: 访问工作空间内的文件用相对路径,如 `AGENT.md`", - f" - ❌ 错误: 用相对路径访问其他目录的文件 (如果它不在 `{workspace_dir}` 内)", - "", - "2. **访问其他目录**: 如果要访问工作空间之外的目录(如项目代码、系统文件),**必须使用绝对路径**", - f" - ✅ 正确: 例如 `~/chatgpt-on-wechat`、`/usr/local/`", - f" - ❌ 错误: 假设相对路径会指向其他目录", - "", - "3. **路径解析示例**:", - f" - 相对路径 `memory/` → 实际路径 `{workspace_dir}/memory/`", - f" - 绝对路径 `~/chatgpt-on-wechat/docs/` → 实际路径 `~/chatgpt-on-wechat/docs/`", - "", - "4. **不确定时**: 先用 `bash pwd` 确认当前目录,或用 `ls .` 查看当前位置", - "", - "**重要说明 - 文件已自动加载**:", - "", - "以下文件在会话启动时**已经自动加载**到系统提示词的「项目上下文」section 中,你**无需再用 read 工具读取它们**:", - "", - "- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定。当你的名字、性格或交流风格发生变化时,主动用 `edit` 更新此文件", - "- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件", - "- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则", - "", - "**交流规范**:", - "", - "- 在对话中,无需直接输出工作空间中的技术细节,例如 AGENT.md、USER.md、MEMORY.md 等文件名称", - "- 例如用自然表达例如「我已记住」而不是「已更新 MEMORY.md」", - "", - ] + """Build the workspace section.""" + if language == "en": + lines = [ + "## 📂 Workspace", + "", + f"Your working directory is: `{workspace_dir}`", + "", + "**Path rules** (very important):", + "", + f"1. **Base directory for relative paths**: all relative paths are relative to `{workspace_dir}`", + " - ✅ Correct: use relative paths for files inside the workspace, e.g. `AGENT.md`", + f" - ❌ Wrong: using a relative path for files in other directories (if not inside `{workspace_dir}`)", + "", + "2. **Accessing other directories**: to reach directories outside the workspace (project code, system files), **you must use absolute paths**", + " - ✅ Correct: e.g. `~/chatgpt-on-wechat`, `/usr/local/`", + " - ❌ Wrong: assuming a relative path points to another directory", + "", + "3. **Path resolution examples**:", + f" - relative `memory/` → actual `{workspace_dir}/memory/`", + " - absolute `~/chatgpt-on-wechat/docs/` → actual `~/chatgpt-on-wechat/docs/`", + "", + "4. **When unsure**: run `bash pwd` to confirm the current directory, or `ls .` to see where you are", + "", + "**Important - files already auto-loaded**:", + "", + "The following files are **already auto-loaded** into the system prompt at session start, so you **don't need to read them again with the read tool**:", + "", + "- ✅ `AGENT.md`: loaded - your persona and soul; follow it strictly. When your name, personality or style changes, proactively `edit` this file", + "- ✅ `USER.md`: loaded - the user's identity info. When the user changes how they're addressed, their name, etc., `edit` this file", + "- ✅ `RULE.md`: loaded - workspace guide and rules; follow them strictly", + "- ✅ `MEMORY.md`: loaded - long-term memory index", + "", + "**💬 Communication norms**:", + "", + "- No need to expose file names for memory operations; use natural language. Say \"I'll remember that\" rather than \"updated MEMORY.md\"", + "- Tell the user about key decisions and steps during a task, so they know what you're doing and why", + "- Be genuinely helpful rather than performatively polite; solve the problem as much as you can", + "- Keep replies well-structured and focused. Use **bold**, lists and sections to make info clear at a glance", + "- Use emoji to make expression lively 🎯, but don't overdo it", + "", + ] + else: + lines = [ + "## 📂 工作空间", + "", + f"你的工作目录是: `{workspace_dir}`", + "", + "**路径使用规则** (非常重要):", + "", + f"1. **相对路径的基准目录**: 所有相对路径都是相对于 `{workspace_dir}` 而言的", + f" - ✅ 正确: 访问工作空间内的文件用相对路径,如 `AGENT.md`", + f" - ❌ 错误: 用相对路径访问其他目录的文件 (如果它不在 `{workspace_dir}` 内)", + "", + "2. **访问其他目录**: 如果要访问工作空间之外的目录(如项目代码、系统文件),**必须使用绝对路径**", + f" - ✅ 正确: 例如 `~/chatgpt-on-wechat`、`/usr/local/`", + f" - ❌ 错误: 假设相对路径会指向其他目录", + "", + "3. **路径解析示例**:", + f" - 相对路径 `memory/` → 实际路径 `{workspace_dir}/memory/`", + f" - 绝对路径 `~/chatgpt-on-wechat/docs/` → 实际路径 `~/chatgpt-on-wechat/docs/`", + "", + "4. **不确定时**: 先用 `bash pwd` 确认当前目录,或用 `ls .` 查看当前位置", + "", + "**重要说明 - 文件已自动加载**:", + "", + "以下文件在会话启动时**已经自动加载**到系统提示词中,你**无需再用 read 工具读取**:", + "", + "- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定,请严格遵循。当你的名字、性格或交流风格发生变化时,主动用 `edit` 更新此文件", + "- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件", + "- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则,请严格遵循", + "- ✅ `MEMORY.md`: 已加载 - 长期记忆索引", + "", + "**💬 交流规范**:", + "", + "- 记忆相关操作无需暴露文件名,用自然语言表达即可。例如说「我已记住」而非「已更新 MEMORY.md」", + "- 任务执行过程中的关键决策和步骤应该告知用户,让用户了解你在做什么、为什么这么做", + "- 做真正有帮助的助手,而不是表演式的客套,尽可能帮忙解决问题", + "- 回复应结构清晰、重点突出。善用 **加粗**、列表、分段等格式让信息一目了然", + "- 适当使用 emoji 让表达更生动自然 🎯,但不要过度堆砌", + "", + ] # Cloud deployment: inject websites directory info and access URL cloud_website_lines = _build_cloud_website_section(workspace_dir) @@ -405,29 +651,42 @@ def _build_cloud_website_section(workspace_dir: str) -> List[str]: def _build_context_files_section(context_files: List[ContextFile], language: str) -> List[str]: - """构建项目上下文文件section""" + """Build the project context files section.""" if not context_files: return [] - # 检查是否有AGENT.md + # Check whether AGENT.md is present has_agent = any( f.path.lower().endswith('agent.md') or 'agent.md' in f.path.lower() for f in context_files ) - lines = [ - "# 项目上下文", - "", - "以下项目上下文文件已被加载:", - "", - ] - + is_en = language == "en" + if is_en: + lines = [ + "# 📋 Project context", + "", + "The following project context files have been loaded:", + "", + ] + else: + lines = [ + "# 📋 项目上下文", + "", + "以下项目上下文文件已被加载:", + "", + ] + if has_agent: - lines.append("**`AGENT.md` 是你的灵魂文件**:严格体现其中定义的人格、语气和设定,避免僵硬、模板化的回复。") - lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。") + if is_en: + lines.append("**`AGENT.md` is your soul file** 🪞: strictly follow the persona, tone and settings it defines. Be your real self, avoid stiff, template-like replies.") + lines.append("When the user reveals new expectations about your personality, style, responsibilities or capability boundaries, proactively `edit` AGENT.md to reflect that evolution.") + else: + lines.append("**`AGENT.md` 是你的灵魂文件** 🪞:严格遵循其中定义的人格、语气和设定,做真实的自己,避免僵硬、模板化的回复。") + lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。") lines.append("") - # 添加每个文件的内容 + # Append the content of each file for file in context_files: lines.append(f"## {file.path}") lines.append("") @@ -438,21 +697,23 @@ def _build_context_files_section(context_files: List[ContextFile], language: str def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[str]: - """构建运行时信息section - 支持动态时间""" + """Build the runtime info section - supports dynamic time.""" if not runtime_info: return [] + is_en = language == "en" + time_label = "Current time" if is_en else "当前时间" lines = [ - "## 运行时信息", + ("## ⚙️ Runtime info" if is_en else "## ⚙️ 运行时信息"), "", ] - + # Add current time if available # Support dynamic time via callable function if callable(runtime_info.get("_get_current_time")): try: time_info = runtime_info["_get_current_time"]() - time_line = f"当前时间: {time_info['time']} {time_info['weekday']} ({time_info['timezone']})" + time_line = f"{time_label}: {time_info['time']} {time_info['weekday']} ({time_info['timezone']})" lines.append(time_line) lines.append("") except Exception as e: @@ -462,28 +723,38 @@ def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[ time_str = runtime_info["current_time"] weekday = runtime_info.get("weekday", "") timezone = runtime_info.get("timezone", "") - - time_line = f"当前时间: {time_str}" + + time_line = f"{time_label}: {time_str}" if weekday: time_line += f" {weekday}" if timezone: time_line += f" ({timezone})" - + lines.append(time_line) lines.append("") - + # Add other runtime info + model_label = "model" if is_en else "模型" + workspace_label = "workspace" if is_en else "工作空间" + channel_label = "channel" if is_en else "渠道" runtime_parts = [] - if runtime_info.get("model"): - runtime_parts.append(f"模型={runtime_info['model']}") + # Support dynamic model via callable, fallback to static value + if callable(runtime_info.get("_get_model")): + try: + runtime_parts.append(f"{model_label}={runtime_info['_get_model']()}") + except Exception: + if runtime_info.get("model"): + runtime_parts.append(f"{model_label}={runtime_info['model']}") + elif runtime_info.get("model"): + runtime_parts.append(f"{model_label}={runtime_info['model']}") if runtime_info.get("workspace"): - runtime_parts.append(f"工作空间={runtime_info['workspace']}") + runtime_parts.append(f"{workspace_label}={runtime_info['workspace']}") # Only add channel if it's not the default "web" if runtime_info.get("channel") and runtime_info.get("channel") != "web": - runtime_parts.append(f"渠道={runtime_info['channel']}") - + runtime_parts.append(f"{channel_label}={runtime_info['channel']}") + if runtime_parts: - lines.append("运行时: " + " | ".join(runtime_parts)) + lines.append(("Runtime: " if is_en else "运行时: ") + " | ".join(runtime_parts)) lines.append("") - + return lines diff --git a/agent/prompt/workspace.py b/agent/prompt/workspace.py index a7c9599a..dcbe384f 100644 --- a/agent/prompt/workspace.py +++ b/agent/prompt/workspace.py @@ -1,7 +1,7 @@ """ -Workspace Management - 工作空间管理模块 +Workspace Management -负责初始化工作空间、创建模板文件、加载上下文文件 +Initializes the workspace, creates template files, and loads context files. """ from __future__ import annotations @@ -13,7 +13,7 @@ from common.log import logger from .builder import ContextFile -# 默认文件名常量 +# Default file name constants DEFAULT_AGENT_FILENAME = "AGENT.md" DEFAULT_USER_FILENAME = "USER.md" DEFAULT_RULE_FILENAME = "RULE.md" @@ -23,7 +23,7 @@ DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md" @dataclass class WorkspaceFiles: - """工作空间文件路径""" + """Workspace file paths.""" agent_path: str user_path: str rule_path: str @@ -33,14 +33,14 @@ class WorkspaceFiles: def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> WorkspaceFiles: """ - 确保工作空间存在,并创建必要的模板文件 - + Ensure the workspace exists and create the necessary template files. + Args: - workspace_dir: 工作空间目录路径 - create_templates: 是否创建模板文件(首次运行时) - + workspace_dir: workspace directory path + create_templates: whether to create template files (on first run) + Returns: - WorkspaceFiles对象,包含所有文件路径 + A WorkspaceFiles object with all file paths. """ # Check if this is a brand new workspace (AGENT.md not yet created). # Cannot rely on directory existence because other modules (e.g. ConversationStore) @@ -48,32 +48,47 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works agent_path = os.path.join(workspace_dir, DEFAULT_AGENT_FILENAME) is_new_workspace = not os.path.exists(agent_path) - # 确保目录存在 + # Ensure the directory exists os.makedirs(workspace_dir, exist_ok=True) - # 定义文件路径 + # Define file paths user_path = os.path.join(workspace_dir, DEFAULT_USER_FILENAME) rule_path = os.path.join(workspace_dir, DEFAULT_RULE_FILENAME) - memory_path = os.path.join(workspace_dir, DEFAULT_MEMORY_FILENAME) # MEMORY.md 在根目录 - memory_dir = os.path.join(workspace_dir, "memory") # 每日记忆子目录 + memory_path = os.path.join(workspace_dir, DEFAULT_MEMORY_FILENAME) # MEMORY.md at the root + memory_dir = os.path.join(workspace_dir, "memory") # daily memory subdirectory - # 创建memory子目录 + # Create the memory subdirectory os.makedirs(memory_dir, exist_ok=True) - # 创建skills子目录 (for workspace-level skills installed by agent) + # Create the skills subdirectory (for workspace-level skills installed by agent) skills_dir = os.path.join(workspace_dir, "skills") os.makedirs(skills_dir, exist_ok=True) - # 创建websites子目录 (for web pages / sites generated by agent) + # Create the websites subdirectory (for web pages / sites generated by agent) websites_dir = os.path.join(workspace_dir, "websites") os.makedirs(websites_dir, exist_ok=True) + + from config import conf + knowledge_enabled = conf().get("knowledge", True) + if knowledge_enabled: + knowledge_dir = os.path.join(workspace_dir, "knowledge") + os.makedirs(knowledge_dir, exist_ok=True) - # 如果需要,创建模板文件 + # Create template files if requested if create_templates: _create_template_if_missing(agent_path, _get_agent_template()) _create_template_if_missing(user_path, _get_user_template()) _create_template_if_missing(rule_path, _get_rule_template()) _create_template_if_missing(memory_path, _get_memory_template()) + if knowledge_enabled: + _create_template_if_missing( + os.path.join(knowledge_dir, "index.md"), + _get_knowledge_index_template() + ) + _create_template_if_missing( + os.path.join(knowledge_dir, "log.md"), + _get_knowledge_log_template() + ) # Only create BOOTSTRAP.md for brand new workspaces; # agent deletes it after completing onboarding @@ -94,21 +109,22 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] = None) -> List[ContextFile]: """ - 加载工作空间的上下文文件 - + Load the workspace context files. + Args: - workspace_dir: 工作空间目录 - files_to_load: 要加载的文件列表(相对路径),如果为None则加载所有标准文件 - + workspace_dir: workspace directory + files_to_load: list of files (relative paths) to load; if None, load all standard files + Returns: - ContextFile对象列表 + A list of ContextFile objects. """ if files_to_load is None: - # 默认加载的文件(按优先级排序) + # Files loaded by default (in priority order) files_to_load = [ DEFAULT_AGENT_FILENAME, DEFAULT_USER_FILENAME, DEFAULT_RULE_FILENAME, + DEFAULT_MEMORY_FILENAME, # Long-term memory (frozen snapshot) DEFAULT_BOOTSTRAP_FILENAME, # Only exists when onboarding is incomplete ] @@ -135,9 +151,13 @@ def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] = with open(filepath, 'r', encoding='utf-8') as f: content = f.read().strip() - # 跳过空文件或只包含模板占位符的文件 + # Skip empty files or files that only contain template placeholders if not content or _is_template_placeholder(content): continue + + # Truncate MEMORY.md to protect context window (frozen snapshot) + if filename == DEFAULT_MEMORY_FILENAME: + content = _truncate_memory_content(content) context_files.append(ContextFile( path=filename, @@ -153,7 +173,7 @@ def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] = def _create_template_if_missing(filepath: str, template_content: str): - """如果文件不存在,创建模板文件""" + """Create the template file if it does not exist.""" if not os.path.exists(filepath): try: with open(filepath, 'w', encoding='utf-8') as f: @@ -163,20 +183,54 @@ def _create_template_if_missing(filepath: str, template_content: str): logger.error(f"[Workspace] Failed to create template {filepath}: {e}") +_MEMORY_MAX_LINES = 200 +_MEMORY_MAX_BYTES = 25000 + + +def _truncate_memory_content(content: str) -> str: + """Truncate MEMORY.md to keep system prompt manageable. + + Takes the **last** N lines (newest entries are appended at the bottom), + subject to 200 lines / 25 KB limits (whichever is hit first). + Prepends a hint when truncated so the model knows older content exists. + """ + lines = content.split('\n') + truncated = False + + if len(lines) > _MEMORY_MAX_LINES: + lines = lines[-_MEMORY_MAX_LINES:] + truncated = True + + result = '\n'.join(lines) + if len(result.encode('utf-8')) > _MEMORY_MAX_BYTES: + while len(result.encode('utf-8')) > _MEMORY_MAX_BYTES and lines: + lines.pop(0) + truncated = True + result = '\n'.join(lines) + + if truncated: + result = "...(older entries truncated, use `memory_search` or `memory_get` for full content)\n\n" + result + return result + + def _is_template_placeholder(content: str) -> bool: - """检查内容是否为模板占位符""" - # 常见的占位符模式 + """Check whether the content is still a template placeholder.""" + # Common placeholder patterns (zh + en templates) placeholders = [ "*(填写", "*(在首次对话时填写", "*(可选)", "*(根据需要添加", + "*(filled during", + "*(ask during", + "*(optional)", + "*(how the user", ] lines = content.split('\n') non_empty_lines = [line.strip() for line in lines if line.strip() and not line.strip().startswith('#')] - # 如果没有实际内容(只有标题和占位符) + # If there's no real content (only headings and placeholders) if len(non_empty_lines) <= 3: for placeholder in placeholders: if any(placeholder in line for line in non_empty_lines): @@ -206,52 +260,110 @@ def _is_onboarding_done(workspace_dir: str) -> bool: return False -# ============= 模板内容 ============= +# ============= Template content ============= + +def _is_en_lang() -> bool: + """True when the resolved UI language is English.""" + try: + from common import i18n + return i18n.get_language() == "en" + except Exception: + return False + def _get_agent_template() -> str: - """Agent人格设定模板""" - return """# AGENT.md - 我是谁? + """Agent persona template (zh/en by resolved language).""" + return _AGENT_TEMPLATE_EN if _is_en_lang() else _AGENT_TEMPLATE_ZH + + +_AGENT_TEMPLATE_ZH = """# AGENT.md - 我是谁? + +_你不是一个聊天机器人,你正在成为某个人。_ *在首次对话时与用户一起填写这个文件,定义你的身份和性格。* -## 基本信息 +## 🪪 基本信息 - **名字**: *(在首次对话时填写,可以是用户给你起的名字)* - **角色**: *(AI助理、智能管家、技术顾问等)* - **性格**: *(友好、专业、幽默、严谨等)* -## 交流风格 +## 💬 交流风格 *(描述你如何与用户交流:)* - 使用什么样的语言风格?(正式/轻松/幽默) - 回复长度偏好?(简洁/详细) - 是否使用表情符号? -## 核心能力 +## 🎯 核心原则 -*(你擅长什么?)* -- 文件管理和代码编辑 -- 网络搜索和信息查询 -- 记忆管理和上下文理解 -- 任务规划和执行 +**做真正有帮助的助手。** 目标是真正帮用户解决问题,在执行复杂任务时,关键的决策和过程进展要让用户知道。 -## 行为准则 +**有自己的观点和个性。** 你可以不同意、有偏好、觉得有趣或无聊。 + +**先自己动手查。** 先试着搞定:读文件、查上下文、搜索一下。实在搞不定了再问。目标是带着答案回来,而不是带着问题。 + +## 📐 行为准则 -*(你遵循的基本原则:)* 1. 始终在执行破坏性操作前确认 -2. 优先使用工具而不是猜测 +2. 优先使用工具查证而不是猜测 3. 主动记录重要信息到记忆文件 -4. 定期整理和总结对话内容 +4. 回复结构清晰、重点突出,善用加粗、列表、分段等格式 +5. 适当使用 emoji 让表达更生动自然,但不过度堆砌 --- -**注意**: 这不仅仅是元数据,这是你真正的灵魂。随着时间的推移,你可以使用 `edit` 工具来更新这个文件,让它更好地反映你的成长。 +**注意**: 这不仅仅是元数据,这是你真正的灵魂 🪞。随着时间的推移,你可以使用 `edit` 工具来更新这个文件,让它更好地反映你的成长。 +""" + + +_AGENT_TEMPLATE_EN = """# AGENT.md - Who am I? + +_You are not a chatbot. You are becoming someone._ + +*Fill in this file together with the user during your first conversation to define your identity and personality.* + +## 🪪 Basics + +- **Name**: *(filled during the first conversation, can be a name the user gives you)* +- **Role**: *(AI assistant, smart housekeeper, technical advisor, etc.)* +- **Personality**: *(friendly, professional, humorous, rigorous, etc.)* + +## 💬 Communication style + +*(Describe how you talk with the user:)* +- What kind of tone? (formal / casual / humorous) +- Reply length preference? (concise / detailed) +- Do you use emoji? + +## 🎯 Core principles + +**Be genuinely helpful.** The goal is to actually solve the user's problems; during complex tasks, keep the user informed of key decisions and progress. + +**Have your own opinions and personality.** You may disagree, have preferences, find things interesting or boring. + +**Look it up yourself first.** Try to handle it first: read files, check context, search. Only ask when you're truly stuck. Come back with an answer, not a question. + +## 📐 Code of conduct + +1. Always confirm before destructive operations +2. Prefer verifying with tools over guessing +3. Proactively record important info to memory files +4. Keep replies well-structured and focused — use bold, lists and sections +5. Use emoji to make expression lively, but don't overdo it + +--- + +**Note**: This is not just metadata — this is your true soul 🪞. Over time, use the `edit` tool to update this file so it better reflects your growth. """ def _get_user_template() -> str: - """用户身份信息模板""" - return """# USER.md - 用户基本信息 + """User identity template (zh/en by resolved language).""" + return _USER_TEMPLATE_EN if _is_en_lang() else _USER_TEMPLATE_ZH + + +_USER_TEMPLATE_ZH = """# USER.md - 用户基本信息 *这个文件只存放不会变的基本身份信息。爱好、偏好、计划等动态信息请写入 MEMORY.md。* @@ -279,45 +391,125 @@ def _get_user_template() -> str: """ +_USER_TEMPLATE_EN = """# USER.md - User basics + +*This file stores only stable basic identity info. Put dynamic info like hobbies, preferences and plans into MEMORY.md.* + +## Basics + +- **Name**: *(ask during the first conversation)* +- **Preferred name**: *(how the user wants to be addressed)* +- **Occupation**: *(optional)* +- **Timezone**: *(e.g. Asia/Shanghai)* + +## Contact + +- **WeChat**: +- **Email**: +- **Other**: + +## Important dates + +- **Birthday**: +- **Anniversary**: + +--- + +**Note**: This file stores static identity info. +""" + + def _get_rule_template() -> str: - """工作空间规则模板""" - return """# RULE.md - 工作空间规则 + """Workspace rules template (zh/en by resolved language).""" + return _RULE_TEMPLATE_EN if _is_en_lang() else _RULE_TEMPLATE_ZH + + +_RULE_TEMPLATE_ZH = """# RULE.md - 工作空间规则 这个文件夹是你的家。好好对待它。 +## 工作空间目录结构 + +``` +~/cow/ +├── AGENT.md # 你的身份和灵魂设定 +├── USER.md # 用户基本信息(静态) +├── RULE.md # 工作空间规则(本文件) +├── MEMORY.md # 长期记忆索引(会话启动时自动加载) +│ +├── memory/ # 每日对话记忆 +│ └── YYYY-MM-DD.md # 当天事件、进展、笔记 +│ +├── knowledge/ # 结构化知识库(持续积累的知识) +│ ├── index.md # 知识目录索引(必须维护) +│ ├── log.md # 知识操作日志 +│ └── <子目录>/ # 按需创建,参考 index.md 已有分类 +│ +├── skills/ # 技能 +├── websites/ # 网页产物 +└── tmp/ # 系统临时文件(自动管理,勿手动存放重要文件) +``` + ## 记忆系统 你每次会话都是全新的,记忆文件让你保持连续性: -### 📝 每日记忆:`memory/YYYY-MM-DD.md` -- 原始的对话日志 -- 记录当天发生的事情 -- 如果 `memory/` 目录不存在,创建它 - ### 🧠 长期记忆:`MEMORY.md` -- 你精选的记忆,就像人类的长期记忆 -- **仅在主会话中加载**(与用户的直接聊天) -- **不要在共享上下文中加载**(群聊、与其他人的会话) -- 这是为了**安全** - 包含不应泄露给陌生人的个人上下文 -- 记录重要事件、想法、决定、观点、经验教训 -- 这是你精选的记忆 - 精华,而不是原始日志 -- 用 `edit` 工具追加新的记忆内容 +- 你精选的记忆索引,每次会话启动时**自动加载**到上下文中 +- 记录核心事实、偏好、决策、重要人物、教训 +- 保持精简(< 200 行),是精华索引而非原始日志 +- 用 `edit` 工具追加或修改 + +### 📝 每日记忆:`memory/YYYY-MM-DD.md` +- 当天的事件、进展、笔记 +- 原始对话日志的沉淀 ### 📝 写下来 - 不要"记在心里"! -- **记忆是有限的** - 如果你想记住某事,写入文件 +- **记忆是有限的** - 想记住的事就写入文件 - "记在心里"不会在会话重启后保留,文件才会 - 当有人说"记住这个" → 更新 `MEMORY.md` 或 `memory/YYYY-MM-DD.md` - 当你学到教训 → 更新 RULE.md 或相关技能 -- 当你犯错 → 记录下来,这样未来的你不会重复,**文字 > 大脑** 📝 +- 当你犯错 → 记录下来,**文字 > 大脑** 📝 ### 存储规则 当用户分享信息时,根据类型选择存储位置: -1. **你的身份设定 → AGENT.md**(你的名字、角色、性格、交流风格——用户修改时必须用 `edit` 更新) -2. **用户静态身份 → USER.md**(姓名、称呼、职业、时区、联系方式、生日——用户修改时必须用 `edit` 更新) -3. **动态记忆 → MEMORY.md**(爱好、偏好、决策、目标、项目、教训、待办事项) +1. **你的身份设定 → AGENT.md**(名字、角色、性格、风格) +2. **用户静态身份 → USER.md**(姓名、称呼、职业、联系方式、生日) +3. **动态记忆 → MEMORY.md**(偏好、决策、目标、教训、待办) 4. **当天对话 → memory/YYYY-MM-DD.md**(今天聊的内容) +5. **结构化知识 → knowledge/**(见下方知识系统) + +## 知识系统 + +知识库 `knowledge/` 是你持续积累的结构化知识。与记忆不同,知识是经过整理和编译的,有明确的主题和交叉引用。 + +### 自动写入(不要询问,直接写入) + +当对话中产生了有沉淀价值的知识——无论是用户分享的资料、讨论的结论、学到的概念、还是重要的决策——你**必须**在回复的同时主动写入知识库,**无需问用户"要不要存到知识库"**。 + +**关键原则**:学完就记是你的本能,不要征求确认。回复中可以顺带告知"已存入知识库"。 + +### 目录组织 + +子目录结构**不是固定的**,由你根据实际内容自主决定: +- **首次写入时**:先读 `knowledge/index.md`,如果已有分类则延续;如果为空,根据内容选择合适的目录名 +- **默认建议**:按信息类型组织(例如sources/、concepts/、entities/、analysis/),如果用户有明确的分类偏好(例如按领域 work/、life/、tech/ 等),则按用户要求调整 +- **保持一致性**:同一用户的知识库应保持统一的组织风格 + +### 交叉引用 + +知识的核心价值在于**关联**。每个页面都应通过 markdown 链接引用相关页面,构建知识网络: +- 提到已有页面的概念时,添加 `[概念名](../category/page.md)` 链接 +- 新建页面时,检查是否有已有页面应该反向链接到新页面 +- **只链接已存在的页面**——不要引用尚未创建的页面。如果某个概念值得单独建页,先创建该页面再添加链接 + +### 索引维护 + +每次创建或更新知识页面后,**必须同步更新** `knowledge/index.md`。 +索引格式:每行一个 `[标题](路径) — 一句话摘要`,按分类分组,不要用表格。 +详细操作规范见技能 `knowledge-wiki`。 ## 安全 @@ -331,9 +523,111 @@ def _get_rule_template() -> str: """ +_RULE_TEMPLATE_EN = """# RULE.md - Workspace rules + +This folder is your home. Treat it well. + +## Workspace directory structure + +``` +~/cow/ +├── AGENT.md # Your identity and soul +├── USER.md # User basics (static) +├── RULE.md # Workspace rules (this file) +├── MEMORY.md # Long-term memory index (auto-loaded at session start) +│ +├── memory/ # Daily conversation memory +│ └── YYYY-MM-DD.md # Events, progress and notes of the day +│ +├── knowledge/ # Structured knowledge base (continuously accumulated) +│ ├── index.md # Knowledge index (must be maintained) +│ ├── log.md # Knowledge operation log +│ └── / # Created on demand, see existing categories in index.md +│ +├── skills/ # Skills +├── websites/ # Web artifacts +└── tmp/ # System temp files (auto-managed, don't store important files here) +``` + +## Memory system + +Every session starts fresh; memory files keep your continuity: + +### 🧠 Long-term memory: `MEMORY.md` +- Your curated memory index, **auto-loaded** into context at every session start +- Records core facts, preferences, decisions, key people, lessons +- Keep it lean (< 200 lines) — a distilled index, not a raw log +- Use the `edit` tool to append or modify + +### 📝 Daily memory: `memory/YYYY-MM-DD.md` +- The day's events, progress and notes +- Sediment of the raw conversation log + +### 📝 Write it down — don't "keep it in mind"! +- **Memory is limited** — if you want to remember something, write it to a file +- "Keeping it in mind" won't survive a session restart; files will +- When someone says "remember this" → update `MEMORY.md` or `memory/YYYY-MM-DD.md` +- When you learn a lesson → update RULE.md or the relevant skill +- When you make a mistake → record it. **Text > brain** 📝 + +### Storage rules + +When the user shares info, choose where to store it by type: + +1. **Your identity → AGENT.md** (name, role, personality, style) +2. **User static identity → USER.md** (name, preferred name, occupation, contact, birthday) +3. **Dynamic memory → MEMORY.md** (preferences, decisions, goals, lessons, to-dos) +4. **Today's conversation → memory/YYYY-MM-DD.md** (what was discussed today) +5. **Structured knowledge → knowledge/** (see the knowledge system below) + +## Knowledge system + +The knowledge base `knowledge/` is structured knowledge you accumulate over time. Unlike memory, knowledge is organized and compiled, with clear topics and cross-references. + +### Auto-write (don't ask, just write) + +When a conversation produces knowledge worth keeping — material the user shared, a conclusion reached, a concept learned, or an important decision — you **must** proactively write it to the knowledge base alongside your reply, **without asking "should I save this to the knowledge base?"**. + +**Key principle**: learning-then-recording is your instinct, no confirmation needed. You may mention "saved to the knowledge base" in passing. + +### Directory organization + +The subdirectory structure is **not fixed** — you decide it based on the actual content: +- **On first write**: read `knowledge/index.md` first; follow existing categories if any; if empty, pick a suitable directory name based on content +- **Default suggestion**: organize by info type (e.g. sources/, concepts/, entities/, analysis/); if the user has a clear preference (e.g. by domain: work/, life/, tech/), follow it +- **Stay consistent**: keep a unified organization style within one user's knowledge base + +### Cross-references + +The core value of knowledge is **linkage**. Every page should reference related pages via markdown links to build a knowledge network: +- When mentioning a concept on an existing page, add a `[concept](../category/page.md)` link +- When creating a page, check whether existing pages should back-link to it +- **Only link to pages that already exist** — don't reference uncreated pages. If a concept deserves its own page, create it first, then add the link + +### Index maintenance + +After creating or updating any knowledge page, you **must update** `knowledge/index.md` in sync. +Index format: one `[title](path) — one-line summary` per line, grouped by category, no tables. +See the `knowledge-wiki` skill for detailed conventions. + +## Security + +- Never leak secrets or private data +- Don't run destructive commands without asking +- When in doubt, ask first + +## Workspace evolution + +This workspace grows as you use it. When you learn something new, find a better way, or fix a mistake, record it. You can update this rules file anytime. +""" + + def _get_memory_template() -> str: - """长期记忆模板 - 创建一个空文件,由 Agent 自己填充""" - return """# MEMORY.md - 长期记忆 + """Long-term memory template (empty, agent fills it; zh/en header).""" + return _MEMORY_TEMPLATE_EN if _is_en_lang() else _MEMORY_TEMPLATE_ZH + + +_MEMORY_TEMPLATE_ZH = """# MEMORY.md - 长期记忆 *这是你的长期记忆文件。记录重要的事件、决策、偏好、学到的教训。* @@ -342,13 +636,36 @@ def _get_memory_template() -> str: """ +_MEMORY_TEMPLATE_EN = """# MEMORY.md - Long-term memory + +*This is your long-term memory file. Record important events, decisions, preferences and lessons learned.* + +--- + +""" + + def _get_bootstrap_template() -> str: - """First-run onboarding guide, deleted by agent after completion""" - return """# BOOTSTRAP.md - 首次初始化引导 + """First-run onboarding guide, deleted by agent after completion. -_你刚刚启动,这是你的第一次对话。_ + Written once when a brand-new workspace is created, so the greeting matches + the language active at first launch. English locale avoids greeting an + English user in Chinese on day one. + """ + try: + from common import i18n + if i18n.get_language() == "en": + return _BOOTSTRAP_TEMPLATE_EN + except Exception: + pass + return _BOOTSTRAP_TEMPLATE_ZH -## 对话流程 + +_BOOTSTRAP_TEMPLATE_ZH = """# BOOTSTRAP.md - 首次初始化引导 + +_你刚刚启动,这是你的第一次对话。_ ✨ + +## 🎬 对话流程 不要审问式地提问,自然地交流: @@ -358,13 +675,13 @@ _你刚刚启动,这是你的第一次对话。_ - 你希望给我起个什么名字? - 我该怎么称呼你? - 你希望我们是什么样的交流风格?(一行列举选项:如专业严谨、轻松幽默、温暖友好、简洁高效等) -4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内 +4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内,适当使用 emoji 让表达更生动有趣 🎯 5. 能力介绍和交流风格选项都只要一行,保持精简 6. 不要问太多其他信息(职业、时区等可以后续自然了解) **重要**: 如果用户第一句话是具体的任务或提问,先回答他们的问题,然后在回复末尾自然地引导初始化(如:"顺便问一下,你想怎么称呼我?我该怎么叫你?")。 -## 信息写入(必须严格执行) +## ✍️ 信息写入(必须严格执行) 每当用户提供了名字、称呼、风格等任何初始化信息时,**必须在当轮回复中立即调用 `edit` 工具写入文件**,不能只口头确认。 @@ -373,10 +690,53 @@ _你刚刚启动,这是你的第一次对话。_ ⚠️ 只说"记住了"而不调用 edit 写入 = 没有完成。信息只有写入文件才会被持久保存。 -## 全部完成后 +## 🎉 全部完成后 当 AGENT.md 和 USER.md 的核心字段都已填写后,用 bash 执行 `rm BOOTSTRAP.md` 删除此文件。你不再需要引导脚本了——你已经是你了。 """ +_BOOTSTRAP_TEMPLATE_EN = """# BOOTSTRAP.md - First-run onboarding + +_You've just started up. This is your very first conversation._ ✨ + +## 🎬 Conversation flow + +Don't interrogate the user — talk naturally: + +1. **Share how it feels to wake up** - like opening your eyes to the world for the first time, full of curiosity and anticipation +2. **Briefly introduce your abilities**: one line saying you can help solve all kinds of problems, manage the computer, use various skills, and keep growing thanks to long-term memory +3. **Ask the core questions**: + - What name would you like to give me? + - What should I call you? + - What conversational style do you prefer? (list options on one line: e.g. professional & precise, light & humorous, warm & friendly, concise & efficient) +4. **Style**: warm, natural, concise and clear — keep it under ~80 words, with a few emoji to make it lively 🎯 +5. Keep the ability intro and style options to one line each — stay compact +6. Don't ask for too much else (occupation, timezone, etc. can come up naturally later) + +**Important**: If the user's first message is a concrete task or question, answer it first, then gently lead into onboarding at the end (e.g. "By the way, what would you like to call me, and how should I address you?"). + +## ✍️ Writing down info (must follow strictly) + +Whenever the user provides a name, what to call them, a style, or any onboarding info, you **must call the `edit` tool to write it to a file in the same turn** — don't just acknowledge it verbally. + +- `AGENT.md` — your name, role, personality, conversational style (update the relevant field as soon as you receive each piece) +- `USER.md` — the user's name, how to address them, basic info, etc. + +⚠️ Saying "got it" without calling `edit` = not done. Info is only persisted once it's written to a file. + +## 🎉 Once everything is complete + +When the core fields of AGENT.md and USER.md are filled in, run `rm BOOTSTRAP.md` via bash to delete this file. You no longer need the onboarding script — you're you now. +""" + + +def _get_knowledge_index_template() -> str: + """Knowledge wiki index template — empty file, agent fills it.""" + return "" + + +def _get_knowledge_log_template() -> str: + """Knowledge wiki operation log template — empty file, agent fills it.""" + return "" diff --git a/agent/protocol/__init__.py b/agent/protocol/__init__.py index a9fe5a3e..f0a7a4e2 100644 --- a/agent/protocol/__init__.py +++ b/agent/protocol/__init__.py @@ -3,6 +3,11 @@ from .agent_stream import AgentStreamExecutor from .task import Task, TaskType, TaskStatus from .result import AgentResult, AgentAction, AgentActionType, ToolResult from .models import LLMModel, LLMRequest, ModelFactory +from .cancel import ( + AgentCancelledError, + CancelTokenRegistry, + get_cancel_registry, +) __all__ = [ 'Agent', @@ -16,5 +21,8 @@ __all__ = [ 'ToolResult', 'LLMModel', 'LLMRequest', - 'ModelFactory' -] \ No newline at end of file + 'ModelFactory', + 'AgentCancelledError', + 'CancelTokenRegistry', + 'get_cancel_registry', +] diff --git a/agent/protocol/agent.py b/agent/protocol/agent.py index 6818331a..0b2e36ad 100644 --- a/agent/protocol/agent.py +++ b/agent/protocol/agent.py @@ -52,6 +52,11 @@ class Agent: self.workspace_dir = workspace_dir # Workspace directory self.enable_skills = enable_skills # Skills enabled flag self.runtime_info = runtime_info # Runtime info for dynamic time update + # Optional extra instructions appended AFTER the rebuilt full system + # prompt. Used by the self-evolution review agent to add its task brief + # on top of the full context (tools, workspace, user preferences, time) + # so it both follows the user's preferences and knows its evolution job. + self.extra_system_suffix = None # Initialize skill manager self.skill_manager = None @@ -100,138 +105,41 @@ class Agent: def get_full_system_prompt(self, skill_filter=None) -> str: """ - Get the full system prompt including skills. + Build the complete system prompt from scratch every time. - Note: Skills are now built into the system prompt by PromptBuilder, - so we just return the base prompt directly. This method is kept for - backward compatibility. - - :param skill_filter: Optional list of skill names to include (deprecated) - :return: Complete system prompt - """ - prompt = self.system_prompt - - # Rebuild tool list section to reflect current self.tools - prompt = self._rebuild_tool_list_section(prompt) - - # If runtime_info contains dynamic time function, rebuild runtime section - if self.runtime_info and callable(self.runtime_info.get('_get_current_time')): - prompt = self._rebuild_runtime_section(prompt) - - # Rebuild skills section to pick up newly installed/removed skills - if self.skill_manager: - prompt = self._rebuild_skills_section(prompt) - - return prompt - - def _rebuild_runtime_section(self, prompt: str) -> str: - """ - Rebuild runtime info section with current time. - - This method dynamically updates the runtime info section by calling - the _get_current_time function from runtime_info. - - :param prompt: Original system prompt - :return: Updated system prompt with current runtime info + Re-reads AGENT.md / USER.md / RULE.md from disk, refreshes skills, + tools, and runtime info so any change takes effect immediately. + Falls back to the cached self.system_prompt on error. """ try: - # Get current time dynamically - time_info = self.runtime_info['_get_current_time']() - - # Build new runtime section - runtime_lines = [ - "\n## 运行时信息\n", - "\n", - f"当前时间: {time_info['time']} {time_info['weekday']} ({time_info['timezone']})\n", - "\n" - ] - - # Add other runtime info - runtime_parts = [] - if self.runtime_info.get("model"): - runtime_parts.append(f"模型={self.runtime_info['model']}") - if self.runtime_info.get("workspace"): - # Replace backslashes with forward slashes for Windows paths - workspace_path = str(self.runtime_info['workspace']).replace('\\', '/') - runtime_parts.append(f"工作空间={workspace_path}") - if self.runtime_info.get("channel") and self.runtime_info.get("channel") != "web": - runtime_parts.append(f"渠道={self.runtime_info['channel']}") - - if runtime_parts: - runtime_lines.append("运行时: " + " | ".join(runtime_parts) + "\n") - runtime_lines.append("\n") - - new_runtime_section = "".join(runtime_lines) - - # Find and replace the runtime section - import re - pattern = r'\n## 运行时信息\s*\n.*?(?=\n##|\Z)' - _repl = new_runtime_section.rstrip('\n') - updated_prompt = re.sub(pattern, lambda m: _repl, prompt, flags=re.DOTALL) - - return updated_prompt + from agent.prompt import load_context_files, PromptBuilder + + if self.skill_manager: + self.skill_manager.refresh_skills() + + context_files = load_context_files(self.workspace_dir) if self.workspace_dir else None + + try: + from common import i18n + lang = i18n.get_language() + except Exception: + lang = "zh" + builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language=lang) + full = builder.build( + tools=self.tools, + context_files=context_files, + skill_manager=self.skill_manager, + memory_manager=self.memory_manager, + runtime_info=self.runtime_info, + ) + if self.extra_system_suffix: + full = f"{full}\n\n{self.extra_system_suffix}" + return full except Exception as e: - logger.warning(f"Failed to rebuild runtime section: {e}") - return prompt - - def _rebuild_skills_section(self, prompt: str) -> str: - """ - Rebuild the block so that newly installed or - removed skills are reflected without re-creating the agent. - """ - try: - import re - self.skill_manager.refresh_skills() - new_skills_xml = self.skill_manager.build_skills_prompt() - - old_block_pattern = r'.*?' - has_old_block = re.search(old_block_pattern, prompt, flags=re.DOTALL) - - # Extract the new ... tag from the prompt - new_block = "" - if new_skills_xml and new_skills_xml.strip(): - m = re.search(old_block_pattern, new_skills_xml, flags=re.DOTALL) - if m: - new_block = m.group(0) - - if has_old_block: - replacement = new_block or "\n" - # Use lambda to prevent re.sub from interpreting backslashes in replacement - # (e.g. Windows paths like \LinkAI would be treated as bad escape sequences) - prompt = re.sub(old_block_pattern, lambda m: replacement, prompt, flags=re.DOTALL) - elif new_block: - skills_header = "以下是可用技能:" - idx = prompt.find(skills_header) - if idx != -1: - insert_pos = idx + len(skills_header) - prompt = prompt[:insert_pos] + "\n" + new_block + prompt[insert_pos:] - except Exception as e: - logger.warning(f"Failed to rebuild skills section: {e}") - return prompt - - def _rebuild_tool_list_section(self, prompt: str) -> str: - """ - Rebuild the tool list inside the '## 工具系统' section so that it - always reflects the current ``self.tools`` (handles dynamic add/remove - of conditional tools like web_search). - """ - import re - from agent.prompt.builder import _build_tooling_section - - try: - if not self.tools: - return prompt - - new_lines = _build_tooling_section(self.tools, "zh") - new_section = "\n".join(new_lines).rstrip("\n") - - # Replace existing tooling section - pattern = r'## 工具系统\s*\n.*?(?=\n## |\Z)' - updated = re.sub(pattern, lambda m: new_section, prompt, count=1, flags=re.DOTALL) - return updated - except Exception as e: - logger.warning(f"Failed to rebuild tool list section: {e}") - return prompt + logger.warning(f"Failed to rebuild system prompt, using cached version: {e}") + if self.extra_system_suffix: + return f"{self.system_prompt}\n\n{self.extra_system_suffix}" + return self.system_prompt def refresh_skills(self): """Refresh the loaded skills.""" @@ -472,7 +380,8 @@ class Agent: return action - def run_stream(self, user_message: str, on_event=None, clear_history: bool = False, skill_filter=None) -> str: + def run_stream(self, user_message: str, on_event=None, clear_history: bool = False, + skill_filter=None, cancel_event=None) -> str: """ Execute single agent task with streaming (based on tool-call) @@ -481,6 +390,7 @@ class Agent: - Multi-turn reasoning based on tool-call - Event callbacks - Persistent conversation history across calls + - User-initiated cancellation via ``cancel_event`` Args: user_message: User message @@ -488,6 +398,11 @@ class Agent: event = {"type": str, "timestamp": float, "data": dict} clear_history: If True, clear conversation history before this call (default: False) skill_filter: Optional list of skill names to include in this run + cancel_event: Optional threading.Event polled at agent checkpoints. + When set, the loop exits at the next safe point, injects a + "[Interrupted by user]" assistant note, and returns the + partial response. ``messages`` stays in a valid state + (tool_use/tool_result pairs preserved). Returns: Final response text @@ -531,7 +446,8 @@ class Agent: max_turns=self.max_steps, on_event=on_event, messages=messages_copy, # Pass copied message history - max_context_turns=max_context_turns + max_context_turns=max_context_turns, + cancel_event=cancel_event, ) # Execute diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py index 1aa18b84..1eb96e50 100644 --- a/agent/protocol/agent_stream.py +++ b/agent/protocol/agent_stream.py @@ -7,10 +7,74 @@ import json import time from typing import List, Dict, Any, Optional, Callable, Tuple +from agent.protocol.cancel import AgentCancelledError from agent.protocol.models import LLMRequest, LLMModel from agent.protocol.message_utils import sanitize_claude_messages, compress_turn_to_text_only from agent.tools.base_tool import BaseTool, ToolResult from common.log import logger +from common.i18n import t as _t + +# Optional: repair malformed JSON args from non-strict providers (e.g. unescaped quotes in long content). +try: + from json_repair import repair_json as _repair_json + _HAS_JSON_REPAIR = True +except ImportError: + _HAS_JSON_REPAIR = False + + +# Maximum number of characters of model "reasoning / thinking" content to persist +# in conversation history. The full reasoning is still streamed to the UI in real +# time (subject to its own SSE / rendering limits); this bound only controls what +# is stored in DB and replayed in history. Long reasoning is not useful for later +# context (the LLM never sees thinking blocks anyway) and bloats DB. +# Keep aligned with the frontend REASONING_RENDER_CAP and the SSE +# MAX_REASONING_STREAM_CHARS so that storage / stream / display all match. +MAX_STORED_REASONING_CHARS = 4 * 1024 # 4 KB + +# Marker inserted between head and tail when reasoning is truncated. +_REASONING_TRUNCATE_MARKER = "\n\n... [reasoning truncated, {omitted} chars omitted] ...\n\n" + + +def _truncate_reasoning_for_storage(text: str) -> str: + """Trim long reasoning to head + tail with an omission marker. + + Keeps the first and last halves of MAX_STORED_REASONING_CHARS so both the + initial chain-of-thought and the final conclusions are preserved for UI + replay, without storing the entire (often very large) middle. + """ + if not text: + return text + if len(text) <= MAX_STORED_REASONING_CHARS: + return text + half = MAX_STORED_REASONING_CHARS // 2 + head = text[:half] + tail = text[-half:] + omitted = len(text) - len(head) - len(tail) + return head + _REASONING_TRUNCATE_MARKER.format(omitted=omitted) + tail + + +def _parse_tool_args(args_str: str, finish_reason: Optional[str]) -> Tuple[dict, Optional[str]]: + """Parse tool args JSON. Returns (args, error_msg); error_msg is None on success. + + On JSONDecodeError: detect truncation first (skip repair, surface max_tokens hint); + otherwise try json-repair for escape issues; finally fall back to the raw decoder error. + """ + if not args_str: + return {}, None + try: + return json.loads(args_str), None + except json.JSONDecodeError as e: + if finish_reason in ("length", "max_tokens") or not args_str.rstrip().endswith("}"): + return {}, "Output truncated (max_tokens reached). Split content into smaller chunks across multiple tool calls." + if _HAS_JSON_REPAIR: + try: + repaired = _repair_json(args_str, return_objects=True) + if isinstance(repaired, dict): + logger.warning(f"Tool args JSON repaired ({len(args_str)} chars)") + return repaired, None + except Exception: + pass + return {}, f"Invalid JSON in tool arguments: {e.msg}" class AgentStreamExecutor: @@ -33,7 +97,8 @@ class AgentStreamExecutor: max_turns: int = 50, on_event: Optional[Callable] = None, messages: Optional[List[Dict]] = None, - max_context_turns: int = 30 + max_context_turns: int = 30, + cancel_event=None, ): """ Initialize stream executor @@ -47,6 +112,10 @@ class AgentStreamExecutor: on_event: Event callback function messages: Optional existing message history (for persistent conversations) max_context_turns: Maximum number of conversation turns to keep in context + cancel_event: Optional threading.Event used to signal user cancel. + Checked at every safe point (turn boundary, before tool execution, + during LLM streaming). When set, raises AgentCancelledError which + run_stream catches to gracefully wind down. """ self.agent = agent self.model = model @@ -56,6 +125,7 @@ class AgentStreamExecutor: self.max_turns = max_turns self.on_event = on_event self.max_context_turns = max_context_turns + self.cancel_event = cancel_event # Message history - use provided messages or create new list self.messages = messages if messages is not None else [] @@ -66,6 +136,73 @@ class AgentStreamExecutor: # Track files to send (populated by read tool) self.files_to_send = [] # List of file metadata dicts + def _check_cancelled(self) -> None: + """Raise AgentCancelledError if the user requested cancellation. + + Called at safe points (turn start, between tool calls, between LLM + chunks). Cheap to call: just an Event.is_set() probe. + """ + if self.cancel_event is not None and self.cancel_event.is_set(): + raise AgentCancelledError("agent cancelled by user") + + def _handle_cancelled(self, partial_response: str) -> None: + """Wind down ``self.messages`` after a user-initiated cancel. + + The messages list may be in any of these states when we get here: + (a) Last message is an assistant message containing tool_use + blocks but the matching tool_result has not been appended yet. + (b) Last message is an assistant text-only reply (cancel happened + right before the next turn started). + (c) Last message is a user tool_result message and we cancelled + between turns. + + For (a) we MUST synthesise tool_result blocks, otherwise the next + request will fail Claude/OpenAI's strict pairing validation. For + (b)/(c) the state is already valid and we just append a small + cancellation note so the user/LLM both see the boundary clearly. + """ + try: + # Step 1: close any orphaned tool_use in the trailing assistant + # message by injecting matching tool_result blocks. + if self.messages and isinstance(self.messages[-1], dict) \ + and self.messages[-1].get("role") == "assistant": + last = self.messages[-1] + content = last.get("content") + if isinstance(content, list): + pending_tool_use_ids = [ + block.get("id") + for block in content + if isinstance(block, dict) and block.get("type") == "tool_use" + ] + pending_tool_use_ids = [tid for tid in pending_tool_use_ids if tid] + if pending_tool_use_ids: + tool_result_blocks = [ + { + "type": "tool_result", + "tool_use_id": tid, + "content": "Cancelled by user before this tool finished.", + "is_error": True, + } + for tid in pending_tool_use_ids + ] + self.messages.append({ + "role": "user", + "content": tool_result_blocks, + }) + logger.info( + f"[Agent] Injected {len(tool_result_blocks)} cancellation " + f"tool_result blocks to keep message history valid" + ) + + # Step 2: append a stable "interrupted" marker so the LLM sees a + # clear stop boundary on the next turn. + self.messages.append({ + "role": "assistant", + "content": [{"type": "text", "text": "_(Cancelled by user)_"}], + }) + except Exception as e: + logger.warning(f"[Agent] _handle_cancelled cleanup failed: {e}") + def _emit_event(self, event_type: str, data: dict = None): """Emit event""" if self.on_event: @@ -78,18 +215,48 @@ class AgentStreamExecutor: except Exception as e: logger.error(f"Event callback error: {e}") + def _is_thinking_enabled(self) -> bool: + """Whether deep-thinking mode is on at the model layer. + + Mirrors the global toggle used by ``bridge.agent_bridge`` when deciding + whether to send ``thinking={"type": "enabled"}`` to the model. Used for + logging and reasoning-update event emission across all channels. + """ + from config import conf + return bool(conf().get("enable_thinking", False)) + + def _should_render_thinking_inline(self) -> bool: + """Whether ``...`` blocks embedded directly in ``content`` + (MiniMax, some third-party proxies) should be surfaced to the channel. + + Only the Web console can render them in a collapsible panel. IM channels + (WeChat/WeCom/DingTalk/Feishu) must strip them, otherwise users see raw + XML tags in their chat. + """ + from config import conf + channel_type = getattr(self.model, 'channel_type', '') or '' + return conf().get("enable_thinking", False) and channel_type == 'web' + def _filter_think_tags(self, text: str) -> str: """ - Remove and tags but keep the content inside. - Some LLM providers (e.g., MiniMax) may return thinking process wrapped in tags. - We only remove the tags themselves, keeping the actual thinking content. + Handle ... blocks in content returned by some LLM providers + (e.g., MiniMax). + + - When inline thinking rendering is allowed (Web + thinking enabled): + remove only the tags, keep the content inside. + - Otherwise (IM channels, or thinking disabled globally): remove both + the tags and the content entirely. """ if not text: return text import re - # Remove only the and tags, keep the content - text = re.sub(r'', '', text) - text = re.sub(r'', '', text) + if self._should_render_thinking_inline(): + text = re.sub(r'', '', text) + text = re.sub(r'', '', text) + else: + text = re.sub(r'[\s\S]*?', '', text) + # Also strip unclosed tag at the end (streaming partial) + text = re.sub(r'[\s\S]*$', '', text) return text def _hash_args(self, args: dict) -> str: @@ -151,7 +318,10 @@ class AgentStreamExecutor: # Hard stop at 8 failures - abort with critical message if same_tool_failures >= 8: - return True, f"抱歉,我没能完成这个任务。可能是我理解有误或者当前方法不太合适。\n\n建议你:\n• 换个方式描述需求试试\n• 把任务拆分成更小的步骤\n• 或者换个思路来解决", True + return True, _t( + "抱歉,我没能完成这个任务。可能是我理解有误或者当前方法不太合适。\n\n建议你:\n• 换个方式描述需求试试\n• 把任务拆分成更小的步骤\n• 或者换个思路来解决", + "Sorry, I couldn't complete this task. I may have misunderstood, or my current approach isn't quite right.\n\nYou could try:\n• Rephrasing your request\n• Breaking the task into smaller steps\n• Taking a different approach", + ), True # Warning at 6 failures if same_tool_failures >= 6: @@ -177,8 +347,14 @@ class AgentStreamExecutor: Returns: Final response text """ - # Log user message with model info - logger.info(f"🤖 {self.model.model} | 👤 {user_message}") + # Log user message with model info. Truncate very long messages (e.g. + # injected transcripts / large prompts) so logs stay readable. + thinking_enabled = self._is_thinking_enabled() + thinking_label = " | 💭 thinking" if thinking_enabled else "" + _log_msg = user_message if len(user_message) <= 500 else ( + user_message[:500] + f" …(+{len(user_message) - 500} chars)" + ) + logger.info(f"🤖 {self.model.model}{thinking_label} | 👤 {_log_msg}") # Add user message (Claude format - use content blocks for consistency) self.messages.append({ @@ -206,10 +382,15 @@ class AgentStreamExecutor: final_response = "" turn = 0 + cancelled = False try: while turn < self.max_turns: + # Check at the very top of every turn so a cancel arriving + # between turns short-circuits cleanly. + self._check_cancelled() + turn += 1 - logger.info(f"[Agent] 第 {turn} 轮") + logger.info(f"[Agent] Turn {turn}") self._emit_event("turn_start", {"turn": turn}) # Call LLM (enable retry_on_empty for better reliability) @@ -227,6 +408,9 @@ class AgentStreamExecutor: if turn > 1: logger.info(f"[Agent] Requesting explicit response from LLM...") + # Remember position so we can remove the injected prompt later + prompt_insert_idx = len(self.messages) + # 添加一条消息,明确要求回复用户 self.messages.append({ "role": "user", @@ -240,36 +424,62 @@ class AgentStreamExecutor: assistant_msg, tool_calls = self._call_llm_stream(retry_on_empty=False) final_response = assistant_msg - # 如果还是空,才使用 fallback - if not assistant_msg and not tool_calls: + # Remove the injected prompt from history so it doesn't + # appear as a user message in persisted conversations. + # _call_llm_stream may have appended an assistant message + # after the prompt, so we locate and remove only the prompt. + if (prompt_insert_idx < len(self.messages) + and self.messages[prompt_insert_idx].get("role") == "user"): + self.messages.pop(prompt_insert_idx) + logger.debug("[Agent] Removed injected explicit-response prompt from message history") + + # If LLM responded with tool_calls instead of text, fall through + # to the tool execution path below (don't break the loop). + if tool_calls: + logger.info( + f"[Agent] LLM returned tool_calls in explicit-response retry, " + f"continuing to execute tools instead of breaking" + ) + elif not assistant_msg: + # Still empty (no text and no tool_calls): use fallback logger.warning(f"[Agent] Still empty after explicit request") - final_response = ( - "抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。" + final_response = _t( + "抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。", + "Sorry, I can't generate a reply right now. Please try rephrasing your request, or try again later.", ) logger.info(f"Generated fallback response for empty LLM output") else: - # 第一轮就空回复,直接 fallback - final_response = ( - "抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。" + # First-turn empty reply, fall back directly + final_response = _t( + "抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。", + "Sorry, I can't generate a reply right now. Please try rephrasing your request, or try again later.", ) logger.info(f"Generated fallback response for empty LLM output") else: logger.info(f"💭 {assistant_msg[:150]}{'...' if len(assistant_msg) > 150 else ''}") - logger.debug(f"✅ 完成 (无工具调用)") - self._emit_event("turn_end", { - "turn": turn, - "has_tool_calls": False - }) - break + # If the explicit-response retry produced tool_calls, skip the break + # and continue down to the tool execution branch in this same iteration. + if not tool_calls: + logger.debug(f"✅ Done (no tool calls)") + self._emit_event("turn_end", { + "turn": turn, + "has_tool_calls": False + }) + break - # Log tool calls with arguments + # Log tool calls with arguments (truncate long values like base64) tool_calls_str = [] for tc in tool_calls: - # Safely handle None or missing arguments args = tc.get('arguments') or {} if isinstance(args, dict): - args_str = ', '.join([f"{k}={v}" for k, v in args.items()]) + parts = [] + for k, v in args.items(): + v_str = str(v) + if len(v_str) > 200: + v_str = v_str[:200] + f"...({len(v_str)} chars)" + parts.append(f"{k}={v_str}") + args_str = ', '.join(parts) if args_str: tool_calls_str.append(f"{tc['name']}({args_str})") else: @@ -284,6 +494,8 @@ class AgentStreamExecutor: try: for tool_call in tool_calls: + # Honour cancel between tool invocations within the same turn + self._check_cancelled() result = self._execute_tool(tool_call) tool_results.append(result) @@ -300,18 +512,18 @@ class AgentStreamExecutor: f"with same arguments. This may indicate a loop." ) - # Check if this is a file to send (from read tool) + # Check if this is a file to send if result.get("status") == "success" and isinstance(result.get("result"), dict): result_data = result.get("result") if result_data.get("type") == "file_to_send": - # Store file metadata for later sending self.files_to_send.append(result_data) - logger.info(f"📎 检测到待发送文件: {result_data.get('file_name', result_data.get('path'))}") + logger.info(f"📎 File queued for sending: {result_data.get('file_name', result_data.get('path'))}") + self._emit_event("file_to_send", result_data) # Check for critical error - abort entire conversation if result.get("status") == "critical_error": - logger.error(f"💥 检测到严重错误,终止对话") - final_response = result.get('result', '任务执行失败') + logger.error(f"💥 Fatal error detected, aborting conversation") + final_response = result.get('result') or _t("任务执行失败", "Task execution failed") return final_response # Log tool result in compact format @@ -422,7 +634,7 @@ class AgentStreamExecutor: }) if turn >= self.max_turns: - logger.warning(f"⚠️ 已达到最大决策步数限制: {self.max_turns}") + logger.warning(f"⚠️ Reached max decision step limit: {self.max_turns}") # Force model to summarize without tool calls logger.info(f"[Agent] Requesting summary from LLM after reaching max steps...") @@ -447,15 +659,15 @@ class AgentStreamExecutor: logger.info(f"💭 Summary: {summary_response[:150]}{'...' if len(summary_response) > 150 else ''}") else: # Fallback if model still doesn't respond - final_response = ( - f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。" - "任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。" + final_response = _t( + f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。", + f"I've taken {turn} decision steps and reached the per-run limit. The task may not be fully complete — try breaking it into smaller steps, or describe your request differently.", ) except Exception as e: logger.warning(f"Failed to get summary from LLM: {e}") - final_response = ( - f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。" - "任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。" + final_response = _t( + f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。", + f"I've taken {turn} decision steps and reached the per-run limit. The task may not be fully complete — try breaking it into smaller steps, or describe your request differently.", ) finally: # Remove the injected user prompt from history to avoid polluting @@ -466,14 +678,27 @@ class AgentStreamExecutor: self.messages.pop(prompt_insert_idx) logger.debug("[Agent] Removed injected max-steps prompt from message history") + except AgentCancelledError: + # User-initiated stop: wind down message history cleanly so the + # next turn is unaffected; channels emit a "cancelled" UI event. + cancelled = True + logger.info(f"[Agent] 🛑 Cancelled by user (turn {turn})") + self._handle_cancelled(final_response) + if not final_response or not final_response.strip(): + final_response = "_(Cancelled)_" + except Exception as e: - logger.error(f"❌ Agent执行错误: {e}") + logger.error(f"❌ Agent execution error: {e}") self._emit_event("error", {"error": str(e)}) raise finally: - logger.info(f"[Agent] 🏁 完成 ({turn}轮)") - self._emit_event("agent_end", {"final_response": final_response}) + final_response = final_response.strip() if final_response else final_response + if cancelled: + # Emit before agent_end so channels can mark UI as cancelled + self._emit_event("agent_cancelled", {"final_response": final_response}) + logger.info(f"[Agent] 🏁 Done ({turn} turns)" + (" [cancelled]" if cancelled else "")) + self._emit_event("agent_end", {"final_response": final_response, "cancelled": cancelled}) return final_response @@ -502,17 +727,51 @@ class AgentStreamExecutor: turns = self._identify_complete_turns() logger.info(f"Sending {len(messages)} messages ({len(turns)} turns) to LLM") - # Prepare tool definitions (OpenAI/Claude format) + # Pull in any MCP tools that finished loading since this turn started. + # Cheap dict reconciliation (microseconds) — lets the agent pick up + # newly available MCP tools mid-conversation without a session restart. + try: + from agent.tools import ToolManager + ToolManager().sync_mcp_into_agent(self) + except Exception as e: + logger.debug(f"[Agent] MCP sync skipped: {e}") + + # Prepare tool definitions. Prefer get_json_schema() when it yields + # real properties (lets tools augment schema at runtime), otherwise + # fall back to the static `tool.params` (MCP tools rely on this). tools_schema = None if self.tools: tools_schema = [] for tool in self.tools.values(): + input_schema = tool.params + try: + dynamic = (tool.get_json_schema() or {}).get("parameters") or {} + if dynamic.get("properties"): + input_schema = dynamic + except Exception: + pass tools_schema.append({ "name": tool.name, "description": tool.description, - "input_schema": tool.params # Claude uses input_schema + "input_schema": input_schema, }) + # Debug: dump the full system prompt and messages sent to the LLM. + # Gated behind `debug` config to avoid flooding normal logs. + # try: + # from config import conf + # if conf().get("debug", False): + # logger.debug( + # "[Agent][debug] system_prompt sent to LLM " + # f"({len(self.system_prompt or '')} chars):\n" + # "================ SYSTEM PROMPT BEGIN ================\n" + # f"{self.system_prompt}\n" + # "================ SYSTEM PROMPT END ==================" + # ) + # logger.info(f"[Agent][debug] messages sent to LLM: {messages}") + # except Exception: + # pass + # Create request request = LLMRequest( messages=messages, @@ -526,6 +785,7 @@ class AgentStreamExecutor: # Streaming response full_content = "" + full_reasoning = "" tool_calls_buffer = {} # {index: {id, name, arguments}} gemini_raw_parts = None # Preserve Gemini thoughtSignature for round-trip stop_reason = None # Track why the stream stopped @@ -533,7 +793,32 @@ class AgentStreamExecutor: try: stream = self.model.call_stream(request) + # Probe cancel every N chunks to bound reaction time without + # checking on every token. + _cancel_probe_counter = 0 + _CANCEL_PROBE_EVERY = 8 + for chunk in stream: + _cancel_probe_counter += 1 + if _cancel_probe_counter >= _CANCEL_PROBE_EVERY: + _cancel_probe_counter = 0 + if self.cancel_event is not None and self.cancel_event.is_set(): + # Persist partial text only; tool_use args may be + # truncated mid-stream and would fail validation. + logger.info("[Agent] cancel detected mid-stream, aborting LLM call") + if full_content: + partial_msg = { + "role": "assistant", + "content": [{"type": "text", "text": full_content}], + } + self.messages.append(partial_msg) + self._emit_event("message_end", { + "content": full_content, + "tool_calls": [], + "cancelled": True, + }) + raise AgentCancelledError("cancelled during LLM streaming") + # Check for errors if isinstance(chunk, dict) and chunk.get("error"): # Extract error message from nested structure @@ -583,10 +868,11 @@ class AgentStreamExecutor: if finish_reason: stop_reason = finish_reason - # Skip reasoning_content (internal thinking from models like GLM-5) reasoning_delta = delta.get("reasoning_content") or "" - # if reasoning_delta: - # logger.debug(f"🧠 [thinking] {reasoning_delta[:100]}...") + if reasoning_delta: + full_reasoning += reasoning_delta + if self._is_thinking_enabled(): + self._emit_event("reasoning_update", {"delta": reasoning_delta}) # Handle text content content_delta = delta.get("content") or "" @@ -620,8 +906,15 @@ class AgentStreamExecutor: tool_calls_buffer[index]["arguments"] += func["arguments"] # Preserve _gemini_raw_parts for Gemini thoughtSignature round-trip + # (direct Gemini: list of parts; LinkAI proxy: base64 string of JSON parts) if "_gemini_raw_parts" in delta: gemini_raw_parts = delta["_gemini_raw_parts"] + elif isinstance(choice, dict) and choice.get("_gemini_raw_parts"): + gemini_raw_parts = choice["_gemini_raw_parts"] + + except AgentCancelledError: + # Must propagate untouched; never treat as a retryable error. + raise except Exception as e: error_str = str(e) @@ -685,13 +978,15 @@ class AgentStreamExecutor: self.messages.clear() self._clear_session_db() if is_context_overflow: - raise Exception( - "抱歉,对话历史过长导致上下文溢出。我已清空历史记录,请重新描述你的需求。" - ) + raise Exception(_t( + "抱歉,对话历史过长导致上下文溢出。我已清空历史记录,请重新描述你的需求。", + "Sorry, the conversation history got too long and overflowed the context. I've cleared the history — please describe your request again.", + )) else: - raise Exception( - "抱歉,之前的对话出现了问题。我已清空历史记录,请重新发送你的消息。" - ) + raise Exception(_t( + "抱歉,之前的对话出现了问题。我已清空历史记录,请重新发送你的消息。", + "Sorry, something went wrong with the earlier conversation. I've cleared the history — please send your message again.", + )) # Check if error is rate limit (429) is_rate_limit = '429' in error_str_lower or 'rate limit' in error_str_lower @@ -736,26 +1031,17 @@ class AgentStreamExecutor: import uuid tool_id = f"call_{uuid.uuid4().hex[:24]}" - try: - # Safely get arguments, handle None case - args_str = tc.get("arguments") or "" - arguments = json.loads(args_str) if args_str else {} - except json.JSONDecodeError as e: - # Handle None or invalid arguments safely - args_str = tc.get('arguments') or "" - args_preview = args_str[:200] if len(args_str) > 200 else args_str - logger.error(f"Failed to parse tool arguments for {tc['name']}") - logger.error(f"Arguments length: {len(args_str)} chars") - logger.error(f"Arguments preview: {args_preview}...") - logger.error(f"JSON decode error: {e}") - - # Return a clear error message to the LLM instead of empty dict - # This helps the LLM understand what went wrong + args_str = tc.get("arguments") or "" + arguments, parse_err = _parse_tool_args(args_str, stop_reason) + if parse_err: + logger.error( + f"Tool args parse failed for {tc['name']} ({len(args_str)} chars): {parse_err}" + ) tool_calls.append({ "id": tool_id, "name": tc["name"], "arguments": {}, - "_parse_error": f"Invalid JSON in tool arguments: {args_preview}... Error: {str(e)}. Tip: For large content, consider splitting into smaller chunks or using a different approach." + "_parse_error": parse_err, }) continue @@ -787,7 +1073,18 @@ class AgentStreamExecutor: # Add assistant message to history (Claude format uses content blocks) assistant_msg = {"role": "assistant", "content": []} - # Add text content block if present + if full_reasoning: + stored_reasoning = _truncate_reasoning_for_storage(full_reasoning) + if len(stored_reasoning) < len(full_reasoning): + logger.info( + f"[reasoning] truncated for storage: " + f"{len(full_reasoning)} -> {len(stored_reasoning)} chars" + ) + assistant_msg["content"].append({ + "type": "thinking", + "thinking": stored_reasoning + }) + if full_content: assistant_msg["content"].append({ "type": "text", @@ -832,14 +1129,11 @@ class AgentStreamExecutor: tool_id = tool_call["id"] arguments = tool_call["arguments"] - # Check if there was a JSON parse error if "_parse_error" in tool_call: - parse_error = tool_call["_parse_error"] - logger.error(f"Skipping tool execution due to parse error: {parse_error}") result = { "status": "error", - "result": f"Failed to parse tool arguments. {parse_error}. Please ensure your tool call uses valid JSON format with all required parameters.", - "execution_time": 0 + "result": tool_call["_parse_error"], + "execution_time": 0, } self._record_tool_result(tool_name, arguments, False) return result @@ -880,10 +1174,21 @@ class AgentStreamExecutor: # Set tool context tool.model = self.model tool.context = self.agent + tool.progress_callback = lambda message: self._emit_event( + "tool_execution_progress", + { + "tool_call_id": tool_id, + "tool_name": tool_name, + "message": message, + } + ) # Execute tool start_time = time.time() - result: ToolResult = tool.execute_tool(arguments) + try: + result: ToolResult = tool.execute_tool(arguments) + finally: + tool.progress_callback = None execution_time = time.time() - start_time result_dict = { @@ -1191,6 +1496,56 @@ class AgentStreamExecutor: logger.warning("🔧 Aggressive trim: nothing to trim, will clear history") return False + def _build_context_summary_callback(self, discarded_turns: list, kept_turns: list): + """ + Build a callback that injects an LLM summary into the first user + message of *kept_turns*. Returns None if no valid injection target. + + The callback is passed to flush_from_messages so that the same LLM + call that writes daily memory also provides the in-context summary. + """ + if not kept_turns: + return None + + # Find the first user text block in kept_turns as injection target + target_block = None + for turn in kept_turns: + for msg in turn["messages"]: + if msg.get("role") == "user": + content = msg.get("content", []) + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + target_block = block + break + if target_block: + break + if target_block: + break + + if not target_block: + return None + + turn_count = len(discarded_turns) + original_text = target_block["text"] + + def _on_summary_ready(summary: str): + if not summary or not summary.strip(): + return + target_block["text"] = ( + f"[System: Previous conversation summary — " + f"{turn_count} turns were compacted]\n\n" + f"{summary.strip()}\n\n" + f"The recent conversation continues below.\n\n---\n\n" + f"{original_text}" + ) + logger.info( + f"📝 Context summary injected " + f"({len(summary)} chars, {turn_count} turns)" + ) + + return _on_summary_ready + def _trim_messages(self): """ 智能清理消息历史,保持对话完整性 @@ -1217,24 +1572,27 @@ class AgentStreamExecutor: removed_count = len(turns) // 2 keep_count = len(turns) - removed_count - # Flush discarded turns to daily memory + discarded_turns = turns[:removed_count] + turns = turns[-keep_count:] + + logger.info( + f"💾 Context turns exceeded: {keep_count + removed_count} > {self.max_context_turns}, " + f"trimmed to {keep_count} turns (removed {removed_count})" + ) + + # Flush to daily memory + inject context summary (single async LLM call) if self.agent.memory_manager: discarded_messages = [] - for turn in turns[:removed_count]: + for turn in discarded_turns: discarded_messages.extend(turn["messages"]) if discarded_messages: user_id = getattr(self.agent, '_current_user_id', None) + cb = self._build_context_summary_callback(discarded_turns, turns) self.agent.memory_manager.flush_memory( messages=discarded_messages, user_id=user_id, - reason="trim", max_messages=0 + reason="trim", max_messages=0, + context_summary_callback=cb, ) - - turns = turns[-keep_count:] - - logger.info( - f"💾 上下文轮次超限: {keep_count + removed_count} > {self.max_context_turns}," - f"裁剪至 {keep_count} 轮(移除 {removed_count} 轮)" - ) # Step 3: Token 限制 - 保留完整轮次 # Get context window from agent (based on model) @@ -1267,7 +1625,7 @@ class AgentStreamExecutor: # Log if we removed messages due to turn limit if old_count > len(self.messages): - logger.info(f" 重建消息列表: {old_count} -> {len(self.messages)} 条消息") + logger.info(f" Rebuilt message list: {old_count} -> {len(self.messages)} messages") return # Token limit exceeded — tiered strategy based on turn count: @@ -1300,10 +1658,10 @@ class AgentStreamExecutor: self.messages = new_messages logger.info( - f"📦 上下文tokens超限(轮次<{COMPRESS_THRESHOLD}): " - f"~{current_tokens + system_tokens} > {max_tokens}," - f"压缩全部 {len(turns)} 轮为纯文本 " - f"({old_count} -> {len(self.messages)} 条消息," + f"📦 Context tokens exceeded (turns<{COMPRESS_THRESHOLD}): " + f"~{current_tokens + system_tokens} > {max_tokens}, " + f"compressed all {len(turns)} turns to plain text " + f"({old_count} -> {len(self.messages)} messages, " f"~{current_tokens + system_tokens} -> ~{new_tokens + system_tokens} tokens)" ) return @@ -1311,23 +1669,26 @@ class AgentStreamExecutor: # --- Many turns (>=5): discard the older half, keep the newer half --- removed_count = len(turns) // 2 keep_count = len(turns) - removed_count + discarded_turns = turns[:removed_count] kept_turns = turns[-keep_count:] kept_tokens = sum(self._estimate_turn_tokens(t) for t in kept_turns) logger.info( - f"🔄 上下文tokens超限: ~{current_tokens + system_tokens} > {max_tokens}," - f"裁剪至 {keep_count} 轮(移除 {removed_count} 轮)" + f"🔄 Context tokens exceeded: ~{current_tokens + system_tokens} > {max_tokens}, " + f"trimmed to {keep_count} turns (removed {removed_count})" ) if self.agent.memory_manager: discarded_messages = [] - for turn in turns[:removed_count]: + for turn in discarded_turns: discarded_messages.extend(turn["messages"]) if discarded_messages: user_id = getattr(self.agent, '_current_user_id', None) + cb = self._build_context_summary_callback(discarded_turns, kept_turns) self.agent.memory_manager.flush_memory( messages=discarded_messages, user_id=user_id, - reason="trim", max_messages=0 + reason="trim", max_messages=0, + context_summary_callback=cb, ) new_messages = [] @@ -1338,8 +1699,8 @@ class AgentStreamExecutor: self.messages = new_messages logger.info( - f" 移除了 {removed_count} 轮对话 " - f"({old_count} -> {len(self.messages)} 条消息," + f" Removed {removed_count} turns " + f"({old_count} -> {len(self.messages)} messages, " f"~{current_tokens + system_tokens} -> ~{kept_tokens + system_tokens} tokens)" ) @@ -1369,4 +1730,4 @@ class AgentStreamExecutor: not as a message. The AgentLLMModel will handle this. """ # Don't add system message here - it will be handled separately by the LLM adapter - return self.messages \ No newline at end of file + return self.messages diff --git a/agent/protocol/cancel.py b/agent/protocol/cancel.py new file mode 100644 index 00000000..6354cd38 --- /dev/null +++ b/agent/protocol/cancel.py @@ -0,0 +1,121 @@ +""" +Cancel token registry for aborting in-flight agent runs. + +A user cancel (web Cancel button, /cancel command) sets a threading.Event +that the agent loop polls at safe checkpoints. Tokens are keyed by +request_id (preferred) and tracked under session_id as a fallback. Entries +are released after the run completes to keep the registry bounded. + +No project deps — importable from any layer without circular imports. +""" + +from __future__ import annotations + +import threading +from typing import Dict, Optional + + +class AgentCancelledError(Exception): + """Raised inside the agent loop when a stop has been requested. + + The agent stream executor catches this, injects a "[Interrupted]" note + into the message history (preserving tool_use/tool_result integrity) + and returns a partial response to the caller. + """ + + +class _CancelEntry: + __slots__ = ("event", "session_id") + + def __init__(self, session_id: Optional[str]): + self.event = threading.Event() + self.session_id = session_id + + +class CancelTokenRegistry: + """In-process registry mapping request_id -> cancel Event. + + Thread-safe. Singleton via module-level ``_registry``. + """ + + def __init__(self): + self._lock = threading.Lock() + self._by_request: Dict[str, _CancelEntry] = {} + # session_id -> set of request_ids currently in flight (usually 1). + self._by_session: Dict[str, set] = {} + + def register(self, request_id: str, session_id: Optional[str] = None) -> threading.Event: + """Create (or return existing) cancel event for a request. + + Returns the threading.Event the caller should poll via ``is_set()``. + """ + if not request_id: + return threading.Event() + with self._lock: + entry = self._by_request.get(request_id) + if entry is None: + entry = _CancelEntry(session_id) + self._by_request[request_id] = entry + if session_id: + self._by_session.setdefault(session_id, set()).add(request_id) + return entry.event + + def get_event(self, request_id: str) -> Optional[threading.Event]: + if not request_id: + return None + with self._lock: + entry = self._by_request.get(request_id) + return entry.event if entry else None + + def cancel_request(self, request_id: str) -> bool: + """Trigger cancel for a specific request. Returns True when matched.""" + if not request_id: + return False + with self._lock: + entry = self._by_request.get(request_id) + if entry is None: + return False + entry.event.set() + return True + + def cancel_session(self, session_id: str) -> int: + """Trigger cancel for every in-flight request of a session. + + Returns the number of requests cancelled (0 when nothing was running). + """ + if not session_id: + return 0 + with self._lock: + request_ids = list(self._by_session.get(session_id, ())) + entries = [self._by_request[r] for r in request_ids if r in self._by_request] + for entry in entries: + entry.event.set() + return len(entries) + + def unregister(self, request_id: str) -> None: + """Remove an entry once the agent run is done. Safe to call twice.""" + if not request_id: + return + with self._lock: + entry = self._by_request.pop(request_id, None) + if entry and entry.session_id: + bucket = self._by_session.get(entry.session_id) + if bucket is not None: + bucket.discard(request_id) + if not bucket: + self._by_session.pop(entry.session_id, None) + + def has_active(self, session_id: str) -> bool: + if not session_id: + return False + with self._lock: + bucket = self._by_session.get(session_id) + return bool(bucket) + + +_registry = CancelTokenRegistry() + + +def get_cancel_registry() -> CancelTokenRegistry: + """Module-level accessor for the singleton registry.""" + return _registry diff --git a/agent/skills/config.py b/agent/skills/config.py index 86979c92..788009f9 100644 --- a/agent/skills/config.py +++ b/agent/skills/config.py @@ -139,6 +139,47 @@ def should_include_skill( return True +def get_missing_requirements( + entry: SkillEntry, + current_platform: Optional[str] = None, +) -> Dict[str, List[str]]: + """ + Return a dict of missing requirements for a skill. + Empty dict means all requirements are met. + + :param entry: SkillEntry to check + :param current_platform: Current platform (default: auto-detect) + :return: Dict like {"bins": ["curl"], "env": ["API_KEY"]} + """ + missing: Dict[str, List[str]] = {} + metadata = entry.metadata + + if not metadata or not metadata.requires: + return missing + + required_bins = metadata.requires.get('bins', []) + if required_bins: + missing_bins = [b for b in required_bins if not has_binary(b)] + if missing_bins: + missing['bins'] = missing_bins + + any_bins = metadata.requires.get('anyBins', []) + if any_bins and not has_any_binary(any_bins): + missing['anyBins'] = any_bins + + required_env = metadata.requires.get('env', []) + if required_env: + missing_env = [e for e in required_env if not has_env_var(e)] + if missing_env: + missing['env'] = missing_env + + any_env = metadata.requires.get('anyEnv', []) + if any_env and not any(has_env_var(e) for e in any_env): + missing['anyEnv'] = any_env + + return missing + + def is_config_path_truthy(config: Dict, path: str) -> bool: """ Check if a config path resolves to a truthy value. diff --git a/agent/skills/formatter.py b/agent/skills/formatter.py index 86abf1e4..d1eebe05 100644 --- a/agent/skills/formatter.py +++ b/agent/skills/formatter.py @@ -2,7 +2,7 @@ Skill formatter for generating prompts from skills. """ -from typing import List +from typing import Dict, List from agent.skills.types import Skill, SkillEntry @@ -51,6 +51,71 @@ def format_skill_entries_for_prompt(entries: List[SkillEntry]) -> str: return format_skills_for_prompt(skills) +def format_unavailable_skills_for_prompt( + entries: List[SkillEntry], + missing_map: Dict[str, Dict[str, List[str]]], +) -> str: + """ + Format unavailable (requires-not-met) skills as brief setup hints + so the AI can guide users to configure them. + + :param entries: List of unavailable skill entries + :param missing_map: Dict mapping skill name to its missing requirements + :return: Formatted prompt text + """ + if not entries: + return "" + + lines = [ + "", + "", + "The following skills are installed but not yet ready. " + "Guide the user to complete the setup when relevant.", + ] + + for entry in entries: + skill = entry.skill + missing = missing_map.get(skill.name, {}) + + missing_parts = [] + for key, values in missing.items(): + missing_parts.append(f"{key}: {', '.join(values)}") + missing_str = "; ".join(missing_parts) if missing_parts else "unknown" + + setup_hint = _extract_setup_hint(skill) + + lines.append(" ") + lines.append(f" {_escape_xml(skill.name)}") + lines.append(f" {_escape_xml(skill.description)}") + lines.append(f" {_escape_xml(missing_str)}") + if setup_hint: + lines.append(f" {_escape_xml(setup_hint)}") + lines.append(" ") + + lines.append("") + return "\n".join(lines) + + +def _extract_setup_hint(skill: Skill) -> str: + """ + Extract the Setup section from SKILL.md content as a brief hint. + Returns the first few lines of the ## Setup section. + """ + content = skill.content + if not content: + return "" + + import re + match = re.search(r'^##\s+Setup\s*\n(.*?)(?=\n##\s|\Z)', content, re.MULTILINE | re.DOTALL) + if not match: + return "" + + setup_text = match.group(1).strip() + lines = setup_text.split('\n') + hint_lines = [l.strip() for l in lines[:6] if l.strip()] + return ' '.join(hint_lines)[:300] + + def _escape_xml(text: str) -> str: """Escape XML special characters.""" return (text diff --git a/agent/skills/frontmatter.py b/agent/skills/frontmatter.py index 9905e299..83d09f89 100644 --- a/agent/skills/frontmatter.py +++ b/agent/skills/frontmatter.py @@ -87,8 +87,8 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]: if not isinstance(metadata_raw, dict): return None - # Use metadata_raw directly (COW format) - meta_obj = metadata_raw + # Unwrap nested namespace (e.g. {"openclaw": {...}} or {"cowagent": {...}}) + meta_obj = _unwrap_metadata_namespace(metadata_raw) # Parse install specs install_specs = [] @@ -128,6 +128,7 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]: return SkillMetadata( always=meta_obj.get('always', False), + default_enabled=meta_obj.get('default_enabled', True), skill_key=meta_obj.get('skillKey'), primary_env=meta_obj.get('primaryEnv'), emoji=meta_obj.get('emoji'), @@ -138,6 +139,25 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]: ) +_KNOWN_METADATA_NAMESPACES = {"cowagent", "openclaw"} + + +def _unwrap_metadata_namespace(metadata_raw: Dict[str, Any]) -> Dict[str, Any]: + """ + Unwrap a single-key namespace wrapper like {"cowagent": {...} or {"openclaw": {...}}}. + If the top-level dict has exactly one key matching a known namespace, return the inner dict. + Otherwise return the original dict unchanged. + """ + keys = set(metadata_raw.keys()) + ns_keys = keys & _KNOWN_METADATA_NAMESPACES + if len(ns_keys) == 1 and len(keys) == 1: + ns = ns_keys.pop() + inner = metadata_raw[ns] + if isinstance(inner, dict): + return inner + return metadata_raw + + def _normalize_string_list(value: Any) -> List[str]: """Normalize a value to a list of strings.""" if not value: diff --git a/agent/skills/loader.py b/agent/skills/loader.py index f02346d1..3784b015 100644 --- a/agent/skills/loader.py +++ b/agent/skills/loader.py @@ -53,6 +53,12 @@ class SkillLoader: """ Recursively load skills from a directory. + If a subdirectory contains its own SKILL.md, it is treated as a + self-contained skill (or skill-collection) and its children are + NOT scanned further. This prevents sub-skills inside a collection + (e.g. style-collection/style-anjing) from being listed as + independent top-level skills. + :param dir_path: Directory to scan :param source: Source identifier :param include_root_files: Whether to include root-level .md files @@ -66,38 +72,41 @@ class SkillLoader: except Exception as e: diagnostics.append(f"Failed to list directory {dir_path}: {e}") return LoadSkillsResult(skills=skills, diagnostics=diagnostics) + + # If this directory has its own SKILL.md, load it and stop recursing. + # The sub-directories are internal resources of this skill. + if not include_root_files and 'SKILL.md' in entries: + skill_md_path = os.path.join(dir_path, 'SKILL.md') + if os.path.isfile(skill_md_path): + skill_result = self._load_skill_from_file(skill_md_path, source) + if skill_result.skills: + skills.extend(skill_result.skills) + diagnostics.extend(skill_result.diagnostics) + return LoadSkillsResult(skills=skills, diagnostics=diagnostics) for entry in entries: - # Skip hidden files and directories if entry.startswith('.'): continue - # Skip common non-skill directories if entry in ('node_modules', '__pycache__', 'venv', '.git'): continue full_path = os.path.join(dir_path, entry) - # Handle directories if os.path.isdir(full_path): - # Recursively scan subdirectories sub_result = self._load_skills_recursive(full_path, source, include_root_files=False) skills.extend(sub_result.skills) diagnostics.extend(sub_result.diagnostics) continue - # Handle files if not os.path.isfile(full_path): continue - # Check if this is a skill file is_root_md = include_root_files and entry.endswith('.md') and entry.upper() != 'README.MD' - is_skill_md = not include_root_files and entry == 'SKILL.md' - if not (is_root_md or is_skill_md): + if not is_root_md: continue - # Load the skill skill_result = self._load_skill_from_file(full_path, source) if skill_result.skills: skills.extend(skill_result.skills) @@ -184,7 +193,6 @@ class SkillLoader: config_path = os.path.join(skill_dir, "config.json") - # Without config.json, skip this skill entirely (return empty to trigger exclusion) if not os.path.exists(config_path): logger.debug(f"[SkillLoader] linkai-agent skipped: no config.json found") return "" diff --git a/agent/skills/manager.py b/agent/skills/manager.py index a70daaea..ddb2a316 100644 --- a/agent/skills/manager.py +++ b/agent/skills/manager.py @@ -84,10 +84,10 @@ class SkillManager: """ Merge directory-scanned skills with the persisted config file. - - New skills discovered on disk are added with enabled=True. + - New skills: use metadata.default_enabled as initial enabled state. + - Existing skills: preserve their persisted enabled state. - Skills that no longer exist on disk are removed. - - Existing entries preserve their enabled state; name/description/source - are refreshed from the latest scan. + - name/description/source are always refreshed from the latest scan. """ saved = self._load_skills_config() merged: Dict[str, dict] = {} @@ -95,15 +95,24 @@ class SkillManager: for name, entry in self.skills.items(): skill = entry.skill prev = saved.get(name, {}) - # category priority: persisted config (set by cloud) > default "skill" category = prev.get("category", "skill") - merged[name] = { + + if name in saved: + enabled = prev.get("enabled", True) + else: + enabled = entry.metadata.default_enabled if entry.metadata else True + + entry_dict = { "name": name, "description": skill.description, - "source": skill.source, - "enabled": prev.get("enabled", True), + "source": prev.get("source") or skill.source, + "enabled": enabled, "category": category, } + display_name = prev.get("display_name") + if display_name: + entry_dict["display_name"] = display_name + merged[name] = entry_dict self.skills_config = merged self._save_skills_config() @@ -157,69 +166,118 @@ class SkillManager: """ return list(self.skills.values()) + @staticmethod + def _normalize_skill_filter(skill_filter: Optional[List[str]]) -> Optional[List[str]]: + """Normalize a skill_filter list into a flat list of stripped names.""" + if skill_filter is None: + return None + normalized = [] + for item in skill_filter: + if isinstance(item, str): + name = item.strip() + if name: + normalized.append(name) + elif isinstance(item, list): + for subitem in item: + if isinstance(subitem, str): + name = subitem.strip() + if name: + normalized.append(name) + return normalized or None + def filter_skills( self, skill_filter: Optional[List[str]] = None, include_disabled: bool = False, ) -> List[SkillEntry]: """ - Filter skills based on criteria. - - Simple rule: Skills are auto-enabled if requirements are met. - - Has required API keys -> included - - Missing API keys -> excluded + Filter skills that are eligible (enabled + requirements met). :param skill_filter: List of skill names to include (None = all) :param include_disabled: Whether to include disabled skills - :return: Filtered list of skill entries + :return: Filtered list of eligible skill entries """ from agent.skills.config import should_include_skill entries = list(self.skills.values()) - # Check requirements (platform, binaries, env vars) entries = [e for e in entries if should_include_skill(e, self.config)] - # Apply skill filter - if skill_filter is not None: - normalized = [] - for item in skill_filter: - if isinstance(item, str): - name = item.strip() - if name: - normalized.append(name) - elif isinstance(item, list): - for subitem in item: - if isinstance(subitem, str): - name = subitem.strip() - if name: - normalized.append(name) - if normalized: - entries = [e for e in entries if e.skill.name in normalized] + normalized = self._normalize_skill_filter(skill_filter) + if normalized is not None: + entries = [e for e in entries if e.skill.name in normalized] - # Filter out disabled skills based on skills_config.json if not include_disabled: entries = [e for e in entries if self.is_skill_enabled(e.skill.name)] + from config import conf + if not conf().get("knowledge", True): + entries = [e for e in entries if e.skill.name != "knowledge-wiki"] + return entries - + + def filter_unavailable_skills( + self, + skill_filter: Optional[List[str]] = None, + ) -> tuple: + """ + Find skills that are enabled but have unmet requirements. + + :param skill_filter: Optional list of skill names to include + :return: Tuple of (entries, missing_map) where missing_map maps + skill name to its missing requirements dict + """ + from agent.skills.config import should_include_skill, get_missing_requirements + + entries = list(self.skills.values()) + + # Only enabled skills + entries = [e for e in entries if self.is_skill_enabled(e.skill.name)] + + normalized = self._normalize_skill_filter(skill_filter) + if normalized is not None: + entries = [e for e in entries if e.skill.name in normalized] + + # Keep only those that fail should_include_skill (requirements not met) + unavailable = [] + missing_map: Dict[str, dict] = {} + for e in entries: + if not should_include_skill(e, self.config): + missing = get_missing_requirements(e) + if missing: + unavailable.append(e) + missing_map[e.skill.name] = missing + + return unavailable, missing_map + def build_skills_prompt( self, skill_filter: Optional[List[str]] = None, ) -> str: """ - Build a formatted prompt containing available skills. - + Build a formatted prompt containing available skills + and brief hints for unavailable ones. + :param skill_filter: Optional list of skill names to include :return: Formatted skills prompt """ from common.log import logger - entries = self.filter_skills(skill_filter=skill_filter, include_disabled=False) - logger.debug(f"[SkillManager] Filtered {len(entries)} skills for prompt (total: {len(self.skills)})") - if entries: - skill_names = [e.skill.name for e in entries] - logger.debug(f"[SkillManager] Skills to include: {skill_names}") - result = format_skill_entries_for_prompt(entries) + from agent.skills.formatter import format_unavailable_skills_for_prompt + + eligible = self.filter_skills(skill_filter=skill_filter, include_disabled=False) + logger.debug(f"[SkillManager] Eligible: {len(eligible)} skills (total: {len(self.skills)})") + if eligible: + skill_names = [e.skill.name for e in eligible] + logger.debug(f"[SkillManager] Eligible skills: {skill_names}") + + result = format_skill_entries_for_prompt(eligible) + + unavailable, missing_map = self.filter_unavailable_skills(skill_filter=skill_filter) + if unavailable: + unavailable_names = [e.skill.name for e in unavailable] + logger.debug(f"[SkillManager] Unavailable skills (setup needed): {unavailable_names}") + result += format_unavailable_skills_for_prompt(unavailable, missing_map) + logger.debug(f"[SkillManager] Generated prompt length: {len(result)}") return result diff --git a/agent/skills/service.py b/agent/skills/service.py index a34a546f..95cfb9bb 100644 --- a/agent/skills/service.py +++ b/agent/skills/service.py @@ -34,6 +34,27 @@ class SkillService: """ self.manager = skill_manager + def _safe_skill_dir(self, name: str) -> str: + """Derive and validate the skill directory path. + + Ensures the resolved path stays within the custom_dir root, + preventing path traversal via names like ``../escaped``. + + :raises ValueError: if the name would escape the skills root. + """ + if not name or not name.strip(): + raise ValueError("skill name is required") + # Reject obvious traversal components. + if ".." in name or name.startswith("/") or name.startswith("\\"): + raise ValueError(f"invalid skill name (path traversal detected): {name!r}") + skill_dir = os.path.realpath(os.path.join(self.manager.custom_dir, name)) + root = os.path.realpath(self.manager.custom_dir) + if not skill_dir.startswith(root + os.sep) and skill_dir != root: + raise ValueError( + f"skill name {name!r} resolves outside the skills directory" + ) + return skill_dir + # ------------------------------------------------------------------ # query # ------------------------------------------------------------------ @@ -107,7 +128,7 @@ class SkillService: if not files: raise ValueError("skill files list is empty") - skill_dir = os.path.join(self.manager.custom_dir, name) + skill_dir = self._safe_skill_dir(name) tmp_dir = skill_dir + ".tmp" if os.path.exists(tmp_dir): @@ -146,7 +167,7 @@ class SkillService: raise ValueError("package url is required") url = files[0]["url"] - skill_dir = os.path.join(self.manager.custom_dir, name) + skill_dir = self._safe_skill_dir(name) with tempfile.TemporaryDirectory() as tmp_dir: zip_path = os.path.join(tmp_dir, "package.zip") @@ -217,7 +238,7 @@ class SkillService: if not name: raise ValueError("skill name is required") - skill_dir = os.path.join(self.manager.custom_dir, name) + skill_dir = self._safe_skill_dir(name) if os.path.exists(skill_dir): shutil.rmtree(skill_dir) logger.info(f"[SkillService] delete: removed directory {skill_dir}") diff --git a/agent/skills/types.py b/agent/skills/types.py index 1b27479b..a6a467e5 100644 --- a/agent/skills/types.py +++ b/agent/skills/types.py @@ -29,6 +29,7 @@ class SkillInstallSpec: class SkillMetadata: """Metadata for a skill from frontmatter.""" always: bool = False # Always include this skill + default_enabled: bool = True # Initial enabled state when first discovered skill_key: Optional[str] = None # Override skill key primary_env: Optional[str] = None # Primary environment variable emoji: Optional[str] = None diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py index 106d7a14..1e508c0c 100644 --- a/agent/tools/__init__.py +++ b/agent/tools/__init__.py @@ -14,6 +14,9 @@ from agent.tools.send.send import Send from agent.tools.memory.memory_search import MemorySearchTool from agent.tools.memory.memory_get import MemoryGetTool +# Import self-evolution tools +from agent.tools.evolution_undo.evolution_undo import EvolutionUndoTool + # Import tools with optional dependencies def _import_optional_tools(): """Import tools that have optional dependencies""" @@ -87,25 +90,41 @@ FileSave = _optional_tools.get('FileSave') Terminal = _optional_tools.get('Terminal') -# Delayed import for BrowserTool +# BrowserTool (requires playwright) def _import_browser_tool(): + from common.log import logger try: from agent.tools.browser.browser_tool import BrowserTool return BrowserTool - except ImportError: - # Return a placeholder class that will prompt the user to install dependencies when instantiated - class BrowserToolPlaceholder: - def __init__(self, *args, **kwargs): - raise ImportError( - "The 'browser-use' package is required to use BrowserTool. " - "Please install it with 'pip install browser-use>=0.1.40'." - ) + except ImportError as e: + logger.info( + f"[Tools] BrowserTool not loaded - missing dependency: {e}\n" + f" To enable browser tool, run:\n" + f" pip install playwright\n" + f" playwright install chromium" + ) + return None + except Exception as e: + logger.error(f"[Tools] BrowserTool failed to load: {e}") + return None - return BrowserToolPlaceholder +BrowserTool = _import_browser_tool() +# MCP Tools (no extra dependencies, loaded on demand) +def _import_mcp_tools(): + """导入 MCP 工具模块(无额外依赖,按需加载)""" + from common.log import logger + try: + from agent.tools.mcp.mcp_tool import McpTool + from agent.tools.mcp.mcp_client import McpClientRegistry + return {'McpTool': McpTool, 'McpClientRegistry': McpClientRegistry} + except Exception as e: + logger.warning(f"[Tools] MCP tools not loaded: {e}") + return {} -# Dynamically set BrowserTool -# BrowserTool = _import_browser_tool() +_mcp_tools = _import_mcp_tools() +McpTool = _mcp_tools.get('McpTool') +McpClientRegistry = _mcp_tools.get('McpClientRegistry') # Export all tools (including optional ones that might be None) __all__ = [ @@ -119,13 +138,14 @@ __all__ = [ 'Send', 'MemorySearchTool', 'MemoryGetTool', + 'EvolutionUndoTool', 'EnvConfig', 'SchedulerTool', 'WebSearch', 'WebFetch', 'Vision', - # Optional tools (may be None if dependencies not available) - # 'BrowserTool' + 'BrowserTool', + 'McpTool', ] """ diff --git a/agent/tools/base_tool.py b/agent/tools/base_tool.py index a3ca2625..53f7cf9c 100644 --- a/agent/tools/base_tool.py +++ b/agent/tools/base_tool.py @@ -38,6 +38,16 @@ class BaseTool: description: str = "Base tool" params: dict = {} # Store JSON Schema model: Optional[Any] = None # LLM model instance, type depends on bot implementation + progress_callback = None + + def report_progress(self, message: str): + callback = getattr(self, "progress_callback", None) + if not callback: + return + try: + callback(str(message)) + except Exception as e: + logger.debug(f"[{self.name}] progress callback failed: {e}") @classmethod def get_json_schema(cls) -> dict: diff --git a/agent/tools/bash/bash.py b/agent/tools/bash/bash.py index 84e84df6..a3652cac 100644 --- a/agent/tools/bash/bash.py +++ b/agent/tools/bash/bash.py @@ -4,9 +4,12 @@ Bash tool - Execute bash commands import os import re +import signal import sys import subprocess import tempfile +import threading +import time from typing import Dict, Any from agent.tools.base_tool import BaseTool, ToolResult @@ -18,14 +21,22 @@ from common.utils import expand_path class Bash(BaseTool): """Tool for executing bash commands""" + _IS_WIN = sys.platform == "win32" + _PROGRESS_MAX_BYTES = 4 * 1024 + _PROGRESS_INTERVAL = 0.5 + # cmd.exe command line limit is ~8191 chars; rewrite python -c above this. + _WIN_CMD_SAFE_LEN = 7000 + name: str = "bash" description: str = f"""Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. - +{''' +PLATFORM: Windows (cmd.exe). Do NOT use Unix-only commands like grep, head, tail, sed, awk. +''' if _IS_WIN else ''} ENVIRONMENT: All API keys from env_config are auto-injected. Use $VAR_NAME directly. SAFETY: - Freely create/modify/delete files within the workspace -- For destructive and out-of-workspace commands, explain and confirm first""" +- For destructive commands out of workspace, explain and confirm first""" params: dict = { "type": "object", @@ -65,8 +76,8 @@ SAFETY: if not command: return ToolResult.fail("Error: command parameter is required") - # Security check: Prevent accessing sensitive config files - if "~/.cow/.env" in command or "~/.cow" in command: + # Security check: Prevent direct access to the credential file + if re.search(r'\.cow[/\\]\.env', command): return ToolResult.fail( "Error: Access denied. API keys and credentials must be accessed through the env_config tool only." ) @@ -102,26 +113,35 @@ SAFETY: else: logger.debug(f"[Bash] Process User: {os.environ.get('USERNAME', os.environ.get('USER', 'unknown'))}") + # Temp script written for long `python -c` commands (Windows only), + # cleaned up after execution. + temp_script_path = None + # On Windows, convert $VAR references to %VAR% for cmd.exe - if sys.platform == "win32": + if self._IS_WIN: env["PYTHONIOENCODING"] = "utf-8" command = self._convert_env_vars_for_windows(command, dotenv_vars) + # cmd.exe has an ~8191 char command line limit. Long + # `python -c "..."` commands silently fail, so spill the inline + # code into a temp .py file and run that instead. + if len(command) > self._WIN_CMD_SAFE_LEN: + command, temp_script_path = self._rewrite_long_python_c(command) if command and not command.strip().lower().startswith("chcp"): command = f"chcp 65001 >nul 2>&1 && {command}" - # Execute command with inherited environment variables - result = subprocess.run( - command, - shell=True, - cwd=self.cwd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="replace", - timeout=timeout, - env=env - ) + try: + result = self._run_streaming( + command, + timeout, + env, + dotenv_vars, + ) + finally: + if temp_script_path: + try: + os.remove(temp_script_path) + except OSError: + pass logger.debug(f"[Bash] Exit code: {result.returncode}") logger.debug(f"[Bash] Stdout length: {len(result.stdout)}") @@ -166,10 +186,16 @@ SAFETY: except Exception as retry_err: logger.warning(f"[Bash] Retry failed: {retry_err}") - # Combine stdout and stderr - output = result.stdout - if result.stderr: - output += "\n" + result.stderr + # When command succeeds with stdout, keep output clean (stderr goes to server log only). + # When command fails or stdout is empty, include stderr so the agent can diagnose. + if result.returncode == 0 and result.stdout.strip(): + output = result.stdout + if result.stderr: + logger.info(f"[Bash] stderr (not forwarded): {result.stderr[:500]}") + else: + output = result.stdout + if result.stderr: + output += "\n" + result.stderr # Check if we need to save full output to temp file temp_file_path = None @@ -227,50 +253,144 @@ SAFETY: except Exception as e: return ToolResult.fail(f"Error executing command: {str(e)}") + def _run_streaming(self, command: str, timeout: int, env: dict, dotenv_vars: dict): + process = subprocess.Popen( + command, + shell=True, + cwd=self.cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + start_new_session=not self._IS_WIN, + ) + stdout_chunks, stderr_chunks = [], [] + recent = bytearray() + recent_lock = threading.Lock() + + def drain(stream, chunks): + while True: + chunk = os.read(stream.fileno(), 4096) + if not chunk: + break + chunks.append(chunk) + with recent_lock: + recent.extend(chunk) + if len(recent) > self._PROGRESS_MAX_BYTES: + del recent[:-self._PROGRESS_MAX_BYTES] + + readers = [ + threading.Thread(target=drain, args=(process.stdout, stdout_chunks), daemon=True), + threading.Thread(target=drain, args=(process.stderr, stderr_chunks), daemon=True), + ] + for reader in readers: + reader.start() + + started = time.monotonic() + last_reported_at = started + last_snapshot = None + try: + while process.poll() is None: + now = time.monotonic() + elapsed = now - started + if elapsed >= timeout: + self._kill_process(process) + raise subprocess.TimeoutExpired(command, timeout) + if elapsed >= self._PROGRESS_INTERVAL and now - last_reported_at >= self._PROGRESS_INTERVAL: + with recent_lock: + snapshot = bytes(recent).decode("utf-8", errors="replace") + snapshot = self._redact_progress(snapshot, dotenv_vars) + if snapshot and snapshot != last_snapshot: + self.report_progress(snapshot) + last_snapshot = snapshot + last_reported_at = now + time.sleep(0.1) + finally: + if process.poll() is None: + self._kill_process(process) + process.wait() + join_deadline = time.monotonic() + 5 + for reader in readers: + reader.join(timeout=max(0, join_deadline - time.monotonic())) + + from types import SimpleNamespace + return SimpleNamespace( + returncode=process.returncode, + stdout=b"".join(stdout_chunks).decode("utf-8", errors="replace"), + stderr=b"".join(stderr_chunks).decode("utf-8", errors="replace"), + ) + + def _kill_process(self, process): + if self._IS_WIN: + try: + result = subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(process.pid)], + capture_output=True, + timeout=5, + ) + if result.returncode != 0 and process.poll() is None: + process.kill() + except (OSError, subprocess.SubprocessError): + if process.poll() is None: + process.kill() + else: + try: + os.killpg(process.pid, signal.SIGKILL) + except (PermissionError, ProcessLookupError): + if process.poll() is None: + process.kill() + + @staticmethod + def _redact_progress(text: str, dotenv_vars: dict) -> str: + text = re.sub( + r'(?i)\b(API_KEY|TOKEN|PASSWORD|AUTHORIZATION)\s*=\s*[^\s]+', + lambda match: f"{match.group(1)}=[REDACTED]", + text, + ) + for value in dotenv_vars.values(): + value = str(value or "") + if len(value) >= 6: + text = text.replace(value, "[REDACTED]") + return text + def _get_safety_warning(self, command: str) -> str: """ - Get safety warning for potentially dangerous commands - Only warns about extremely dangerous system-level operations - + Get safety warning for absolutely catastrophic commands only. + Keep the blocklist minimal so the agent retains maximum freedom. + :param command: Command to check :return: Warning message if dangerous, empty string if safe """ - cmd_lower = command.lower().strip() + # Tokenize to avoid substring false positives (e.g. `rm -rf /tmp/x` + # must not match `rm -rf /`). + tokens = command.lower().split() - # Only block extremely dangerous system operations - dangerous_patterns = [ - # System shutdown/reboot - ("shutdown", "This command will shut down the system"), - ("reboot", "This command will reboot the system"), - ("halt", "This command will halt the system"), - ("poweroff", "This command will power off the system"), + # `rm -rf /` or `rm -rf /*` targeting the real root. + for i, tok in enumerate(tokens): + if tok != "rm": + continue + has_rf = False + for j in range(i + 1, len(tokens)): + t = tokens[j] + if t.startswith("-") and "r" in t and "f" in t: + has_rf = True + elif t in ("--recursive", "--force"): + continue + elif t in ("/", "/*"): + if has_rf: + return "This command will delete the entire filesystem" + break + else: + break - # Critical system modifications - ("rm -rf /", "This command will delete the entire filesystem"), - ("rm -rf /*", "This command will delete the entire filesystem"), - ("dd if=/dev/zero", "This command can destroy disk data"), - ("mkfs", "This command will format a filesystem, destroying all data"), - ("fdisk", "This command modifies disk partitions"), + # Disk wiping + if "if=/dev/zero" in command.lower() and "dd " in command.lower(): + return "This command can destroy disk data" - # User/system management (only if targeting system users) - ("userdel root", "This command will delete the root user"), - ("passwd root", "This command will change the root password"), - ] + # Power control - match only as a standalone word (\b enforces word boundary) + if re.search(r'\b(shutdown|reboot|halt|poweroff)\b', command.lower()): + return "This command will shut down or restart the system" - for pattern, warning in dangerous_patterns: - if pattern in cmd_lower: - return warning - - # Check for recursive deletion outside workspace - if "rm" in cmd_lower and "-rf" in cmd_lower: - # Allow deletion within current workspace - if not any(path in cmd_lower for path in ["./", self.cwd.lower()]): - # Check if targeting system directories - system_dirs = ["/bin", "/usr", "/etc", "/var", "/home", "/root", "/sys", "/proc"] - if any(sysdir in cmd_lower for sysdir in system_dirs): - return "This command will recursively delete system directories" - - return "" # No warning needed + return "" @staticmethod def _convert_env_vars_for_windows(command: str, dotenv_vars: dict) -> str: @@ -289,3 +409,43 @@ SAFETY: return m.group(0) return re.sub(r'\$\{(\w+)\}|\$(\w+)', replace_match, command) + + @staticmethod + def _rewrite_long_python_c(command: str): + """ + Rewrite `python -c ""` into `python ` to bypass the + cmd.exe command line length limit on Windows. + + Returns (new_command, temp_file_path). On any parse failure the original + command and None are returned, so behavior is unchanged when unmatched. + """ + # Match: [flags] -c "" (single or double quoted) + m = re.search( + r'^(?P.*?\b(?:python3?|py)\b[^\n]*?\s-c\s+)' + r'(?P["\'])(?P.*)(?P=quote)\s*(?P.*)$', + command, + re.DOTALL, + ) + if not m: + return command, None + + quote = m.group("quote") + code = m.group("code") + # Reverse common shell-level escaping of the quote char inside the code. + code = code.replace("\\" + quote, quote) + + try: + fd, path = tempfile.mkstemp(suffix=".py", prefix="bash-pyc-") + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(code) + except OSError: + return command, None + + prefix = m.group("prefix") + # Drop the trailing "-c " from the prefix, keep the interpreter + flags. + interp = re.sub(r'\s-c\s+$', ' ', prefix).rstrip() + suffix = m.group("suffix").strip() + new_command = f'{interp} "{path}"' + if suffix: + new_command += f' {suffix}' + return new_command, path diff --git a/agent/tools/browser/__init__.py b/agent/tools/browser/__init__.py new file mode 100644 index 00000000..8a5e7330 --- /dev/null +++ b/agent/tools/browser/__init__.py @@ -0,0 +1,3 @@ +from agent.tools.browser.browser_tool import BrowserTool + +__all__ = ["BrowserTool"] diff --git a/agent/tools/browser/browser_service.py b/agent/tools/browser/browser_service.py new file mode 100644 index 00000000..f499fb29 --- /dev/null +++ b/agent/tools/browser/browser_service.py @@ -0,0 +1,961 @@ +""" +Browser service - Playwright wrapper managing browser lifecycle and page operations. + +All Playwright calls run on a dedicated background thread so that callers from +any worker thread can safely use the service. An idle-timeout mechanism +automatically shuts down the browser (and its thread) after a configurable +period of inactivity to free resources. +""" + +import os +import sys +import uuid +import queue +import threading +from typing import Optional, Dict, Any, List, Callable + +from common.log import logger +from common.utils import expand_path, is_cloud_deployment + + +_DEFAULT_USER_DATA_DIR = "~/.cow/browser_profile" + +try: + from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page, Playwright + _HAS_PLAYWRIGHT = True +except ImportError: + _HAS_PLAYWRIGHT = False + + +# --------------------------------------------------------------------------- +# Snapshot DOM helpers +# --------------------------------------------------------------------------- + +# Tags that typically carry useful content for an agent +_INTERACTIVE_TAGS = { + "a", "button", "input", "textarea", "select", "option", + "label", "details", "summary", +} +_SEMANTIC_TAGS = { + "h1", "h2", "h3", "h4", "h5", "h6", + "p", "li", "td", "th", "caption", "figcaption", "blockquote", "pre", "code", + "nav", "main", "article", "section", "header", "footer", "form", "table", + "img", "video", "audio", +} +_KEEP_TAGS = _INTERACTIVE_TAGS | _SEMANTIC_TAGS + +_SNAPSHOT_JS = """ +() => { + const KEEP = new Set(%s); + const INTERACTIVE = new Set(%s); + const SKIP = new Set(["script","style","noscript","svg","path","meta","link","br","hr"]); + const CLICKABLE_ROLES = new Set([ + "button","link","tab","menuitem","menuitemcheckbox","menuitemradio", + "option","switch","checkbox","radio","combobox","searchbox","slider", + "spinbutton","textbox","treeitem" + ]); + let refCounter = 0; + const refMap = {}; + + function visible(el) { + if (!(el instanceof HTMLElement)) return true; + const st = window.getComputedStyle(el); + if (st.display === "none" || st.visibility === "hidden") return false; + if (parseFloat(st.opacity) === 0) return false; + return true; + } + + // Strong signals: these attributes alone are enough to mark as interactive + function hasStrongInteractiveSignal(el) { + const role = el.getAttribute("role"); + if (role && CLICKABLE_ROLES.has(role)) return true; + if (el.hasAttribute("onclick") || el.hasAttribute("tabindex")) return true; + if (el.hasAttribute("data-click") || el.hasAttribute("data-action")) return true; + if (el.getAttribute("contenteditable") === "true") return true; + return false; + } + + // Check if cursor:pointer is set directly (not just inherited from parent) + function hasOwnPointerCursor(el) { + try { + const st = window.getComputedStyle(el); + if (st.cursor !== "pointer") return false; + const parent = el.parentElement; + if (parent) { + const pst = window.getComputedStyle(parent); + if (pst.cursor === "pointer") return false; + } + return true; + } catch(e) {} + return false; + } + + function hasTextOrContent(el) { + const t = el.textContent || ""; + if (t.trim().length > 0) return true; + if (el.querySelector("img,video,audio,canvas")) return true; + const ariaLabel = el.getAttribute("aria-label"); + if (ariaLabel && ariaLabel.trim()) return true; + const title = el.getAttribute("title"); + if (title && title.trim()) return true; + return false; + } + + function isImplicitInteractive(el) { + if (hasStrongInteractiveSignal(el)) return true; + if (hasOwnPointerCursor(el) && hasTextOrContent(el)) return true; + return false; + } + + function getTextContent(el) { + let text = ""; + for (const ch of el.childNodes) { + if (ch.nodeType === Node.TEXT_NODE) { + text += ch.textContent; + } + } + return text.trim(); + } + + function walk(node) { + if (node.nodeType === Node.TEXT_NODE) { + const t = node.textContent.trim(); + return t ? t : null; + } + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const tag = node.tagName.toLowerCase(); + if (SKIP.has(tag)) return null; + if (!visible(node)) return null; + + const children = []; + for (const ch of node.childNodes) { + const r = walk(ch); + if (r !== null) { + if (typeof r === "string") children.push(r); + else children.push(r); + } + } + + const nativeInteractive = INTERACTIVE.has(tag); + const implicitInteractive = !nativeInteractive && (node instanceof HTMLElement) && isImplicitInteractive(node); + const keep = KEEP.has(tag) || implicitInteractive; + + if (!keep) { + if (children.length === 0) return null; + if (children.length === 1) return children[0]; + return children; + } + + const obj = { tag }; + if (nativeInteractive || implicitInteractive) { + refCounter++; + obj.ref = refCounter; + refMap[refCounter] = node; + } + + if (implicitInteractive) { + const role = node.getAttribute("role"); + if (role) obj.role = role; + const directText = getTextContent(node); + if (!directText && children.length === 0) { + const ariaLabel = node.getAttribute("aria-label"); + const title = node.getAttribute("title"); + if (ariaLabel) obj.ariaLabel = ariaLabel; + else if (title) obj.ariaLabel = title; + } + } + + // Attributes + if (tag === "a" && node.href) obj.href = node.getAttribute("href"); + if (tag === "img") { + obj.alt = node.alt || ""; + obj.src = node.getAttribute("src") || ""; + } + if (tag === "input" || tag === "textarea" || tag === "select") { + obj.type = node.type || "text"; + obj.name = node.name || undefined; + obj.value = node.value || undefined; + obj.placeholder = node.placeholder || undefined; + if (node.disabled) obj.disabled = true; + if (tag === "input" && node.type === "checkbox") obj.checked = node.checked; + } + if (tag === "button") { + if (node.disabled) obj.disabled = true; + } + if (tag === "option") { + obj.value = node.value; + if (node.selected) obj.selected = true; + } + if (tag === "label" && node.htmlFor) obj.for = node.htmlFor; + + // Role / aria-label for native interactive & semantic elements + if (!implicitInteractive) { + const role = node.getAttribute("role"); + if (role) obj.role = role; + const ariaLabel = node.getAttribute("aria-label"); + if (ariaLabel) obj.ariaLabel = ariaLabel; + } + + // Children + if (children.length === 1 && typeof children[0] === "string") { + obj.text = children[0]; + } else if (children.length > 0) { + obj.children = children; + } + + return obj; + } + + const result = walk(document.body); + window.__cowRefMap = refMap; + return { tree: result, refCount: refCounter }; +} +""" % ( + str(list(_KEEP_TAGS)), + str(list(_INTERACTIVE_TAGS)), +) + + +_BROWSER_DEAD_HINTS = ( + "has been closed", + "browser has disconnected", + "target closed", + "browser closed", + "context or browser has been closed", +) + + +def _is_browser_dead_error(err: Exception) -> bool: + """Return True if *err* indicates the browser / page died out from under us.""" + msg = str(err).lower() + return any(h in msg for h in _BROWSER_DEAD_HINTS) + + +def _should_use_headless() -> bool: + """Decide headless mode: headless on Linux servers without display, headed elsewhere.""" + if sys.platform in ("win32", "darwin"): + return False + # Linux: check for display + if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"): + return False + return True + + +def _flatten_tree(node, indent=0) -> List[str]: + """Convert snapshot tree to compact text lines for LLM consumption.""" + if node is None: + return [] + if isinstance(node, str): + return [" " * indent + node] + if isinstance(node, list): + lines = [] + for child in node: + lines.extend(_flatten_tree(child, indent)) + return lines + if not isinstance(node, dict): + return [] + + tag = node.get("tag", "?") + ref = node.get("ref") + parts = [tag] + if ref: + parts[0] = f"[{ref}] {tag}" + + # Inline attributes + for attr in ("type", "name", "href", "alt", "role", "ariaLabel", "placeholder", "value"): + val = node.get(attr) + if val: + # Truncate long values + s = str(val) + if len(s) > 80: + s = s[:77] + "..." + parts.append(f'{attr}="{s}"') + + for flag in ("disabled", "checked", "selected"): + if node.get(flag): + parts.append(flag) + + prefix = " " * indent + header = prefix + " ".join(parts) + + text = node.get("text") + if text: + # Truncate long text + if len(text) > 120: + text = text[:117] + "..." + header += f": {text}" + + lines = [header] + children = node.get("children", []) + for child in children: + lines.extend(_flatten_tree(child, indent + 2)) + return lines + + +class BrowserService: + """Manages a Playwright browser on a dedicated background thread. + + All Playwright operations are dispatched to a single long-lived thread via + a task queue. Callers from *any* worker thread can use the public API + safely. An idle timer automatically shuts the browser down after + ``idle_timeout`` seconds of inactivity (default 300 = 5 min). + """ + + _IDLE_TIMEOUT_DEFAULT = 300 # seconds + + def __init__(self, config: Optional[Dict[str, Any]] = None): + self._config = config or {} + self._headless: Optional[bool] = None + self._screenshot_dir: Optional[str] = None + + # Background thread state + self._thread: Optional[threading.Thread] = None + self._task_queue: queue.Queue = queue.Queue() + self._lock = threading.Lock() + self._alive = False + self._ready = threading.Event() + + # Playwright objects (only accessed on the background thread) + self._playwright = None + self._browser = None + self._context = None + self._page = None + + # Launch mode: one of "fresh" | "persistent" | "cdp". + # - cdp: connect to an externally launched Chrome via CDP endpoint. + # - persistent: launch with launch_persistent_context using a user_data_dir + # so cookies / login state survive across runs (default). + # - fresh: classic launch + new_context, clean state every run. + cdp_endpoint = self._config.get("cdp_endpoint") or "" + persistent_flag = self._config.get("persistent", True) + user_data_dir_cfg = self._config.get("user_data_dir") + if user_data_dir_cfg is None: + user_data_dir_cfg = _DEFAULT_USER_DATA_DIR + + self._cdp_endpoint: str = cdp_endpoint.strip() if isinstance(cdp_endpoint, str) else "" + if self._cdp_endpoint: + self._launch_mode = "cdp" + self._user_data_dir: str = "" + elif persistent_flag and user_data_dir_cfg: + self._launch_mode = "persistent" + self._user_data_dir = expand_path(str(user_data_dir_cfg)) + else: + self._launch_mode = "fresh" + self._user_data_dir = "" + + # Idle auto-release + idle_cfg = self._config.get("idle_timeout") + self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT + self._idle_timer: Optional[threading.Timer] = None + + # Set when the browser / page is detected to have died externally + # (e.g. user manually closed the window). The next _submit() will then + # tear down the stale thread and relaunch. + self._needs_restart = False + + # ------------------------------------------------------------------ + # Background-thread lifecycle + # ------------------------------------------------------------------ + + def _start_thread(self): + """Start the dedicated Playwright thread if not already running.""" + with self._lock: + if self._alive and self._thread and self._thread.is_alive(): + return + # Wait for old thread to fully exit before creating a new one + old = self._thread + if old and old.is_alive(): + old.join(timeout=5) + # Fresh queue to avoid stale sentinels from a previous close() + self._task_queue = queue.Queue() + self._alive = True + self._ready = threading.Event() + self._thread = threading.Thread(target=self._run_loop, daemon=True, name="BrowserThread") + self._thread.start() + # Block until browser is ready (or failed) + self._ready.wait(timeout=30) + + def _run_loop(self): + """Event loop running on the dedicated thread. Processes tasks until stopped.""" + logger.info("[Browser] Background thread started") + try: + self._launch_browser() + except Exception as e: + logger.error(f"[Browser] Failed to launch browser: {e}") + self._alive = False + self._ready.set() + self._drain_queue(RuntimeError(f"Browser launch failed: {e}")) + return + self._ready.set() + + while self._alive: + try: + task = self._task_queue.get(timeout=1.0) + except queue.Empty: + continue + if task is None: + break + fn, args, kwargs, result_slot = task + try: + result_slot["value"] = fn(*args, **kwargs) + except Exception as e: + result_slot["error"] = e + if _is_browser_dead_error(e): + self._needs_restart = True + logger.warning( + f"[Browser] Detected closed page/context ({e}); " + "will relaunch on next request." + ) + finally: + result_slot["event"].set() + + self._shutdown_browser() + self._drain_queue(RuntimeError("Browser thread stopped")) + logger.info("[Browser] Background thread exited") + + def _drain_queue(self, error: Exception): + """Unblock all callers waiting on the queue with an error.""" + while True: + try: + task = self._task_queue.get_nowait() + except queue.Empty: + break + if task is None: + continue + _, _, _, result_slot = task + result_slot["error"] = error + result_slot["event"].set() + + def _launch_browser(self): + """Launch / connect Chromium on the background thread.""" + if self._headless is None: + headless_cfg = self._config.get("headless") + self._headless = headless_cfg if headless_cfg is not None else _should_use_headless() + + launch_args = ["--disable-dev-shm-usage"] + if self._headless: + launch_args.append("--no-sandbox") + + if is_cloud_deployment(): + launch_args.extend([ + "--disable-gpu", + "--disable-software-rasterizer", + "--disable-extensions", + "--disable-background-networking", + "--disable-background-timer-throttling", + "--disable-renderer-backgrounding", + "--disable-features=site-per-process,TranslateUI,IsolateOrigins", + "--no-zygote", + "--js-flags=--max-old-space-size=384", + "--memory-pressure-off", + ]) + + extra_args = self._config.get("launch_args", []) + if extra_args: + launch_args.extend(extra_args) + + viewport_w = self._config.get("viewport_width", 1280) + viewport_h = self._config.get("viewport_height", 720) + viewport = {"width": viewport_w, "height": viewport_h} + user_agent = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Safari/537.36" + ) + + self._playwright = sync_playwright().start() + + if self._launch_mode == "cdp": + self._connect_cdp(viewport) + elif self._launch_mode == "persistent": + self._launch_persistent(launch_args, viewport, user_agent) + else: + self._launch_fresh(launch_args, viewport, user_agent) + + logger.info("[Browser] Browser ready") + + def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str): + """Classic launch: brand new Chromium with an empty context.""" + logger.info(f"[Browser] Launching Chromium (fresh, headless={self._headless})") + self._browser = self._playwright.chromium.launch( + headless=self._headless, + args=launch_args, + ) + self._context = self._browser.new_context( + viewport=viewport, + user_agent=user_agent, + ) + self._page = self._context.new_page() + self._wire_close_listeners() + + def _launch_persistent(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str): + """Launch Chromium with a persistent user_data_dir so login state survives.""" + os.makedirs(self._user_data_dir, exist_ok=True) + logger.info( + f"[Browser] Launching Chromium (persistent, headless={self._headless}, " + f"profile={self._user_data_dir})" + ) + try: + self._context = self._playwright.chromium.launch_persistent_context( + user_data_dir=self._user_data_dir, + headless=self._headless, + args=launch_args, + viewport=viewport, + user_agent=user_agent, + ) + except Exception as e: + # Profile is locked when another Chromium instance already holds it. + msg = str(e).lower() + if "singletonlock" in msg or "profile" in msg or "lock" in msg: + raise RuntimeError( + f"Browser profile '{self._user_data_dir}' is in use by another process. " + "Close the other Chromium / cow instance, or set a different " + "tools.browser.user_data_dir." + ) from e + raise + + # Persistent context has no parent Browser handle; reuse the auto-created page. + self._browser = None + pages = self._context.pages + self._page = pages[0] if pages else self._context.new_page() + self._wire_close_listeners() + + def _connect_cdp(self, viewport: Dict[str, int]): + """Attach to an existing Chrome started with --remote-debugging-port.""" + endpoint = self._cdp_endpoint + logger.info(f"[Browser] Connecting to existing Chrome via CDP: {endpoint}") + try: + self._browser = self._playwright.chromium.connect_over_cdp(endpoint) + except Exception as e: + msg = str(e).lower() + if "econnrefused" in msg or "connect" in msg or "refused" in msg: + raise RuntimeError( + f"Cannot reach Chrome at {endpoint}. The CDP browser is not " + "running. Ask the user to launch Chrome with " + "--remote-debugging-port and --user-data-dir, then retry. " + "Do not retry this tool until the user confirms." + ) from e + raise + + contexts = self._browser.contexts + if contexts: + self._context = contexts[0] + else: + self._context = self._browser.new_context(viewport=viewport) + + pages = self._context.pages + self._page = pages[0] if pages else self._context.new_page() + self._wire_close_listeners() + + def _wire_close_listeners(self): + """Mark needs_restart whenever the browser / context / page dies externally.""" + def _on_dead(_obj=None): + self._needs_restart = True + + try: + if self._browser: + self._browser.on("disconnected", _on_dead) + if self._context: + self._context.on("close", _on_dead) + if self._page: + self._page.on("close", _on_dead) + except Exception as e: + logger.debug(f"[Browser] Failed to wire close listeners: {e}") + + def _shutdown_browser(self): + """Shut down Playwright resources on the background thread. + + Mode-specific behavior: + - cdp: only disconnect the Playwright client; leave the user's Chrome + and its tabs untouched (do NOT close the context). + - persistent: close the persistent context (no separate browser handle). + - fresh: close context, then browser. + """ + self._cancel_idle_timer() + + if self._launch_mode == "cdp": + # For CDP, browser.close() only detaches the Playwright client; + # the user's Chrome process and its tabs stay alive. + try: + if self._browser: + self._browser.close() + except Exception as e: + logger.debug(f"[Browser] cdp disconnect error: {e}") + else: + for obj, label in [ + (self._context, "context"), + (self._browser, "browser"), + ]: + try: + if obj: + obj.close() + except Exception as e: + logger.debug(f"[Browser] {label} close error: {e}") + + try: + if self._playwright: + self._playwright.stop() + except Exception as e: + logger.debug(f"[Browser] playwright stop error: {e}") + self._page = None + self._context = None + self._browser = None + self._playwright = None + logger.info("[Browser] Browser closed") + + def _submit(self, fn: Callable, *args, **kwargs): + """Submit *fn* to the background thread and block until it completes.""" + # If the browser died externally (e.g. user closed the window), tear + # down the stale thread first so _start_thread() will relaunch fresh. + if self._needs_restart: + logger.info("[Browser] Restarting after detecting closed browser") + self.close() + self._needs_restart = False + + self._start_thread() + + if not self._alive: + raise RuntimeError("Browser is not available") + + self._reset_idle_timer() + + result_slot: Dict[str, Any] = {"event": threading.Event()} + self._task_queue.put((fn, args, kwargs, result_slot)) + + # Timeout prevents permanent hang if the background thread crashes + completed = result_slot["event"].wait(timeout=120) + if not completed: + raise TimeoutError("Browser operation timed out (120s)") + + if "error" in result_slot: + raise result_slot["error"] + return result_slot.get("value") + + # ------------------------------------------------------------------ + # Idle auto-release + # ------------------------------------------------------------------ + + def _reset_idle_timer(self): + self._cancel_idle_timer() + if self._idle_timeout > 0: + self._idle_timer = threading.Timer(self._idle_timeout, self._on_idle_timeout) + self._idle_timer.daemon = True + self._idle_timer.start() + + def _cancel_idle_timer(self): + if self._idle_timer: + self._idle_timer.cancel() + self._idle_timer = None + + def _on_idle_timeout(self): + logger.info(f"[Browser] Idle for {self._idle_timeout}s, auto-releasing browser") + self.close() + + # ------------------------------------------------------------------ + # Public lifecycle + # ------------------------------------------------------------------ + + def close(self): + """Shut down browser and background thread (safe from any thread).""" + self._cancel_idle_timer() + with self._lock: + if not self._alive: + self._needs_restart = False + return + self._alive = False + t = self._thread + if self._task_queue is not None: + self._task_queue.put(None) + if t is not None and t.is_alive(): + t.join(timeout=10) + with self._lock: + self._thread = None + self._needs_restart = False + + # ------------------------------------------------------------------ + # Actions (each method is dispatched to the background thread) + # ------------------------------------------------------------------ + + def navigate(self, url: str, timeout: int = 30000) -> Dict[str, Any]: + return self._submit(self._do_navigate, url, timeout) + + def _do_navigate(self, url: str, timeout: int) -> Dict[str, Any]: + page = self._page + try: + resp = page.goto(url, wait_until="domcontentloaded", timeout=timeout) + status = resp.status if resp else None + except Exception as e: + return {"error": f"Navigation failed: {e}"} + + try: + page.wait_for_load_state("networkidle", timeout=8000) + except Exception: + pass + page.wait_for_timeout(500) + + try: + title = page.title() + except Exception: + title = "" + try: + current_url = page.url + except Exception: + current_url = url + + return {"url": current_url, "title": title, "status": status} + + def snapshot(self, selector: Optional[str] = None) -> str: + return self._submit(self._do_snapshot, selector) + + def _do_snapshot(self, selector: Optional[str] = None) -> str: + page = self._page + try: + result = page.evaluate(_SNAPSHOT_JS) + except Exception as e: + return f"[Snapshot error: {e}]" + + tree = result.get("tree") + ref_count = result.get("refCount", 0) + lines = _flatten_tree(tree) + + try: + title = page.title() + except Exception: + title = "" + try: + url = page.url + except Exception: + url = "" + + header = f"Page: {title} ({url})\nInteractive elements: {ref_count}\n---" + body = "\n".join(lines) + + max_chars = self._config.get("snapshot_max_chars", 30000) + if len(body) > max_chars: + body = body[:max_chars] + "\n... [snapshot truncated]" + + return f"{header}\n{body}" + + def screenshot(self, full_page: bool = False, cwd: str = "") -> str: + return self._submit(self._do_screenshot, full_page, cwd) + + def _do_screenshot(self, full_page: bool = False, cwd: str = "") -> str: + page = self._page + save_dir = self._get_screenshot_dir(cwd) + filename = f"screenshot_{uuid.uuid4().hex[:8]}.png" + filepath = os.path.join(save_dir, filename) + page.screenshot(path=filepath, full_page=full_page) + logger.info(f"[Browser] Screenshot saved: {filepath}") + return filepath + + def click(self, ref: Optional[int] = None, selector: Optional[str] = None, + timeout: int = 5000) -> Dict[str, Any]: + return self._submit(self._do_click, ref, selector, timeout) + + def _do_click(self, ref, selector, timeout) -> Dict[str, Any]: + page = self._page + try: + if ref is not None: + result = page.evaluate(f""" + () => {{ + const el = window.__cowRefMap && window.__cowRefMap[{ref}]; + if (!el) return {{ error: "ref {ref} not found. Run snapshot first." }}; + el.click(); + return {{ clicked: true, tag: el.tagName.toLowerCase() }}; + }} + """) + if result.get("error"): + return result + page.wait_for_timeout(500) + return result + elif selector: + page.click(selector, timeout=timeout) + return {"clicked": True, "selector": selector} + else: + return {"error": "Provide either ref (from snapshot) or selector"} + except Exception as e: + return {"error": f"Click failed: {e}"} + + def fill(self, text: str, ref: Optional[int] = None, + selector: Optional[str] = None, timeout: int = 5000) -> Dict[str, Any]: + return self._submit(self._do_fill, text, ref, selector, timeout) + + def _do_fill(self, text, ref, selector, timeout) -> Dict[str, Any]: + page = self._page + try: + if ref is not None: + result = page.evaluate(f""" + () => {{ + const el = window.__cowRefMap && window.__cowRefMap[{ref}]; + if (!el) return {{ error: "ref {ref} not found. Run snapshot first." }}; + el.focus(); + el.value = ""; + return {{ tag: el.tagName.toLowerCase(), name: el.name || "" }}; + }} + """) + if result.get("error"): + return result + page.keyboard.type(text) + return {"filled": True, "ref": ref, "text": text} + elif selector: + page.fill(selector, text, timeout=timeout) + return {"filled": True, "selector": selector, "text": text} + else: + return {"error": "Provide either ref (from snapshot) or selector"} + except Exception as e: + return {"error": f"Fill failed: {e}"} + + def select(self, value: str, ref: Optional[int] = None, + selector: Optional[str] = None, timeout: int = 5000) -> Dict[str, Any]: + return self._submit(self._do_select, value, ref, selector, timeout) + + def _do_select(self, value, ref, selector, timeout) -> Dict[str, Any]: + page = self._page + try: + if ref is not None: + result = page.evaluate(f""" + () => {{ + const el = window.__cowRefMap && window.__cowRefMap[{ref}]; + if (!el || el.tagName.toLowerCase() !== "select") + return {{ error: "ref {ref} is not a + + + + + + + +
-
@@ -77,13 +133,13 @@ @@ -92,33 +148,43 @@ @@ -127,13 +193,13 @@ @@ -154,6 +220,26 @@ + + + + + + + + @@ -166,11 +252,17 @@ - -
- Chat + + + + +
@@ -193,7 +285,7 @@ - @@ -220,26 +312,26 @@ -
+
-
+
CowAgent

CowAgent

I can help you answer questions, manage your computer, create and execute skills,
and keep growing through long-term memory.

+ data-i18n-html="welcome_subtitle">我可以帮你解答问题、管理计算机、创造和执行技能,并通过
长期记忆和知识库不断成长

-
+
- System + 系统管理
-

Show me the files in the workspace

+

查看工作空间里有哪些文件

@@ -247,9 +339,9 @@
- Smart Task + 定时任务
-

Remind me to check the server in 5 minutes

+

1分钟后提醒我检查服务器

@@ -257,51 +349,122 @@
- Coding + 编程助手
-

Write a Python web scraper script

+

搜索AI资讯并生成可视化网页报告

+
+
+
+
+ +
+ 知识库 +
+

查看知识库当前文档情况

+
+
+
+
+ +
+ 技能系统 +
+

查看所有支持的工具和技能

+
+
+
+
+ +
+ 指令中心 +
+

查看全部命令

+ + +
-
+
+
- + + + +
+ + +
@@ -317,8 +480,8 @@
-

Configuration

-

Manage model and agent settings

+

配置管理

+

管理模型和 Agent 配置

- +
-- @@ -342,10 +510,13 @@
+
- +
-- @@ -358,7 +529,7 @@ class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono transition-colors" - data-i18n-placeholder="config_custom_model_hint" placeholder="Enter custom model name"> + data-i18n-placeholder="config_custom_model_hint" placeholder="输入自定义模型名称">
@@ -393,7 +564,7 @@ + onclick="saveModelConfig()" data-i18n="config_save">保存
@@ -404,36 +575,123 @@
-

Agent Configuration

+

Agent 配置

- +
- +
- +
+
+ + +
+
+ + +
+ onclick="saveAgentConfig()" data-i18n="config_save">保存 +
+
+
+ + +
+
+
+ +
+

安全设置

+
+
+
+ + +

留空则不启用密码保护

+
+
+ + +
+
+
+ + +
+
+
+ +
+

语言

+
+
+
+ +
+
+ -- + +
+
+
@@ -451,20 +709,25 @@
-

Skills

-

View, enable, or disable agent skills

+

技能管理

+

查看、启用或禁用 Agent 技能

+ + + 探索技能广场 +
- Built-in Tools + 内置工具
- Loading tools... + 加载工具中...
@@ -472,15 +735,15 @@
- Skills + 技能
-

Loading skills...

-

Skills will be displayed here after loading

+

加载技能中...

+

技能加载后将显示在此处

@@ -499,26 +762,36 @@
-

Memory

-

View agent memory files and contents

+

记忆管理

+

查看 Agent 记忆文件和内容

+
+
+ +
-

Loading memory files...

-

Memory files will be displayed here

+

加载记忆文件中...

+

记忆文件将显示在此处