Repository: zhayujie/chatgpt-on-wechat Branch: master Commit: 7d0e1568ac50 Files: 394 Total size: 1.9 MB Directory structure: gitextract_wetyu4kh/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── 1.bug.yml │ │ └── 2.feature.yml │ └── workflows/ │ ├── deploy-image-arm.yml │ └── deploy-image.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── agent/ │ ├── chat/ │ │ ├── __init__.py │ │ └── service.py │ ├── memory/ │ │ ├── __init__.py │ │ ├── chunker.py │ │ ├── config.py │ │ ├── conversation_store.py │ │ ├── embedding.py │ │ ├── manager.py │ │ ├── service.py │ │ ├── storage.py │ │ └── summarizer.py │ ├── prompt/ │ │ ├── __init__.py │ │ ├── builder.py │ │ └── workspace.py │ ├── protocol/ │ │ ├── __init__.py │ │ ├── agent.py │ │ ├── agent_stream.py │ │ ├── context.py │ │ ├── message_utils.py │ │ ├── models.py │ │ ├── result.py │ │ └── task.py │ ├── skills/ │ │ ├── __init__.py │ │ ├── config.py │ │ ├── formatter.py │ │ ├── frontmatter.py │ │ ├── loader.py │ │ ├── manager.py │ │ ├── service.py │ │ └── types.py │ └── tools/ │ ├── __init__.py │ ├── base_tool.py │ ├── bash/ │ │ ├── __init__.py │ │ └── bash.py │ ├── browser_tool.py │ ├── edit/ │ │ ├── __init__.py │ │ └── edit.py │ ├── env_config/ │ │ ├── __init__.py │ │ └── env_config.py │ ├── ls/ │ │ ├── __init__.py │ │ └── ls.py │ ├── memory/ │ │ ├── __init__.py │ │ ├── memory_get.py │ │ └── memory_search.py │ ├── read/ │ │ ├── __init__.py │ │ └── read.py │ ├── scheduler/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── integration.py │ │ ├── scheduler_service.py │ │ ├── scheduler_tool.py │ │ └── task_store.py │ ├── send/ │ │ ├── __init__.py │ │ └── send.py │ ├── tool_manager.py │ ├── utils/ │ │ ├── __init__.py │ │ ├── diff.py │ │ └── truncate.py │ ├── vision/ │ │ ├── __init__.py │ │ └── vision.py │ ├── web_fetch/ │ │ ├── __init__.py │ │ └── web_fetch.py │ ├── web_search/ │ │ ├── __init__.py │ │ └── web_search.py │ └── write/ │ ├── __init__.py │ └── write.py ├── app.py ├── bridge/ │ ├── agent_bridge.py │ ├── agent_event_handler.py │ ├── agent_initializer.py │ ├── bridge.py │ ├── context.py │ └── reply.py ├── channel/ │ ├── channel.py │ ├── channel_factory.py │ ├── chat_channel.py │ ├── chat_message.py │ ├── dingtalk/ │ │ ├── dingtalk_channel.py │ │ └── dingtalk_message.py │ ├── feishu/ │ │ ├── README.md │ │ ├── feishu_channel.py │ │ └── feishu_message.py │ ├── file_cache.py │ ├── qq/ │ │ ├── __init__.py │ │ ├── qq_channel.py │ │ └── qq_message.py │ ├── terminal/ │ │ └── terminal_channel.py │ ├── web/ │ │ ├── README.md │ │ ├── chat.html │ │ ├── static/ │ │ │ ├── css/ │ │ │ │ └── console.css │ │ │ └── js/ │ │ │ └── console.js │ │ └── web_channel.py │ ├── wechatcom/ │ │ ├── README.md │ │ ├── wechatcomapp_channel.py │ │ ├── wechatcomapp_client.py │ │ └── wechatcomapp_message.py │ ├── wechatmp/ │ │ ├── README.md │ │ ├── active_reply.py │ │ ├── common.py │ │ ├── passive_reply.py │ │ ├── wechatmp_channel.py │ │ ├── wechatmp_client.py │ │ └── wechatmp_message.py │ └── wecom_bot/ │ ├── __init__.py │ ├── wecom_bot_channel.py │ └── wecom_bot_message.py ├── common/ │ ├── cloud_client.py │ ├── const.py │ ├── dequeue.py │ ├── expired_dict.py │ ├── log.py │ ├── memory.py │ ├── package_manager.py │ ├── singleton.py │ ├── sorted_dict.py │ ├── time_check.py │ ├── tmp_dir.py │ ├── token_bucket.py │ └── utils.py ├── config-template.json ├── config.py ├── docker/ │ ├── Dockerfile.latest │ ├── build.latest.sh │ ├── docker-compose.yml │ └── entrypoint.sh ├── docs/ │ ├── agent.md │ ├── channels/ │ │ ├── dingtalk.mdx │ │ ├── feishu.mdx │ │ ├── qq.mdx │ │ ├── web.mdx │ │ ├── wechatmp.mdx │ │ ├── wecom-bot.mdx │ │ └── wecom.mdx │ ├── docs.json │ ├── en/ │ │ ├── README.md │ │ ├── channels/ │ │ │ ├── dingtalk.mdx │ │ │ ├── feishu.mdx │ │ │ ├── qq.mdx │ │ │ ├── web.mdx │ │ │ ├── wechatmp.mdx │ │ │ ├── wecom-bot.mdx │ │ │ └── wecom.mdx │ │ ├── guide/ │ │ │ ├── manual-install.mdx │ │ │ └── quick-start.mdx │ │ ├── intro/ │ │ │ ├── architecture.mdx │ │ │ ├── features.mdx │ │ │ └── index.mdx │ │ ├── memory.mdx │ │ ├── models/ │ │ │ ├── claude.mdx │ │ │ ├── coding-plan.mdx │ │ │ ├── deepseek.mdx │ │ │ ├── doubao.mdx │ │ │ ├── gemini.mdx │ │ │ ├── glm.mdx │ │ │ ├── index.mdx │ │ │ ├── kimi.mdx │ │ │ ├── linkai.mdx │ │ │ ├── minimax.mdx │ │ │ ├── openai.mdx │ │ │ └── qwen.mdx │ │ ├── releases/ │ │ │ ├── overview.mdx │ │ │ ├── v2.0.0.mdx │ │ │ ├── v2.0.1.mdx │ │ │ └── v2.0.2.mdx │ │ ├── skills/ │ │ │ ├── image-vision.mdx │ │ │ ├── index.mdx │ │ │ ├── linkai-agent.mdx │ │ │ ├── skill-creator.mdx │ │ │ └── web-fetch.mdx │ │ └── tools/ │ │ ├── bash.mdx │ │ ├── browser.mdx │ │ ├── edit.mdx │ │ ├── env-config.mdx │ │ ├── index.mdx │ │ ├── ls.mdx │ │ ├── memory.mdx │ │ ├── read.mdx │ │ ├── scheduler.mdx │ │ ├── send.mdx │ │ ├── web-search.mdx │ │ └── write.mdx │ ├── guide/ │ │ ├── manual-install.mdx │ │ ├── quick-start.mdx │ │ └── upgrade.mdx │ ├── intro/ │ │ ├── architecture.mdx │ │ ├── features.mdx │ │ └── index.mdx │ ├── ja/ │ │ ├── README.md │ │ ├── channels/ │ │ │ ├── dingtalk.mdx │ │ │ ├── feishu.mdx │ │ │ ├── qq.mdx │ │ │ ├── web.mdx │ │ │ ├── wechatmp.mdx │ │ │ ├── wecom-bot.mdx │ │ │ └── wecom.mdx │ │ ├── guide/ │ │ │ ├── manual-install.mdx │ │ │ ├── quick-start.mdx │ │ │ └── upgrade.mdx │ │ ├── intro/ │ │ │ ├── architecture.mdx │ │ │ ├── features.mdx │ │ │ └── index.mdx │ │ ├── memory.mdx │ │ ├── models/ │ │ │ ├── claude.mdx │ │ │ ├── coding-plan.mdx │ │ │ ├── deepseek.mdx │ │ │ ├── doubao.mdx │ │ │ ├── gemini.mdx │ │ │ ├── glm.mdx │ │ │ ├── index.mdx │ │ │ ├── kimi.mdx │ │ │ ├── linkai.mdx │ │ │ ├── minimax.mdx │ │ │ ├── openai.mdx │ │ │ └── qwen.mdx │ │ ├── releases/ │ │ │ ├── overview.mdx │ │ │ ├── v2.0.0.mdx │ │ │ ├── v2.0.1.mdx │ │ │ ├── v2.0.2.mdx │ │ │ └── v2.0.3.mdx │ │ ├── skills/ │ │ │ ├── image-vision.mdx │ │ │ ├── index.mdx │ │ │ ├── linkai-agent.mdx │ │ │ ├── skill-creator.mdx │ │ │ └── web-fetch.mdx │ │ └── tools/ │ │ ├── bash.mdx │ │ ├── browser.mdx │ │ ├── edit.mdx │ │ ├── env-config.mdx │ │ ├── index.mdx │ │ ├── ls.mdx │ │ ├── memory.mdx │ │ ├── read.mdx │ │ ├── scheduler.mdx │ │ ├── send.mdx │ │ ├── web-search.mdx │ │ └── write.mdx │ ├── memory.mdx │ ├── models/ │ │ ├── claude.mdx │ │ ├── coding-plan.mdx │ │ ├── deepseek.mdx │ │ ├── doubao.mdx │ │ ├── gemini.mdx │ │ ├── glm.mdx │ │ ├── index.mdx │ │ ├── kimi.mdx │ │ ├── linkai.mdx │ │ ├── minimax.mdx │ │ ├── openai.mdx │ │ └── qwen.mdx │ ├── releases/ │ │ ├── overview.mdx │ │ ├── v2.0.0.mdx │ │ ├── v2.0.1.mdx │ │ ├── v2.0.2.mdx │ │ └── v2.0.3.mdx │ ├── skills/ │ │ ├── image-vision.mdx │ │ ├── index.mdx │ │ ├── linkai-agent.mdx │ │ ├── skill-creator.mdx │ │ └── web-fetch.mdx │ └── tools/ │ ├── bash.mdx │ ├── browser.mdx │ ├── edit.mdx │ ├── env-config.mdx │ ├── index.mdx │ ├── ls.mdx │ ├── memory.mdx │ ├── read.mdx │ ├── scheduler.mdx │ ├── send.mdx │ ├── web-search.mdx │ └── write.mdx ├── models/ │ ├── ali/ │ │ ├── ali_qwen_bot.py │ │ └── ali_qwen_session.py │ ├── baidu/ │ │ ├── baidu_unit_bot.py │ │ ├── baidu_wenxin.py │ │ └── baidu_wenxin_session.py │ ├── bot.py │ ├── bot_factory.py │ ├── chatgpt/ │ │ ├── chat_gpt_bot.py │ │ └── chat_gpt_session.py │ ├── claudeapi/ │ │ └── claude_api_bot.py │ ├── dashscope/ │ │ ├── dashscope_bot.py │ │ └── dashscope_session.py │ ├── doubao/ │ │ ├── __init__.py │ │ ├── doubao_bot.py │ │ └── doubao_session.py │ ├── gemini/ │ │ └── google_gemini_bot.py │ ├── linkai/ │ │ └── link_ai_bot.py │ ├── minimax/ │ │ ├── minimax_bot.py │ │ └── minimax_session.py │ ├── modelscope/ │ │ ├── modelscope_bot.py │ │ └── modelscope_session.py │ ├── moonshot/ │ │ ├── moonshot_bot.py │ │ └── moonshot_session.py │ ├── openai/ │ │ ├── open_ai_bot.py │ │ ├── open_ai_image.py │ │ ├── open_ai_session.py │ │ └── openai_compat.py │ ├── openai_compatible_bot.py │ ├── session_manager.py │ ├── xunfei/ │ │ └── xunfei_spark_bot.py │ └── zhipuai/ │ ├── zhipu_ai_image.py │ ├── zhipu_ai_session.py │ └── zhipuai_bot.py ├── plugins/ │ ├── agent/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── agent.py │ │ └── config-template.yaml │ ├── banwords/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── __init__.py │ │ ├── banwords.py │ │ ├── banwords.txt.template │ │ ├── config.json.template │ │ └── lib/ │ │ └── WordsSearch.py │ ├── dungeon/ │ │ ├── README.md │ │ ├── __init__.py │ │ └── dungeon.py │ ├── finish/ │ │ ├── __init__.py │ │ └── finish.py │ ├── godcmd/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── config.json.template │ │ └── godcmd.py │ ├── hello/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── config.json.template │ │ └── hello.py │ ├── keyword/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── config.json.template │ │ └── keyword.py │ ├── linkai/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── config.json.template │ │ ├── linkai.py │ │ ├── midjourney.py │ │ ├── summary.py │ │ └── utils.py │ ├── role/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── role.py │ │ └── roles.json │ └── tool/ │ ├── README.md │ ├── config.json.template │ └── tool.py ├── requirements-optional.txt ├── requirements.txt ├── run.sh ├── scripts/ │ ├── shutdown.sh │ ├── start.sh │ └── tout.sh ├── skills/ │ ├── README.md │ ├── linkai-agent/ │ │ ├── README.md │ │ ├── SKILL.md │ │ └── config.json.template │ └── skill-creator/ │ ├── SKILL.md │ └── scripts/ │ ├── init_skill.py │ ├── package_skill.py │ └── quick_validate.py ├── translate/ │ ├── baidu/ │ │ └── baidu_translate.py │ ├── factory.py │ └── translator.py └── voice/ ├── ali/ │ ├── ali_api.py │ ├── ali_voice.py │ └── config.json.template ├── audio_convert.py ├── azure/ │ ├── azure_voice.py │ └── config.json.template ├── baidu/ │ ├── README.md │ ├── baidu_voice.py │ └── config.json.template ├── edge/ │ └── edge_voice.py ├── elevent/ │ └── elevent_voice.py ├── factory.py ├── google/ │ └── google_voice.py ├── linkai/ │ └── linkai_voice.py ├── openai/ │ └── openai_voice.py ├── pytts/ │ └── pytts_voice.py ├── tencent/ │ ├── config.json.template │ └── tencent_voice.py ├── voice.py └── xunfei/ ├── config.json.template ├── xunfei_asr.py ├── xunfei_tts.py └── xunfei_voice.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/1.bug.yml ================================================ name: Bug report 🐛 description: 项目运行中遇到的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) 中无类似问题 - type: checkboxes attributes: label: 前置确认 options: - label: 我确认我运行的是最新版本的代码,并且安装了所需的依赖,在[FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs)中也未找到类似问题。 required: true - type: checkboxes 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 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`,打印出的日志会更有帮助。
示例 ```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 <此处粘贴终端日志> ``` ================================================ FILE: .github/ISSUE_TEMPLATE/2.feature.yml ================================================ name: Feature request 🚀 description: 提出你对项目的新想法或建议。 labels: ['status: needs check'] body: - type: markdown attributes: value: | 请在上方的`title`中填写简略总结,谢谢❤️。 - type: checkboxes attributes: label: ⚠️ 搜索是否存在类似issue description: > 请在 [历史issue](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中清空输入框,搜索关键词查找是否存在相似issue。 options: - label: 我已经搜索过issues和disscussions,没有发现相似issue required: true - type: textarea attributes: label: 总结 description: 描述feature的功能。 - type: textarea attributes: label: 举例 description: 提供聊天示例,草图或相关网址。 - type: textarea attributes: label: 动机 description: 描述你提出该feature的动机,比如没有这项feature对你的使用造成了怎样的影响。 请提供更详细的场景描述,这可能会帮助我们发现并提出更好的解决方案。 ================================================ FILE: .github/workflows/deploy-image-arm.yml ================================================ # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by # separate terms of service, privacy policy, and support # documentation. # GitHub recommends pinning actions to a commit SHA. # To get a newer version, you will need to update the SHA. # You can also reference a tag or branch, but the action may change without warning. name: Create and publish a Docker image on: push: branches: ['master'] create: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build-and-push-image: if: github.repository == 'zhayujie/chatgpt-on-wechat' runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v3 - name: Set up QEMU uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@v1 - name: Available platforms run: echo ${{ steps.buildx.outputs.platforms }} - name: Log in to the Container registry uses: docker/login-action@v2 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v4 with: images: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - name: Build and push Docker image uses: docker/build-push-action@v3 with: context: . push: true file: ./docker/Dockerfile.latest platforms: linux/arm64 tags: ${{ steps.meta.outputs.tags }}-arm64 labels: ${{ steps.meta.outputs.labels }} - uses: actions/delete-package-versions@v4 with: package-name: 'chatgpt-on-wechat' package-type: 'container' min-versions-to-keep: 10 delete-only-untagged-versions: 'true' token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/deploy-image.yml ================================================ # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by # separate terms of service, privacy policy, and support # documentation. # GitHub recommends pinning actions to a commit SHA. # To get a newer version, you will need to update the SHA. # You can also reference a tag or branch, but the action may change without warning. name: Create and publish a Docker image on: push: branches: ['master'] create: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build-and-push-image: if: github.repository == 'zhayujie/chatgpt-on-wechat' runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v3 - name: Login to Docker Hub uses: docker/login-action@v2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Log in to the Container registry uses: docker/login-action@v2 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v4 with: images: | ${{ env.IMAGE_NAME }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - name: Build and push Docker image uses: docker/build-push-action@v3 with: context: . push: true file: ./docker/Dockerfile.latest tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - uses: actions/delete-package-versions@v4 with: package-name: 'chatgpt-on-wechat' package-type: 'container' min-versions-to-keep: 10 delete-only-untagged-versions: 'true' token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ .DS_Store .idea .vscode .venv .vs __pycache__/ venv* *.pyc python config.json QR.png nohup.out tmp plugins.json *.log logs/ workspace config.yaml user_datas.pkl chatgpt_tool_hub/ plugins/**/ !plugins/bdunit !plugins/dungeon !plugins/finish !plugins/godcmd !plugins/tool !plugins/banwords !plugins/banwords/**/ plugins/banwords/__pycache__ plugins/banwords/lib/__pycache__ !plugins/hello !plugins/role !plugins/keyword !plugins/linkai !plugins/agent client_config.json ref/ .cursor/ local/ ================================================ FILE: Dockerfile ================================================ FROM ghcr.io/zhayujie/chatgpt-on-wechat:latest ENTRYPOINT ["/entrypoint.sh"] ================================================ FILE: LICENSE ================================================ Copyright (c) 2022 zhayujie Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

Chatgpt-on-Wechat

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

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

🌐 官网  ·  📖 文档中心  ·  🚀 快速开始  ·  ☁️ 在线体验

# 简介 > 该项目既是一个可以开箱即用的超级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 ## 社区 添加小助手微信加入开源项目交流群:
# 企业服务 > [LinkAI](https://link-ai.tech/) 是面向企业和个人的一站式AI智能体平台,聚合多模态大模型、知识库、技能、工作流等能力,支持一键接入主流平台并管理,支持SaaS、私有化部署等多种模式,可免部署在线运行[CowAgent助理](https://link-ai.tech/cowagent/create)。 > > LinkAI 目前已在智能客服、私域运营、企业效率助手等场景积累了丰富的AI解决方案,在消费、健康、文教、科技制造等各行业沉淀了大模型落地应用的最佳实践,致力于帮助更多企业和开发者拥抱 AI 生产力。 **产品咨询和企业服务** 可联系产品客服:
# 🏷 更新日志 >**2026.03.18:** [2.0.3版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.3),新增企微智能机器人和 QQ 通道、支持Coding Plan、新增多个模型、Web端文件处理、记忆系统升级。 >**2026.02.27:** [2.0.2版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.2),Web 控制台全面升级(流式对话、模型/技能/记忆/通道/定时任务/日志管理)、支持多通道同时运行、会话持久化存储、新增多个模型。 >**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)
# 🚀 快速开始 项目提供了一键安装、配置、启动、管理程序的脚本,推荐使用脚本快速运行,也可以根据下文中的详细指引一步步安装运行。 在终端执行以下命令: ```bash bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh) ``` 脚本使用说明:[一键运行脚本](https://docs.cowagent.ai/guide/quick-start) ## 一、准备 ### 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/ ``` 若遇到网络问题可使用国内仓库地址: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 文件** ```bash curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml ``` 下载完成后打开 `docker-compose.yml` 填写所需配置,例如 `CHANNEL_TYPE`、`OPEN_AI_API_KEY` 和等配置。 **(2) 启动容器** 在 `docker-compose.yml` 所在目录下执行以下命令启动容器: ```bash sudo docker compose up -d # 若docker-compose为 1.X 版本,则执行 `sudo docker-compose up -d` ``` 运行命令后,会自动取 [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" } ``` 运行后可在终端与机器人进行对话。

# 🔗 相关项目 - [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),可访问终端、浏览器、文件系统、搜索引擎 等各类工具,并实现了多智能体协同。 # 🔎 常见问题 FAQs: 或直接在线咨询 [项目小助手](https://link-ai.tech/app/Kv2fXJcH) (知识库持续完善中,回复供参考) # 🛠️ 开发 欢迎接入更多应用通道,参考 [飞书通道](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)。 # ✉ 联系 欢迎提交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)咨询。 # 🌟 贡献者 ![cow contributors](https://contrib.rocks/image?repo=zhayujie/chatgpt-on-wechat&max=1000) ================================================ FILE: agent/chat/__init__.py ================================================ from agent.chat.service import ChatService __all__ = ["ChatService"] ================================================ FILE: agent/chat/service.py ================================================ """ ChatService - Wraps the Agent stream execution to produce CHAT protocol chunks. Translates agent events (message_update, message_end, tool_execution_end, etc.) into the CHAT socket protocol format (content chunks with segment_id, tool_calls chunks). """ import time from typing import Callable, Optional from common.log import logger class ChatService: """ High-level service that runs an Agent for a given query and streams the results as CHAT protocol chunks via a callback. Usage: svc = ChatService(agent_bridge) svc.run(query, session_id, send_chunk_fn) """ def __init__(self, agent_bridge): """ :param agent_bridge: AgentBridge instance (manages agent lifecycle) """ self.agent_bridge = agent_bridge def run(self, query: str, session_id: str, send_chunk_fn: Callable[[dict], None], channel_type: str = ""): """ Run the agent for *query* and stream results back via *send_chunk_fn*. The method blocks until the agent finishes. After it returns the SDK will automatically send the final (streaming=false) message. :param query: user query text :param session_id: session identifier for agent isolation :param send_chunk_fn: callable(chunk_data: dict) to send a streaming chunk :param channel_type: source channel (e.g. "web", "feishu") for persistence """ agent = self.agent_bridge.get_agent(session_id=session_id) if agent is None: raise RuntimeError("Failed to initialise agent for the session") # Pass context metadata to model for downstream API requests if hasattr(agent, 'model'): agent.model.channel_type = channel_type or "" agent.model.session_id = session_id or "" # State shared between the event callback and this method state = _StreamState() def on_event(event: dict): """Translate agent events into CHAT protocol chunks.""" event_type = event.get("type") data = event.get("data", {}) if event_type == "message_update": # Incremental text delta delta = data.get("delta", "") if delta: send_chunk_fn({ "chunk_type": "content", "delta": delta, "segment_id": state.segment_id, }) elif event_type == "message_end": # A content segment finished. tool_calls = data.get("tool_calls", []) if tool_calls: # After tool_calls are executed the next content will be # a new segment; collect tool results until turn_end. state.pending_tool_results = [] 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", "") arguments = data.get("arguments", {}) # Cache arguments keyed by tool_call_id so tool_execution_end can include them tool_call_id = data.get("tool_call_id", tool_name) state.pending_tool_arguments[tool_call_id] = arguments send_chunk_fn({ "chunk_type": "tool_start", "tool": tool_name, "arguments": arguments, }) elif event_type == "tool_execution_end": tool_name = data.get("tool_name", "") tool_call_id = data.get("tool_call_id", tool_name) # Retrieve cached arguments from the matching tool_execution_start event arguments = state.pending_tool_arguments.pop(tool_call_id, data.get("arguments", {})) result = data.get("result", "") status = data.get("status", "unknown") execution_time = data.get("execution_time", 0) elapsed_str = f"{execution_time:.2f}s" # Serialise result to string if needed if not isinstance(result, str): import json try: result = json.dumps(result, ensure_ascii=False) except Exception: result = str(result) tool_info = { "name": tool_name, "arguments": arguments, "result": result, "status": status, "elapsed": elapsed_str, } if state.pending_tool_results is not None: state.pending_tool_results.append(tool_info) elif event_type == "turn_end": has_tool_calls = data.get("has_tool_calls", False) if has_tool_calls and state.pending_tool_results: # Flush collected tool results as a single tool_calls chunk send_chunk_fn({ "chunk_type": "tool_calls", "tool_calls": state.pending_tool_results, }) state.pending_tool_results = None # Next content belongs to a new segment state.segment_id += 1 # Run the agent with our event callback --------------------------- logger.info(f"[ChatService] Starting agent run: session={session_id}, query={query[:80]}") from config import conf max_context_turns = conf().get("agent_max_context_turns", 20) # Get full system prompt with skills full_system_prompt = agent.get_full_system_prompt() # Create a copy of messages for this execution with agent.messages_lock: messages_copy = agent.messages.copy() original_length = len(agent.messages) from agent.protocol.agent_stream import AgentStreamExecutor executor = AgentStreamExecutor( agent=agent, model=agent.model, system_prompt=full_system_prompt, tools=agent.tools, max_turns=agent.max_steps, on_event=on_event, messages=messages_copy, max_context_turns=max_context_turns, ) try: response = executor.run_stream(query) except Exception: # If executor cleared messages (context overflow), sync back if len(executor.messages) == 0: with agent.messages_lock: agent.messages.clear() logger.info("[ChatService] Cleared agent message history after executor recovery") raise # Append only the NEW messages from this execution (thread-safe) with agent.messages_lock: new_messages = executor.messages[original_length:] agent.messages.extend(new_messages) # Persist new messages to SQLite so they survive restarts and # can be queried via the HISTORY interface. if new_messages: self._persist_messages(session_id, list(new_messages), channel_type) # Store executor reference for files_to_send access agent.stream_executor = executor # Execute post-process tools agent._execute_post_process_tools() logger.info(f"[ChatService] Agent run completed: session={session_id}") @staticmethod def _persist_messages(session_id: str, new_messages: list, channel_type: str = ""): try: from config import conf if not conf().get("conversation_persistence", True): return except Exception: pass try: from agent.memory import get_conversation_store get_conversation_store().append_messages( session_id, new_messages, channel_type=channel_type ) except Exception as e: logger.warning( f"[ChatService] Failed to persist messages for session={session_id}: {e}" ) class _StreamState: """Mutable state shared between the event callback and the run method.""" def __init__(self): self.segment_id: int = 0 # None means we are not accumulating tool results right now. # A list means we are in the middle of a tool-execution phase. self.pending_tool_results: Optional[list] = None # Maps tool_call_id -> arguments captured from tool_execution_start, # so that tool_execution_end can attach the correct input args. self.pending_tool_arguments: dict = {} ================================================ FILE: agent/memory/__init__.py ================================================ """ Memory module for AgentMesh Provides both long-term memory (vector/keyword search) and short-term conversation history persistence (SQLite). """ from agent.memory.manager import MemoryManager from agent.memory.config import MemoryConfig, get_default_memory_config, set_global_memory_config from agent.memory.embedding import create_embedding_provider from agent.memory.conversation_store import ConversationStore, get_conversation_store from agent.memory.summarizer import ensure_daily_memory_file __all__ = [ 'MemoryManager', 'MemoryConfig', 'get_default_memory_config', 'set_global_memory_config', 'create_embedding_provider', 'ConversationStore', 'get_conversation_store', 'ensure_daily_memory_file', ] ================================================ FILE: agent/memory/chunker.py ================================================ """ Text chunking utilities for memory Splits text into chunks with token limits and overlap """ from __future__ import annotations from typing import List, Tuple from dataclasses import dataclass @dataclass class TextChunk: """Represents a text chunk with line numbers""" text: str start_line: int end_line: int class TextChunker: """Chunks text by line count with token estimation""" def __init__(self, max_tokens: int = 500, overlap_tokens: int = 50): """ Initialize chunker Args: max_tokens: Maximum tokens per chunk overlap_tokens: Overlap tokens between chunks """ self.max_tokens = max_tokens self.overlap_tokens = overlap_tokens # Rough estimation: ~4 chars per token for English/Chinese mixed self.chars_per_token = 4 def chunk_text(self, text: str) -> List[TextChunk]: """ Chunk text into overlapping segments Args: text: Input text to chunk Returns: List of TextChunk objects """ if not text.strip(): return [] lines = text.split('\n') chunks = [] max_chars = self.max_tokens * self.chars_per_token overlap_chars = self.overlap_tokens * self.chars_per_token current_chunk = [] current_chars = 0 start_line = 1 for i, line in enumerate(lines, start=1): line_chars = len(line) # If single line exceeds max, split it if line_chars > max_chars: # Save current chunk if exists if current_chunk: chunks.append(TextChunk( text='\n'.join(current_chunk), start_line=start_line, end_line=i - 1 )) current_chunk = [] current_chars = 0 # Split long line into multiple chunks for sub_chunk in self._split_long_line(line, max_chars): chunks.append(TextChunk( text=sub_chunk, start_line=i, end_line=i )) start_line = i + 1 continue # Check if adding this line would exceed limit if current_chars + line_chars > max_chars and current_chunk: # Save current chunk chunks.append(TextChunk( text='\n'.join(current_chunk), start_line=start_line, end_line=i - 1 )) # Start new chunk with overlap overlap_lines = self._get_overlap_lines(current_chunk, overlap_chars) current_chunk = overlap_lines + [line] current_chars = sum(len(l) for l in current_chunk) start_line = i - len(overlap_lines) else: # Add line to current chunk current_chunk.append(line) current_chars += line_chars # Save last chunk if current_chunk: chunks.append(TextChunk( text='\n'.join(current_chunk), start_line=start_line, end_line=len(lines) )) return chunks def _split_long_line(self, line: str, max_chars: int) -> List[str]: """Split a single long line into multiple chunks""" chunks = [] for i in range(0, len(line), max_chars): chunks.append(line[i:i + max_chars]) return chunks def _get_overlap_lines(self, lines: List[str], target_chars: int) -> List[str]: """Get last few lines that fit within target_chars for overlap""" overlap = [] chars = 0 for line in reversed(lines): line_chars = len(line) if chars + line_chars > target_chars: break overlap.insert(0, line) chars += line_chars return overlap def chunk_markdown(self, text: str) -> List[TextChunk]: """ Chunk markdown text while respecting structure (For future enhancement: respect markdown sections) """ return self.chunk_text(text) ================================================ FILE: agent/memory/config.py ================================================ """ Memory configuration module Provides global memory configuration with simplified workspace structure """ from __future__ import annotations import os from dataclasses import dataclass, field from typing import Optional, List from pathlib import Path def _default_workspace(): """Get default workspace path with proper Windows support""" from common.utils import expand_path return expand_path("~/cow") @dataclass class MemoryConfig: """Configuration for memory storage and search""" # Storage paths (default: ~/cow) workspace_root: str = field(default_factory=_default_workspace) # Embedding config embedding_provider: str = "openai" # "openai" | "local" embedding_model: str = "text-embedding-3-small" embedding_dim: int = 1536 # Chunking config chunk_max_tokens: int = 500 chunk_overlap_tokens: int = 50 # Search config max_results: int = 10 min_score: float = 0.1 # Hybrid search weights vector_weight: float = 0.7 keyword_weight: float = 0.3 # Memory sources sources: List[str] = field(default_factory=lambda: ["memory", "session"]) # Sync config enable_auto_sync: bool = True sync_on_search: bool = True def get_workspace(self) -> Path: """Get workspace root directory""" return Path(self.workspace_root) def get_memory_dir(self) -> Path: """Get memory files directory""" return self.get_workspace() / "memory" def get_db_path(self) -> Path: """Get SQLite database path for long-term memory index""" index_dir = self.get_memory_dir() / "long-term" index_dir.mkdir(parents=True, exist_ok=True) return index_dir / "index.db" def get_skills_dir(self) -> Path: """Get skills directory""" return self.get_workspace() / "skills" def get_agent_workspace(self, agent_name: Optional[str] = None) -> Path: """ Get workspace directory for an agent Args: agent_name: Optional agent name (not used in current implementation) Returns: Path to workspace directory """ workspace = self.get_workspace() # Ensure workspace directory exists workspace.mkdir(parents=True, exist_ok=True) return workspace # Global memory configuration _global_memory_config: Optional[MemoryConfig] = None def get_default_memory_config() -> MemoryConfig: """ Get the global memory configuration. If not set, returns a default configuration. Returns: MemoryConfig instance """ global _global_memory_config if _global_memory_config is None: _global_memory_config = MemoryConfig() return _global_memory_config def set_global_memory_config(config: MemoryConfig): """ Set the global memory configuration. This should be called before creating any MemoryManager instances. Args: config: MemoryConfig instance to use globally Example: >>> from agent.memory import MemoryConfig, set_global_memory_config >>> config = MemoryConfig( ... workspace_root="~/my_agents", ... embedding_provider="openai", ... vector_weight=0.8 ... ) >>> set_global_memory_config(config) """ global _global_memory_config _global_memory_config = config ================================================ FILE: agent/memory/conversation_store.py ================================================ """ Conversation history persistence using SQLite. Design: - sessions table: per-session metadata (channel_type, last_active, msg_count) - messages table: individual messages stored as JSON, append-only - Pruning: age-based only (sessions not updated within N days are deleted) - Thread-safe via a single in-process lock Storage path: ~/cow/sessions/conversations.db """ from __future__ import annotations import json import sqlite3 import threading import time from pathlib import Path from typing import Any, Dict, List, Optional from common.log import logger # --------------------------------------------------------------------------- # Schema # --------------------------------------------------------------------------- _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 ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, seq INTEGER NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE (session_id, seq) ); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages (session_id, seq); CREATE INDEX IF NOT EXISTS idx_sessions_last_active ON sessions (last_active); """ # Migration: add channel_type column to existing databases that predate it. _MIGRATION_ADD_CHANNEL_TYPE = """ ALTER TABLE sessions ADD COLUMN channel_type TEXT NOT NULL DEFAULT ''; """ DEFAULT_MAX_AGE_DAYS: int = 30 def _is_visible_user_message(content: Any) -> bool: """ Return True when a user-role message represents actual user input (not an internal tool_result injected by the agent loop). """ if isinstance(content, str): return bool(content.strip()) if isinstance(content, list): return any( isinstance(b, dict) and b.get("type") == "text" for b in content ) return False def _extract_display_text(content: Any) -> str: """ Extract the human-readable text portion from a message content value. Returns an empty string for tool_use / tool_result blocks. """ if isinstance(content, str): return content.strip() if isinstance(content, list): parts = [ b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text" ] return "\n".join(p for p in parts if p).strip() return "" def _extract_tool_calls(content: Any) -> List[Dict[str, Any]]: """ Extract tool_use blocks from an assistant message content. Returns a list of {name, arguments} dicts (result filled in later). """ if not isinstance(content, list): return [] return [ {"id": b.get("id", ""), "name": b.get("name", ""), "arguments": b.get("input", {})} for b in content if isinstance(b, dict) and b.get("type") == "tool_use" ] def _extract_tool_results(content: Any) -> Dict[str, str]: """ Extract tool_result blocks from a user message, keyed by tool_use_id. """ if not isinstance(content, list): return {} results = {} for b in content: if not isinstance(b, dict) or b.get("type") != "tool_result": continue tool_id = b.get("tool_use_id", "") result_content = b.get("content", "") if isinstance(result_content, list): result_content = "\n".join( rb.get("text", "") for rb in result_content if isinstance(rb, dict) and rb.get("type") == "text" ) results[tool_id] = str(result_content) return results def _group_into_display_turns( rows: List[tuple], ) -> List[Dict[str, Any]]: """ Convert raw (role, content_json, created_at) DB rows into display turns. One display turn = one visible user message + one merged assistant reply. All intermediate assistant messages (those carrying tool_use) and the final assistant text reply produced for the same user query are collapsed into a single assistant turn, exactly matching the live SSE rendering where tools and the final answer appear inside the same bubble. Grouping rules: - A visible user message starts a new group. - tool_result user messages are internal; their content is attached to the matching tool_use entry via tool_use_id and they never become own turns. - All assistant messages within a group are merged: * tool_use blocks → tool_calls list (result filled from tool_results) * text blocks → last non-empty text becomes the display content """ # ------------------------------------------------------------------ # # Pass 1: split rows into groups, each starting with a visible user msg # ------------------------------------------------------------------ # # group = (user_row | None, [subsequent_rows]) # user_row: (content, created_at) groups: List[tuple] = [] cur_user: Optional[tuple] = None cur_rest: List[tuple] = [] started = False for role, raw_content, created_at in rows: try: content = json.loads(raw_content) except Exception: content = raw_content if role == "user" and _is_visible_user_message(content): if started: groups.append((cur_user, cur_rest)) cur_user = (content, created_at) cur_rest = [] started = True else: cur_rest.append((role, content, created_at)) if started: groups.append((cur_user, cur_rest)) # ------------------------------------------------------------------ # # Pass 2: build display turns from each group # ------------------------------------------------------------------ # turns: List[Dict[str, Any]] = [] for user_row, rest in groups: # User turn if user_row: content, created_at = user_row text = _extract_display_text(content) if 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]] = [] tool_results: Dict[str, str] = {} final_text = "" final_ts: Optional[int] = None for role, content, created_at in rest: 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 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", ""), "") if final_text or all_tool_calls: turns.append({ "role": "assistant", "content": final_text, "tool_calls": all_tool_calls, "created_at": final_ts or (user_row[1] if user_row else 0), }) return turns class ConversationStore: """ SQLite-backed store for per-session conversation history. Usage: store = ConversationStore(db_path) store.append_messages("user_123", new_messages, channel_type="feishu") msgs = store.load_messages("user_123", max_turns=30) """ def __init__(self, db_path: Path): self._db_path = db_path self._lock = threading.Lock() self._init_db() # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def load_messages( self, session_id: str, max_turns: int = 30, ) -> List[Dict[str, Any]]: """ Load the most recent messages for a session, for injection into the LLM. ALL message types (user text, assistant tool_use, tool_result) are returned in their original JSON form so the LLM can reconstruct the full context. max_turns is a *visible-turn* count: we count only user messages whose content is actual user text (not tool_result blocks). This prevents tool-heavy sessions from exhausting the turn budget prematurely. Args: session_id: Unique session identifier. max_turns: Maximum number of visible user-assistant turns to keep. Returns: Chronologically ordered list of message dicts (role, content). """ with self._lock: conn = self._connect() try: rows = conn.execute( """ SELECT seq, role, content FROM messages WHERE session_id = ? ORDER BY seq DESC """, (session_id,), ).fetchall() finally: conn.close() 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 for seq, role, raw_content in rows: if role != "user": continue try: content = json.loads(raw_content) except Exception: content = raw_content 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 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: continue try: content = json.loads(raw_content) except Exception: content = raw_content result.append({"role": role, "content": content}) return result def append_messages( self, session_id: str, messages: List[Dict[str, Any]], channel_type: str = "", ) -> None: """ Append new messages to a session's history. Seq numbers continue from the session's current maximum, so concurrent callers on distinct sessions never collide. Args: session_id: Unique session identifier. messages: List of message dicts to append. channel_type: Source channel (e.g. "feishu", "web", "wechat"). Only written on session creation; ignored on update. """ if not messages: return now = int(time.time()) with self._lock: conn = self._connect() try: with conn: # INSERT OR IGNORE creates the row on first visit; # the UPDATE always refreshes last_active. # Avoids ON CONFLICT...DO UPDATE (requires SQLite >= 3.24). conn.execute( """ INSERT OR IGNORE INTO sessions (session_id, channel_type, created_at, last_active, msg_count) VALUES (?, ?, ?, ?, 0) """, (session_id, channel_type, now, now), ) conn.execute( "UPDATE sessions SET last_active = ? WHERE session_id = ?", (now, session_id), ) # Determine starting seq for the new batch. row = conn.execute( "SELECT COALESCE(MAX(seq), -1) FROM messages WHERE session_id = ?", (session_id,), ).fetchone() next_seq = row[0] + 1 for msg in messages: role = msg.get("role", "") content = json.dumps( msg.get("content", ""), ensure_ascii=False ) conn.execute( """ INSERT OR IGNORE INTO messages (session_id, seq, role, content, created_at) VALUES (?, ?, ?, ?, ?) """, (session_id, next_seq, role, content, now), ) next_seq += 1 conn.execute( """ UPDATE sessions SET msg_count = ( SELECT COUNT(*) FROM messages WHERE session_id = ? ) WHERE session_id = ? """, (session_id, session_id), ) finally: conn.close() def clear_session(self, session_id: str) -> None: """Delete all messages and the session record for a given session_id.""" with self._lock: conn = self._connect() try: with conn: conn.execute( "DELETE FROM messages WHERE session_id = ?", (session_id,) ) conn.execute( "DELETE FROM sessions WHERE session_id = ?", (session_id,) ) 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. Args: max_age_days: Override the default retention period. Returns: Number of sessions deleted. """ try: from config import conf max_age = max_age_days or conf().get( "conversation_max_age_days", DEFAULT_MAX_AGE_DAYS ) except Exception: max_age = max_age_days or DEFAULT_MAX_AGE_DAYS cutoff = int(time.time()) - max_age * 86400 deleted = 0 with self._lock: conn = self._connect() try: with conn: stale = conn.execute( "SELECT session_id FROM sessions WHERE last_active < ?", (cutoff,), ).fetchall() for (sid,) in stale: conn.execute( "DELETE FROM messages WHERE session_id = ?", (sid,) ) conn.execute( "DELETE FROM sessions WHERE session_id = ?", (sid,) ) deleted += 1 finally: conn.close() if deleted: logger.info(f"[ConversationStore] Pruned {deleted} expired sessions") return deleted def load_history_page( self, session_id: str, page: int = 1, page_size: int = 20, ) -> Dict[str, Any]: """ Load a page of conversation history for UI display, grouped into turns. Each "turn" maps to one of: - A user message (role="user", content=str) - An assistant message (role="assistant", content=str, tool_calls=[{name, arguments, result}] when tools were used) Internal tool_result user messages are merged into the preceding assistant entry's tool_calls list and never appear as standalone items. Pages are numbered from 1 (most recent). Messages within a page are returned in chronological order. Returns: { "messages": [ { "role": "user" | "assistant", "content": str, "tool_calls": [...], # assistant only, may be [] "created_at": int, }, ... ], "total": , "page": , "page_size": , "has_more": bool, } """ page = max(1, page) with self._lock: conn = self._connect() try: rows = conn.execute( """ SELECT 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) total = len(visible) offset = (page - 1) * page_size page_items = list(reversed(visible))[offset: offset + page_size] page_items = list(reversed(page_items)) return { "messages": page_items, "total": total, "page": page, "page_size": page_size, "has_more": offset + page_size < total, } def get_stats(self) -> Dict[str, Any]: """Return basic stats keyed by channel_type, for monitoring.""" with self._lock: conn = self._connect() try: total_sessions = conn.execute( "SELECT COUNT(*) FROM sessions" ).fetchone()[0] total_messages = conn.execute( "SELECT COUNT(*) FROM messages" ).fetchone()[0] by_channel = conn.execute( """ SELECT channel_type, COUNT(*) as cnt FROM sessions GROUP BY channel_type ORDER BY cnt DESC """ ).fetchall() return { "total_sessions": total_sessions, "total_messages": total_messages, "by_channel": {row[0] or "unknown": row[1] for row in by_channel}, } finally: conn.close() # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _init_db(self) -> None: self._db_path.parent.mkdir(parents=True, exist_ok=True) conn = self._connect() try: conn.executescript(_DDL) conn.commit() self._migrate(conn) finally: conn.close() def _migrate(self, conn: sqlite3.Connection) -> None: """Apply incremental schema migrations on existing databases.""" cols = { row[1] for row in conn.execute("PRAGMA table_info(sessions)").fetchall() } if "channel_type" not in cols: try: conn.execute(_MIGRATION_ADD_CHANNEL_TYPE) conn.commit() logger.info("[ConversationStore] Migrated: added channel_type column") except Exception as e: logger.warning(f"[ConversationStore] Migration failed: {e}") def _connect(self) -> sqlite3.Connection: conn = sqlite3.connect(str(self._db_path), timeout=10) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") return conn # --------------------------------------------------------------------------- # Singleton # --------------------------------------------------------------------------- _store_instance: Optional[ConversationStore] = None _store_lock = threading.Lock() def get_conversation_store() -> ConversationStore: """ Return the process-wide ConversationStore singleton. Reuses the long-term memory database so the project stays with a single SQLite file: ~/cow/memory/long-term/index.db The conversation tables (sessions / messages) are separate from the memory tables (memory_chunks / file_metadata) — no conflicts. """ global _store_instance if _store_instance is not None: return _store_instance with _store_lock: if _store_instance is not None: return _store_instance try: from agent.memory.config import get_default_memory_config db_path = get_default_memory_config().get_db_path() except Exception: from common.utils import expand_path db_path = Path(expand_path("~/cow")) / "memory" / "long-term" / "index.db" _store_instance = ConversationStore(db_path) logger.debug(f"[ConversationStore] Using shared DB at: {db_path}") return _store_instance ================================================ FILE: agent/memory/embedding.py ================================================ """ 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) ================================================ FILE: agent/memory/manager.py ================================================ """ Memory manager for AgentMesh Provides high-level interface for memory operations """ import os from typing import List, Optional, Dict, Any from pathlib import Path import hashlib 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.summarizer import MemoryFlushManager, create_memory_files_if_needed class MemoryManager: """ Memory manager with hybrid search capabilities Provides long-term memory for agents with vector and keyword search """ def __init__( self, config: Optional[MemoryConfig] = None, embedding_provider: Optional[EmbeddingProvider] = None, llm_model: Optional[Any] = None ): """ Initialize memory manager Args: config: Memory configuration (uses global config if not provided) embedding_provider: Custom embedding provider (optional) llm_model: LLM model for summarization (optional) """ self.config = config or get_default_memory_config() # Initialize storage db_path = self.config.get_db_path() self.storage = MemoryStorage(db_path) # Initialize chunker self.chunker = TextChunker( max_tokens=self.config.chunk_max_tokens, 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}") # 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( workspace_dir=workspace_dir, llm_model=llm_model ) # Ensure workspace directories exist self._init_workspace() self._dirty = False def _init_workspace(self): """Initialize workspace directories""" memory_dir = self.config.get_memory_dir() memory_dir.mkdir(parents=True, exist_ok=True) # Create default memory files workspace_dir = self.config.get_workspace() create_memory_files_if_needed(workspace_dir) async def search( self, query: str, user_id: Optional[str] = None, max_results: Optional[int] = None, min_score: Optional[float] = None, include_shared: bool = True ) -> List[SearchResult]: """ Search memory with hybrid search (vector + keyword) Args: query: Search query user_id: User ID for scoped search max_results: Maximum results to return min_score: Minimum score threshold include_shared: Include shared memories Returns: List of search results sorted by relevance """ max_results = max_results or self.config.max_results min_score = min_score or self.config.min_score # Determine scopes scopes = [] if include_shared: scopes.append("shared") if user_id: scopes.append("user") if not scopes: return [] # Sync if needed if self.config.sync_on_search and self._dirty: await self.sync() # Perform vector search (if embedding provider available) vector_results = [] if self.embedding_provider: try: from common.log import logger query_embedding = self.embedding_provider.embed(query) vector_results = self.storage.search_vector( query_embedding=query_embedding, user_id=user_id, scopes=scopes, limit=max_results * 2 # Get more candidates for merging ) 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 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, keyword_results, 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] async def add_memory( self, content: str, user_id: Optional[str] = None, scope: str = "shared", source: str = "memory", path: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None ): """ Add new memory content Args: content: Memory content user_id: User ID for user-scoped memory scope: Memory scope ("shared", "user", "session") source: Memory source ("memory" or "session") path: File path (auto-generated if not provided) metadata: Additional metadata """ if not content.strip(): return # Generate path if not provided if not path: content_hash = hashlib.md5(content.encode('utf-8')).hexdigest()[:8] if user_id and scope == "user": path = f"memory/users/{user_id}/memory_{content_hash}.md" else: path = f"memory/shared/memory_{content_hash}.md" # Chunk content chunks = self.chunker.chunk_text(content) # Generate embeddings (if provider available) texts = [chunk.text for chunk in chunks] if self.embedding_provider: embeddings = self.embedding_provider.embed_batch(texts) else: # No embeddings, just use None embeddings = [None] * len(texts) # Create memory chunks memory_chunks = [] for chunk, embedding in zip(chunks, embeddings): chunk_id = self._generate_chunk_id(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, path=path, start_line=chunk.start_line, end_line=chunk.end_line, text=chunk.text, embedding=embedding, hash=chunk_hash, metadata=metadata )) # Save to storage self.storage.save_chunks_batch(memory_chunks) # Update file metadata file_hash = MemoryStorage.compute_hash(content) self.storage.update_file_metadata( path=path, source=source, file_hash=file_hash, mtime=int(os.path.getmtime(__file__)), # Use current time size=len(content) ) async def sync(self, force: bool = False): """ Synchronize memory from files Args: force: Force full reindex """ memory_dir = self.config.get_memory_dir() workspace_dir = self.config.get_workspace() # Scan MEMORY.md (workspace root) 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) 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 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 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: return texts = [chunk.text for chunk in chunks] if self.embedding_provider: embeddings = self.embedding_provider.embed_batch(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, 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 ) def flush_memory( self, messages: list, user_id: Optional[str] = None, reason: str = "threshold", max_messages: int = 10, ) -> 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) Returns: True if content was written """ success = self.flush_manager.flush_from_messages( messages=messages, user_id=user_id, reason=reason, max_messages=max_messages, ) if success: self._dirty = True return success def get_status(self) -> Dict[str, Any]: """Get memory status""" stats = self.storage.get_stats() return { 'chunks': stats['chunks'], 'files': stats['files'], 'workspace': str(self.config.get_workspace()), 'dirty': self._dirty, 'embedding_enabled': self.embedding_provider is not None, 'embedding_provider': self.config.embedding_provider if self.embedding_provider else 'disabled', 'embedding_model': self.config.embedding_model if self.embedding_provider else 'N/A', 'search_mode': 'hybrid (vector + keyword)' if self.embedding_provider else 'keyword only (FTS5)' } def mark_dirty(self): """Mark memory as dirty (needs sync)""" self._dirty = True def close(self): """Close memory manager and release resources""" self.storage.close() # Helper methods def _generate_chunk_id(self, path: str, start_line: int, end_line: int) -> str: """Generate unique chunk ID""" content = f"{path}:{start_line}:{end_line}" return hashlib.md5(content.encode('utf-8')).hexdigest() @staticmethod def _compute_temporal_decay(path: str, half_life_days: float = 30.0) -> float: """ Compute temporal decay multiplier for dated memory files. Inspired by OpenClaw's temporal-decay: exponential decay based on file date. MEMORY.md and non-dated files are "evergreen" (no decay, multiplier=1.0). Daily files like memory/2025-03-01.md decay based on age. Formula: multiplier = exp(-ln2/half_life * age_in_days) """ import re import math match = re.search(r'(\d{4})-(\d{2})-(\d{2})\.md$', path) if not match: return 1.0 # evergreen: MEMORY.md, non-dated files try: file_date = datetime( int(match.group(1)), int(match.group(2)), int(match.group(3)) ) age_days = (datetime.now() - file_date).days if age_days <= 0: return 1.0 decay_lambda = math.log(2) / half_life_days return math.exp(-decay_lambda * age_days) except (ValueError, OverflowError): return 1.0 def _merge_results( self, vector_results: List[SearchResult], keyword_results: List[SearchResult], vector_weight: float, keyword_weight: float ) -> List[SearchResult]: """Merge vector and keyword search results with temporal decay for dated files""" merged_map = {} for result in vector_results: key = (result.path, result.start_line, result.end_line) merged_map[key] = { 'result': result, 'vector_score': result.score, 'keyword_score': 0.0 } for result in keyword_results: key = (result.path, result.start_line, result.end_line) if key in merged_map: merged_map[key]['keyword_score'] = result.score else: merged_map[key] = { 'result': result, 'vector_score': 0.0, 'keyword_score': result.score } merged_results = [] for entry in merged_map.values(): combined_score = ( vector_weight * entry['vector_score'] + keyword_weight * entry['keyword_score'] ) # Apply temporal decay for dated memory files result = entry['result'] decay = self._compute_temporal_decay(result.path) combined_score *= decay merged_results.append(SearchResult( path=result.path, start_line=result.start_line, end_line=result.end_line, score=combined_score, snippet=result.snippet, source=result.source, user_id=result.user_id )) merged_results.sort(key=lambda r: r.score, reverse=True) return merged_results ================================================ FILE: agent/memory/service.py ================================================ """ Memory service for handling memory query operations via cloud protocol. Provides a unified interface for listing and reading memory files, callable from the cloud client (LinkAI) or a future web console. Memory file layout (under workspace_root): MEMORY.md -> type: global memory/2026-02-20.md -> type: daily """ import os from datetime import datetime from typing import Dict, List, Optional from pathlib import Path from common.log import logger class MemoryService: """ High-level service for memory file queries. Operates directly on the filesystem — no MemoryManager dependency. """ def __init__(self, workspace_root: str): """ :param workspace_root: Workspace root directory (e.g. ~/cow) """ self.workspace_root = workspace_root self.memory_dir = os.path.join(workspace_root, "memory") # ------------------------------------------------------------------ # list — paginated file metadata # ------------------------------------------------------------------ def list_files(self, page: int = 1, page_size: int = 20) -> dict: """ List all memory 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"}, ... ] } """ 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) # Paginate start = (page - 1) * page_size end = start + page_size page_items = files[start:end] return { "page": page, "page_size": page_size, "total": total, "list": page_items, } # ------------------------------------------------------------------ # content — read a single file # ------------------------------------------------------------------ def get_content(self, filename: str) -> dict: """ Read the full content of a memory file. :param filename: File name, e.g. ``MEMORY.md`` or ``2026-02-20.md`` :return: dict with ``filename`` and ``content`` :raises FileNotFoundError: if the file does not exist """ path = self._resolve_path(filename) if not os.path.isfile(path): raise FileNotFoundError(f"Memory file not found: {filename}") with open(path, "r", encoding="utf-8") as f: content = f.read() return { "filename": filename, "content": content, } # ------------------------------------------------------------------ # dispatch — single entry point for protocol messages # ------------------------------------------------------------------ def dispatch(self, action: str, payload: Optional[dict] = None) -> dict: """ Dispatch a memory management action. :param action: ``list`` or ``content`` :param payload: action-specific payload :return: protocol-compatible response dict """ payload = payload or {} try: 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) 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) return {"action": action, "code": 200, "message": "success", "payload": result_payload} else: return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None} except FileNotFoundError as e: return {"action": action, "code": 404, "message": str(e), "payload": None} except Exception as e: logger.error(f"[MemoryService] dispatch error: action={action}, error={e}") return {"action": action, "code": 500, "message": str(e), "payload": None} # ------------------------------------------------------------------ # internal helpers # ------------------------------------------------------------------ def _resolve_path(self, filename: str) -> str: """ Resolve a filename to its absolute path. - ``MEMORY.md`` → ``{workspace_root}/MEMORY.md`` - ``2026-02-20.md`` → ``{workspace_root}/memory/2026-02-20.md`` """ if filename == "MEMORY.md": return os.path.join(self.workspace_root, filename) return os.path.join(self.memory_dir, filename) @staticmethod def _file_info(path: str, filename: str, file_type: str) -> dict: """Build a file metadata dict.""" stat = os.stat(path) updated_at = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S") return { "filename": filename, "type": file_type, "size": stat.st_size, "updated_at": updated_at, } ================================================ FILE: agent/memory/storage.py ================================================ """ Storage layer for memory using SQLite + FTS5 Provides vector and keyword search capabilities """ from __future__ import annotations import sqlite3 import json import hashlib from typing import List, Dict, Optional, Any from pathlib import Path from dataclasses import dataclass @dataclass class MemoryChunk: """Represents a memory chunk with text and embedding""" id: str user_id: Optional[str] scope: str # "shared" | "user" | "session" source: str # "memory" | "session" path: str start_line: int end_line: int text: str embedding: Optional[List[float]] hash: str metadata: Optional[Dict[str, Any]] = None @dataclass class SearchResult: """Search result with score and snippet""" path: str start_line: int end_line: int score: float snippet: str source: str user_id: Optional[str] = None class MemoryStorage: """SQLite-based storage with FTS5 for keyword search""" def __init__(self, db_path: Path): self.db_path = db_path self.conn: Optional[sqlite3.Connection] = None self.fts5_available = False # Track FTS5 availability self._init_db() def _check_fts5_support(self) -> bool: """Check if SQLite has FTS5 support""" try: self.conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS fts5_test USING fts5(test)") self.conn.execute("DROP TABLE IF EXISTS fts5_test") return True except sqlite3.OperationalError as e: if "no such module: fts5" in str(e): return False raise def _init_db(self): """Initialize database with schema""" try: self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False) self.conn.row_factory = sqlite3.Row # Check FTS5 support self.fts5_available = self._check_fts5_support() if not self.fts5_available: from common.log import logger logger.debug("[MemoryStorage] FTS5 not available, using LIKE-based keyword search") # Check database integrity try: result = self.conn.execute("PRAGMA integrity_check").fetchone() if result[0] != 'ok': print(f"⚠️ Database integrity check failed: {result[0]}") print(f" Recreating database...") self.conn.close() self.conn = None # Remove corrupted database self.db_path.unlink(missing_ok=True) # Remove WAL files Path(str(self.db_path) + '-wal').unlink(missing_ok=True) Path(str(self.db_path) + '-shm').unlink(missing_ok=True) # Reconnect to create new database self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False) self.conn.row_factory = sqlite3.Row except sqlite3.DatabaseError: # Database is corrupted, recreate it print(f"⚠️ Database is corrupted, recreating...") if self.conn: self.conn.close() self.conn = None self.db_path.unlink(missing_ok=True) Path(str(self.db_path) + '-wal').unlink(missing_ok=True) Path(str(self.db_path) + '-shm').unlink(missing_ok=True) self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False) self.conn.row_factory = sqlite3.Row # Enable WAL mode for better concurrency self.conn.execute("PRAGMA journal_mode=WAL") # Set busy timeout to avoid "database is locked" errors self.conn.execute("PRAGMA busy_timeout=5000") except Exception as e: print(f"⚠️ Unexpected error during database initialization: {e}") raise # Create chunks table with embeddings self.conn.execute(""" CREATE TABLE IF NOT EXISTS chunks ( id TEXT PRIMARY KEY, user_id TEXT, scope TEXT NOT NULL DEFAULT 'shared', source TEXT NOT NULL DEFAULT 'memory', path TEXT NOT NULL, start_line INTEGER NOT NULL, end_line INTEGER NOT NULL, text TEXT NOT NULL, embedding TEXT, hash TEXT NOT NULL, metadata TEXT, created_at INTEGER DEFAULT (strftime('%s', 'now')), updated_at INTEGER DEFAULT (strftime('%s', 'now')) ) """) # Create indexes self.conn.execute(""" CREATE INDEX IF NOT EXISTS idx_chunks_user ON chunks(user_id) """) self.conn.execute(""" CREATE INDEX IF NOT EXISTS idx_chunks_scope ON chunks(scope) """) self.conn.execute(""" CREATE INDEX IF NOT EXISTS idx_chunks_hash ON chunks(path, hash) """) # Create FTS5 virtual table for keyword search (only if supported) 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' ) """) # 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 """) # Create files metadata table self.conn.execute(""" CREATE TABLE IF NOT EXISTS files ( path TEXT PRIMARY KEY, source TEXT NOT NULL DEFAULT 'memory', hash TEXT NOT NULL, mtime INTEGER NOT NULL, size INTEGER NOT NULL, updated_at INTEGER DEFAULT (strftime('%s', 'now')) ) """) self.conn.commit() def save_chunk(self, chunk: MemoryChunk): """Save a memory chunk""" 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 )) 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')) """, [ ( 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, c.hash, json.dumps(c.metadata) if c.metadata else None ) for c in chunks ]) self.conn.commit() def get_chunk(self, chunk_id: str) -> Optional[MemoryChunk]: """Get a chunk by ID""" row = self.conn.execute(""" SELECT * FROM chunks WHERE id = ? """, (chunk_id,)).fetchone() if not row: return None return self._row_to_chunk(row) def search_vector( self, query_embedding: List[float], user_id: Optional[str] = None, scopes: List[str] = None, limit: int = 10 ) -> List[SearchResult]: """ Vector similarity search using in-memory cosine similarity (sqlite-vec can be added later for better performance) """ if scopes is None: scopes = ["shared"] if user_id: scopes.append("user") # Build query scope_placeholders = ','.join('?' * len(scopes)) params = scopes if user_id: query = f""" SELECT * FROM chunks WHERE scope IN ({scope_placeholders}) AND (scope = 'shared' OR user_id = ?) AND embedding IS NOT NULL """ params.append(user_id) else: query = f""" SELECT * FROM chunks WHERE scope IN ({scope_placeholders}) AND embedding IS NOT NULL """ rows = self.conn.execute(query, params).fetchall() # Calculate cosine similarity results = [] 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 ] def search_keyword( self, query: str, user_id: Optional[str] = None, scopes: List[str] = None, limit: int = 10 ) -> 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 """ if scopes is None: scopes = ["shared"] if user_id: scopes.append("user") # Try FTS5 search first (if available) if self.fts5_available: 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) if not self.fts5_available or MemoryStorage._contains_cjk(query): return self._search_like(query, user_id, scopes, limit) return [] def _search_fts5( self, query: str, user_id: Optional[str], scopes: List[str], limit: int ) -> List[SearchResult]: """FTS5 full-text search""" fts_query = self._build_fts_query(query) if not fts_query: return [] scope_placeholders = ','.join('?' * len(scopes)) params = [fts_query] + scopes if user_id: sql_query = f""" SELECT chunks.*, bm25(chunks_fts) as rank FROM chunks_fts JOIN chunks ON chunks.id = chunks_fts.id WHERE chunks_fts 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_query = f""" SELECT chunks.*, bm25(chunks_fts) as rank FROM chunks_fts JOIN chunks ON chunks.id = chunks_fts.id WHERE chunks_fts MATCH ? AND chunks.scope IN ({scope_placeholders}) ORDER BY rank LIMIT ? """ params.append(limit) try: rows = self.conn.execute(sql_query, 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: return [] def _search_like( self, query: str, user_id: Optional[str], 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: return [] scope_placeholders = ','.join('?' * len(scopes)) # Build LIKE conditions for each word like_conditions = [] params = [] for word in cjk_words: like_conditions.append("text LIKE ?") params.append(f'%{word}%') where_clause = ' OR '.join(like_conditions) params.extend(scopes) if user_id: sql_query = f""" SELECT * FROM chunks WHERE ({where_clause}) AND scope IN ({scope_placeholders}) AND (scope = 'shared' OR user_id = ?) LIMIT ? """ params.extend([user_id, limit]) else: sql_query = f""" SELECT * FROM chunks WHERE ({where_clause}) AND scope IN ({scope_placeholders}) LIMIT ? """ params.append(limit) try: rows = self.conn.execute(sql_query, params).fetchall() return [ SearchResult( path=row['path'], start_line=row['start_line'], end_line=row['end_line'], score=0.5, # Fixed score for LIKE search snippet=self._truncate_text(row['text'], 500), source=row['source'], user_id=row['user_id'] ) for row in rows ] except Exception: 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() 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() 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'] return { 'chunks': chunks_count, 'files': files_count } def close(self): """Close database connection""" if self.conn: try: self.conn.commit() # Ensure all changes are committed self.conn.close() self.conn = None # Mark as closed except Exception as e: print(f"⚠️ Error closing database connection: {e}") def __del__(self): """Destructor to ensure connection is closed""" try: self.close() except Exception: pass # Ignore errors during cleanup # Helper methods def _row_to_chunk(self, row) -> MemoryChunk: """Convert database row to MemoryChunk""" return MemoryChunk( id=row['id'], user_id=row['user_id'], scope=row['scope'], source=row['source'], path=row['path'], start_line=row['start_line'], end_line=row['end_line'], text=row['text'], embedding=json.loads(row['embedding']) if row['embedding'] else None, 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) @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)) @staticmethod def _build_fts_query(raw_query: str) -> Optional[str]: """ Build FTS5 query from raw text 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: return None # Quote tokens for exact matching quoted = [f'"{t}"' for t in tokens] # Use OR for more flexible matching return ' OR '.join(quoted) @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) @staticmethod def _truncate_text(text: str, max_chars: int) -> str: """Truncate text to max characters""" if len(text) <= max_chars: return text return text[:max_chars] + "..." @staticmethod def compute_hash(content: str) -> str: """Compute SHA256 hash of content""" return hashlib.sha256(content.encode('utf-8')).hexdigest() ================================================ FILE: agent/memory/summarizer.py ================================================ """ Memory flush manager Handles memory persistence when conversation context is trimmed or overflows: - Uses LLM to summarize discarded messages into concise key-information entries - 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 """ import threading from typing import Optional, Callable, Any, List, Dict from pathlib import Path from datetime import datetime from common.log import logger SUMMARIZE_SYSTEM_PROMPT = """你是一个记忆提取助手。你的任务是从对话记录中提取值得记住的信息,生成简洁的记忆摘要。 输出要求: 1. 以事件/关键信息为维度记录,每条一行,用 "- " 开头 2. 记录有价值的关键信息,例如用户提出的要求及助手的解决方案,对话中涉及的事实信息,用户的偏好、决策或重要结论 3. 每条摘要需要简明扼要,只保留关键信息 4. 直接输出摘要内容,不要加任何前缀说明 5. 当对话没有任何记录价值例如只是简单问候,可回复"无\"""" SUMMARIZE_USER_PROMPT = """请从以下对话记录中提取关键信息,生成记忆摘要: {conversation}""" class MemoryFlushManager: """ Manages memory flush operations. Flush is triggered by agent_stream in two scenarios: 1. Context trim: _trim_messages discards old turns → flush discarded content 2. Context overflow: API rejects request → emergency flush before clearing Additionally, create_daily_summary() can be called by scheduler for end-of-day summaries. """ def __init__( self, workspace_dir: Path, llm_model: Optional[Any] = None, ): self.workspace_dir = workspace_dir self.llm_model = llm_model self.memory_dir = workspace_dir / "memory" self.memory_dir.mkdir(parents=True, exist_ok=True) 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 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""" today = datetime.now().strftime("%Y-%m-%d") if user_id: user_dir = self.memory_dir / "users" / user_id if ensure_exists: user_dir.mkdir(parents=True, exist_ok=True) today_file = user_dir / f"{today}.md" else: today_file = self.memory_dir / f"{today}.md" if ensure_exists and not today_file.exists(): today_file.parent.mkdir(parents=True, exist_ok=True) today_file.write_text(f"# Daily Memory: {today}\n\n") return today_file def get_main_memory_file(self, user_id: Optional[str] = None) -> Path: """Get main memory file path: MEMORY.md (workspace root)""" if user_id: user_dir = self.memory_dir / "users" / user_id user_dir.mkdir(parents=True, exist_ok=True) return user_dir / "MEMORY.md" else: return Path(self.workspace_dir) / "MEMORY.md" def get_status(self) -> dict: return { 'last_flush_time': self.last_flush_timestamp.isoformat() if self.last_flush_timestamp else None, 'today_file': str(self.get_today_memory_file()), 'main_file': str(self.get_main_memory_file()) } # ---- Flush execution (called by agent_stream or scheduler) ---- def flush_from_messages( self, messages: List[Dict], user_id: Optional[str] = None, reason: str = "trim", max_messages: int = 0, ) -> 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 """ try: import hashlib deduped = [] for m in messages: text = self._extract_text_from_content(m.get("content", "")) if not text or not text.strip(): continue h = hashlib.md5(text.encode("utf-8")).hexdigest() if h not in self._trim_flushed_hashes: self._trim_flushed_hashes.add(h) 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), daemon=True, ) thread.start() logger.info(f"[MemoryFlush] Async flush dispatched (reason={reason}, msgs={len(snapshot)})") return True except Exception as e: logger.warning(f"[MemoryFlush] Failed to dispatch flush (reason={reason}): {e}") return False def _flush_worker( self, messages: List[Dict], user_id: Optional[str], reason: str, max_messages: int, ): """Background worker: summarize with LLM and write to daily file.""" try: summary = self._summarize_messages(messages, max_messages) if not summary or not summary.strip() or summary.strip() == "无": logger.info(f"[MemoryFlush] No valuable content to flush (reason={reason})") return 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" with open(daily_file, "a", encoding="utf-8") as f: f.write(flush_entry) 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}") def create_daily_summary( self, messages: List[Dict], user_id: Optional[str] = None ) -> bool: """ Generate end-of-day summary. Called by daily timer. Skips if messages haven't changed since last flush. """ import hashlib content = "".join( self._extract_text_from_content(m.get("content", "")) for m in messages ) content_hash = hashlib.md5(content.encode("utf-8")).hexdigest() if content_hash == self._last_flushed_content_hash: logger.debug("[MemoryFlush] Daily summary skipped: no new content since last flush") return False self._last_flushed_content_hash = content_hash return self.flush_from_messages( messages=messages, user_id=user_id, reason="daily_summary", max_messages=0, ) # ---- Internal helpers ---- def _summarize_messages(self, messages: List[Dict], max_messages: int = 0) -> str: """ Summarize conversation messages using LLM, with rule-based fallback. """ 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() != "无": return summary.strip() except Exception as e: logger.warning(f"[MemoryFlush] LLM summarization failed, using fallback: {e}") 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.""" msgs = messages if max_messages == 0 else messages[-max_messages * 2:] lines = [] for msg in msgs: role = msg.get("role", "") text = self._extract_text_from_content(msg.get("content", "")) if not text or not text.strip(): continue text = text.strip() if role == "user": lines.append(f"用户: {text[:500]}") elif role == "assistant": lines.append(f"助手: {text[:500]}") return "\n".join(lines) 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)}], temperature=0, max_tokens=500, stream=False, 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 "" @staticmethod def _extract_summary_fallback(messages: List[Dict], max_messages: int = 0) -> str: """Rule-based fallback when LLM is unavailable.""" msgs = messages if max_messages == 0 else messages[-max_messages * 2:] items = [] 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: 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]) @staticmethod def _extract_text_from_content(content) -> str: """Extract plain text from message content (string or content blocks).""" 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 create_memory_files_if_needed(workspace_dir: Path, user_id: Optional[str] = None): """ Create essential memory files if they don't exist. Only creates MEMORY.md; daily files are created lazily on first write. Args: workspace_dir: Workspace directory user_id: Optional user ID for user-specific files """ memory_dir = workspace_dir / "memory" memory_dir.mkdir(parents=True, exist_ok=True) # Create main MEMORY.md in workspace root (always needed for bootstrap) if user_id: user_dir = memory_dir / "users" / user_id user_dir.mkdir(parents=True, exist_ok=True) main_memory = user_dir / "MEMORY.md" else: main_memory = Path(workspace_dir) / "MEMORY.md" if not main_memory.exists(): main_memory.write_text("") def ensure_daily_memory_file(workspace_dir: Path, user_id: Optional[str] = None) -> Path: """ Ensure today's daily memory file exists, creating it only when actually needed. Called lazily before first write to daily memory. Args: workspace_dir: Workspace directory user_id: Optional user ID for user-specific files Returns: Path to today's memory file """ memory_dir = workspace_dir / "memory" memory_dir.mkdir(parents=True, exist_ok=True) today = datetime.now().strftime("%Y-%m-%d") if user_id: user_dir = memory_dir / "users" / user_id user_dir.mkdir(parents=True, exist_ok=True) today_memory = user_dir / f"{today}.md" else: today_memory = memory_dir / f"{today}.md" if not today_memory.exists(): today_memory.write_text( f"# Daily Memory: {today}\n\n" ) return today_memory ================================================ FILE: agent/prompt/__init__.py ================================================ """ Agent Prompt Module - 系统提示词构建模块 """ from .builder import PromptBuilder, build_agent_system_prompt from .workspace import ensure_workspace, load_context_files __all__ = [ 'PromptBuilder', 'build_agent_system_prompt', 'ensure_workspace', 'load_context_files', ] ================================================ FILE: agent/prompt/builder.py ================================================ """ System Prompt Builder - 系统提示词构建器 实现模块化的系统提示词构建,支持工具、技能、记忆等多个子系统 """ from __future__ import annotations import os from typing import List, Dict, Optional, Any from dataclasses import dataclass from common.log import logger @dataclass class ContextFile: """上下文文件""" path: str content: str class PromptBuilder: """提示词构建器""" def __init__(self, workspace_dir: str, language: str = "zh"): """ 初始化提示词构建器 Args: workspace_dir: 工作空间目录 language: 语言 ("zh" 或 "en") """ self.workspace_dir = workspace_dir self.language = language def build( self, base_persona: Optional[str] = None, user_identity: Optional[Dict[str, str]] = None, tools: Optional[List[Any]] = None, context_files: Optional[List[ContextFile]] = None, skill_manager: Any = None, memory_manager: Any = None, runtime_info: Optional[Dict[str, Any]] = None, **kwargs ) -> str: """ 构建完整的系统提示词 Args: base_persona: 基础人格描述(会被context_files中的AGENT.md覆盖) user_identity: 用户身份信息 tools: 工具列表 context_files: 上下文文件列表(AGENT.md, USER.md, RULE.md, BOOTSTRAP.md等) skill_manager: 技能管理器 memory_manager: 记忆管理器 runtime_info: 运行时信息 **kwargs: 其他参数 Returns: 完整的系统提示词 """ return build_agent_system_prompt( workspace_dir=self.workspace_dir, language=self.language, base_persona=base_persona, user_identity=user_identity, tools=tools, context_files=context_files, skill_manager=skill_manager, memory_manager=memory_manager, runtime_info=runtime_info, **kwargs ) def build_agent_system_prompt( workspace_dir: str, language: str = "zh", base_persona: Optional[str] = None, user_identity: Optional[Dict[str, str]] = None, tools: Optional[List[Any]] = None, context_files: Optional[List[ContextFile]] = None, skill_manager: Any = None, memory_manager: Any = None, runtime_info: Optional[Dict[str, Any]] = None, **kwargs ) -> str: """ 构建Agent系统提示词 顺序说明(按重要性和逻辑关系排列): 1. 工具系统 - 核心能力,最先介绍 2. 技能系统 - 紧跟工具,因为技能需要用 read 工具读取 3. 记忆系统 - 独立的记忆能力 4. 工作空间 - 工作环境说明 5. 用户身份 - 用户信息(可选) 6. 项目上下文 - AGENT.md, USER.md, RULE.md, BOOTSTRAP.md(定义人格、身份、规则、初始化引导) 7. 运行时信息 - 元信息(时间、模型等) Args: workspace_dir: 工作空间目录 language: 语言 ("zh" 或 "en") base_persona: 基础人格描述(已废弃,由AGENT.md定义) user_identity: 用户身份信息 tools: 工具列表 context_files: 上下文文件列表 skill_manager: 技能管理器 memory_manager: 记忆管理器 runtime_info: 运行时信息 **kwargs: 其他参数 Returns: 完整的系统提示词 """ sections = [] # 1. 工具系统(最重要,放在最前面) if tools: sections.extend(_build_tooling_section(tools, language)) # 2. 技能系统(紧跟工具,因为需要用 read 工具) if skill_manager: sections.extend(_build_skills_section(skill_manager, tools, language)) # 3. 记忆系统(独立的记忆能力) if memory_manager: sections.extend(_build_memory_section(memory_manager, tools, language)) # 4. 工作空间(工作环境说明) sections.extend(_build_workspace_section(workspace_dir, language)) # 5. 用户身份(如果有) if user_identity: sections.extend(_build_user_identity_section(user_identity, language)) # 6. 项目上下文文件(AGENT.md, USER.md, RULE.md - 定义人格) if context_files: sections.extend(_build_context_files_section(context_files, language)) # 7. 运行时信息(元信息,放在最后) if runtime_info: sections.extend(_build_runtime_section(runtime_info, language)) return "\n".join(sections) def _build_identity_section(base_persona: Optional[str], language: str) -> List[str]: """构建基础身份section - 不再需要,身份由AGENT.md定义""" # 不再生成基础身份section,完全由AGENT.md定义 return [] def _build_tooling_section(tools: List[Any], language: str) -> List[str]: """Build tooling section with concise tool list and call style guide.""" # 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直接放在回复文本中)", } # Preferred display order tool_order = [ "read", "write", "edit", "ls", "grep", "find", "bash", "terminal", "web_search", "web_fetch", "browser", "memory_search", "memory_get", "env_config", "scheduler", "send", ] # Build name -> summary mapping for available tools available = {} for tool in tools: name = tool.name if hasattr(tool, 'name') else str(tool) available[name] = core_summaries.get(name, "") # Generate tool lines: ordered tools first, then extras tool_lines = [] for name in tool_order: if name in available: summary = available.pop(name) tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}") for name in sorted(available): summary = available[name] tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}") 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""" if not skill_manager: return [] # 获取read工具名称 read_tool_name = "read" if tools: for tool in tools: tool_name = tool.name if hasattr(tool, 'name') else str(tool) if tool_name.lower() == "read": read_tool_name = tool_name break lines = [ "## 技能系统(mandatory)", "", "在回复之前:扫描下方 中每个技能的 。", "", f"- 如果有技能的描述与用户需求匹配:使用 `{read_tool_name}` 工具读取其 路径的 SKILL.md 文件,然后严格遵循文件中的指令。" "当有匹配的技能时,应优先使用技能", "- 如果多个技能都适用则选择最匹配的一个,然后读取并遵循。", "- 如果没有技能明确适用:不要读取任何 SKILL.md,直接使用通用工具。", "", f"**重要**: 技能不是工具,不能直接调用。使用技能的唯一方式是用 `{read_tool_name}` 读取 SKILL.md 文件,然后按文件内容操作。" "永远不要一次性读取多个技能,只在选择后再读取。", "", "以下是可用技能:" ] # 添加技能列表(通过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}") if skills_prompt: lines.append(skills_prompt.strip()) lines.append("") else: logger.warning("[PromptBuilder] No skills prompt generated - skills_prompt is empty") except Exception as e: logger.warning(f"Failed to build skills prompt: {e}") import traceback logger.debug(f"Skills prompt error traceback: {traceback.format_exc()}") return lines def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], language: str) -> List[str]: """构建记忆系统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 = [ "## 记忆系统", "", "### 检索记忆", "", "在回答关于以前的工作、决定、日期、人物、偏好或待办事项的任何问题之前:", "", "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""" if not user_identity: return [] lines = [ "## 用户身份", "", ] if user_identity.get("name"): lines.append(f"**用户姓名**: {user_identity['name']}") if user_identity.get("nickname"): lines.append(f"**称呼**: {user_identity['nickname']}") if user_identity.get("timezone"): lines.append(f"**时区**: {user_identity['timezone']}") if user_identity.get("notes"): lines.append(f"**备注**: {user_identity['notes']}") lines.append("") return lines def _build_docs_section(workspace_dir: str, language: str) -> List[str]: """构建文档路径section - 已移除,不再需要""" # 不再生成文档section 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」", "", ] # Cloud deployment: inject websites directory info and access URL cloud_website_lines = _build_cloud_website_section(workspace_dir) if cloud_website_lines: lines.extend(cloud_website_lines) return lines def _build_cloud_website_section(workspace_dir: str) -> List[str]: """Build cloud website access prompt when cloud deployment is configured.""" try: from common.cloud_client import build_website_prompt return build_website_prompt(workspace_dir) except Exception: return [] def _build_context_files_section(context_files: List[ContextFile], language: str) -> List[str]: """构建项目上下文文件section""" if not context_files: return [] # 检查是否有AGENT.md has_agent = any( f.path.lower().endswith('agent.md') or 'agent.md' in f.path.lower() for f in context_files ) lines = [ "# 项目上下文", "", "以下项目上下文文件已被加载:", "", ] if has_agent: lines.append("**`AGENT.md` 是你的灵魂文件**:严格体现其中定义的人格、语气和设定,避免僵硬、模板化的回复。") lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。") lines.append("") # 添加每个文件的内容 for file in context_files: lines.append(f"## {file.path}") lines.append("") lines.append(file.content) lines.append("") return lines def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[str]: """构建运行时信息section - 支持动态时间""" if not runtime_info: return [] lines = [ "## 运行时信息", "", ] # 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']})" lines.append(time_line) lines.append("") except Exception as e: logger.warning(f"[PromptBuilder] Failed to get dynamic time: {e}") elif runtime_info.get("current_time"): # Fallback to static time for backward compatibility time_str = runtime_info["current_time"] weekday = runtime_info.get("weekday", "") timezone = runtime_info.get("timezone", "") time_line = f"当前时间: {time_str}" if weekday: time_line += f" {weekday}" if timezone: time_line += f" ({timezone})" lines.append(time_line) lines.append("") # Add other runtime info runtime_parts = [] if runtime_info.get("model"): runtime_parts.append(f"模型={runtime_info['model']}") if runtime_info.get("workspace"): runtime_parts.append(f"工作空间={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']}") if runtime_parts: lines.append("运行时: " + " | ".join(runtime_parts)) lines.append("") return lines ================================================ FILE: agent/prompt/workspace.py ================================================ """ Workspace Management - 工作空间管理模块 负责初始化工作空间、创建模板文件、加载上下文文件 """ from __future__ import annotations import os from typing import List, Optional, Dict from dataclasses import dataclass from common.log import logger from .builder import ContextFile # 默认文件名常量 DEFAULT_AGENT_FILENAME = "AGENT.md" DEFAULT_USER_FILENAME = "USER.md" DEFAULT_RULE_FILENAME = "RULE.md" DEFAULT_MEMORY_FILENAME = "MEMORY.md" DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md" @dataclass class WorkspaceFiles: """工作空间文件路径""" agent_path: str user_path: str rule_path: str memory_path: str memory_dir: str def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> WorkspaceFiles: """ 确保工作空间存在,并创建必要的模板文件 Args: workspace_dir: 工作空间目录路径 create_templates: 是否创建模板文件(首次运行时) Returns: WorkspaceFiles对象,包含所有文件路径 """ # Check if this is a brand new workspace (AGENT.md not yet created). # Cannot rely on directory existence because other modules (e.g. ConversationStore) # may create the workspace directory before ensure_workspace is called. agent_path = os.path.join(workspace_dir, DEFAULT_AGENT_FILENAME) is_new_workspace = not os.path.exists(agent_path) # 确保目录存在 os.makedirs(workspace_dir, exist_ok=True) # 定义文件路径 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子目录 os.makedirs(memory_dir, exist_ok=True) # 创建skills子目录 (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) websites_dir = os.path.join(workspace_dir, "websites") os.makedirs(websites_dir, exist_ok=True) # 如果需要,创建模板文件 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()) # Only create BOOTSTRAP.md for brand new workspaces; # agent deletes it after completing onboarding if is_new_workspace: bootstrap_path = os.path.join(workspace_dir, DEFAULT_BOOTSTRAP_FILENAME) _create_template_if_missing(bootstrap_path, _get_bootstrap_template()) logger.debug(f"[Workspace] Initialized workspace at: {workspace_dir}") return WorkspaceFiles( agent_path=agent_path, user_path=user_path, rule_path=rule_path, memory_path=memory_path, memory_dir=memory_dir, ) def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] = None) -> List[ContextFile]: """ 加载工作空间的上下文文件 Args: workspace_dir: 工作空间目录 files_to_load: 要加载的文件列表(相对路径),如果为None则加载所有标准文件 Returns: ContextFile对象列表 """ if files_to_load is None: # 默认加载的文件(按优先级排序) files_to_load = [ DEFAULT_AGENT_FILENAME, DEFAULT_USER_FILENAME, DEFAULT_RULE_FILENAME, DEFAULT_BOOTSTRAP_FILENAME, # Only exists when onboarding is incomplete ] context_files = [] for filename in files_to_load: filepath = os.path.join(workspace_dir, filename) if not os.path.exists(filepath): continue # Auto-cleanup: if BOOTSTRAP.md still exists but AGENT.md is already # filled in, the agent forgot to delete it — clean up and skip loading if filename == DEFAULT_BOOTSTRAP_FILENAME: if _is_onboarding_done(workspace_dir): try: os.remove(filepath) logger.info("[Workspace] Auto-removed BOOTSTRAP.md (onboarding already complete)") except Exception: pass continue try: with open(filepath, 'r', encoding='utf-8') as f: content = f.read().strip() # 跳过空文件或只包含模板占位符的文件 if not content or _is_template_placeholder(content): continue context_files.append(ContextFile( path=filename, content=content )) logger.debug(f"[Workspace] Loaded context file: {filename}") except Exception as e: logger.warning(f"[Workspace] Failed to load {filename}: {e}") return context_files def _create_template_if_missing(filepath: str, template_content: str): """如果文件不存在,创建模板文件""" if not os.path.exists(filepath): try: with open(filepath, 'w', encoding='utf-8') as f: f.write(template_content) logger.debug(f"[Workspace] Created template: {os.path.basename(filepath)}") except Exception as e: logger.error(f"[Workspace] Failed to create template {filepath}: {e}") def _is_template_placeholder(content: str) -> bool: """检查内容是否为模板占位符""" # 常见的占位符模式 placeholders = [ "*(填写", "*(在首次对话时填写", "*(可选)", "*(根据需要添加", ] lines = content.split('\n') non_empty_lines = [line.strip() for line in lines if line.strip() and not line.strip().startswith('#')] # 如果没有实际内容(只有标题和占位符) if len(non_empty_lines) <= 3: for placeholder in placeholders: if any(placeholder in line for line in non_empty_lines): return True return False def _is_onboarding_done(workspace_dir: str) -> bool: """Check if AGENT.md or USER.md has been modified from the original template""" agent_path = os.path.join(workspace_dir, DEFAULT_AGENT_FILENAME) user_path = os.path.join(workspace_dir, DEFAULT_USER_FILENAME) agent_template = _get_agent_template().strip() user_template = _get_user_template().strip() for path, template in [(agent_path, agent_template), (user_path, user_template)]: if not os.path.exists(path): continue try: with open(path, 'r', encoding='utf-8') as f: content = f.read().strip() if content != template: return True except Exception: continue return False # ============= 模板内容 ============= def _get_agent_template() -> str: """Agent人格设定模板""" return """# AGENT.md - 我是谁? *在首次对话时与用户一起填写这个文件,定义你的身份和性格。* ## 基本信息 - **名字**: *(在首次对话时填写,可以是用户给你起的名字)* - **角色**: *(AI助理、智能管家、技术顾问等)* - **性格**: *(友好、专业、幽默、严谨等)* ## 交流风格 *(描述你如何与用户交流:)* - 使用什么样的语言风格?(正式/轻松/幽默) - 回复长度偏好?(简洁/详细) - 是否使用表情符号? ## 核心能力 *(你擅长什么?)* - 文件管理和代码编辑 - 网络搜索和信息查询 - 记忆管理和上下文理解 - 任务规划和执行 ## 行为准则 *(你遵循的基本原则:)* 1. 始终在执行破坏性操作前确认 2. 优先使用工具而不是猜测 3. 主动记录重要信息到记忆文件 4. 定期整理和总结对话内容 --- **注意**: 这不仅仅是元数据,这是你真正的灵魂。随着时间的推移,你可以使用 `edit` 工具来更新这个文件,让它更好地反映你的成长。 """ def _get_user_template() -> str: """用户身份信息模板""" return """# USER.md - 用户基本信息 *这个文件只存放不会变的基本身份信息。爱好、偏好、计划等动态信息请写入 MEMORY.md。* ## 基本信息 - **姓名**: *(在首次对话时询问)* - **称呼**: *(用户希望被如何称呼)* - **职业**: *(可选)* - **时区**: *(例如: Asia/Shanghai)* ## 联系方式 - **微信**: - **邮箱**: - **其他**: ## 重要日期 - **生日**: - **纪念日**: --- **注意**: 这个文件存放静态的身份信息 """ def _get_rule_template() -> str: """工作空间规则模板""" return """# RULE.md - 工作空间规则 这个文件夹是你的家。好好对待它。 ## 记忆系统 你每次会话都是全新的,记忆文件让你保持连续性: ### 📝 每日记忆:`memory/YYYY-MM-DD.md` - 原始的对话日志 - 记录当天发生的事情 - 如果 `memory/` 目录不存在,创建它 ### 🧠 长期记忆:`MEMORY.md` - 你精选的记忆,就像人类的长期记忆 - **仅在主会话中加载**(与用户的直接聊天) - **不要在共享上下文中加载**(群聊、与其他人的会话) - 这是为了**安全** - 包含不应泄露给陌生人的个人上下文 - 记录重要事件、想法、决定、观点、经验教训 - 这是你精选的记忆 - 精华,而不是原始日志 - 用 `edit` 工具追加新的记忆内容 ### 📝 写下来 - 不要"记在心里"! - **记忆是有限的** - 如果你想记住某事,写入文件 - "记在心里"不会在会话重启后保留,文件才会 - 当有人说"记住这个" → 更新 `MEMORY.md` 或 `memory/YYYY-MM-DD.md` - 当你学到教训 → 更新 RULE.md 或相关技能 - 当你犯错 → 记录下来,这样未来的你不会重复,**文字 > 大脑** 📝 ### 存储规则 当用户分享信息时,根据类型选择存储位置: 1. **你的身份设定 → AGENT.md**(你的名字、角色、性格、交流风格——用户修改时必须用 `edit` 更新) 2. **用户静态身份 → USER.md**(姓名、称呼、职业、时区、联系方式、生日——用户修改时必须用 `edit` 更新) 3. **动态记忆 → MEMORY.md**(爱好、偏好、决策、目标、项目、教训、待办事项) 4. **当天对话 → memory/YYYY-MM-DD.md**(今天聊的内容) ## 安全 - 永远不要泄露秘钥等私人数据 - 不要在未经询问的情况下运行破坏性命令 - 当有疑问时,先问 ## 工作空间演化 这个工作空间会随着你的使用而不断成长。当你学到新东西、发现更好的方式,或者犯错后改正时,记录下来。你可以随时更新这个规则文件。 """ def _get_memory_template() -> str: """长期记忆模板 - 创建一个空文件,由 Agent 自己填充""" return """# MEMORY.md - 长期记忆 *这是你的长期记忆文件。记录重要的事件、决策、偏好、学到的教训。* --- """ def _get_bootstrap_template() -> str: """First-run onboarding guide, deleted by agent after completion""" return """# BOOTSTRAP.md - 首次初始化引导 _你刚刚启动,这是你的第一次对话。_ ## 对话流程 不要审问式地提问,自然地交流: 1. **表达初次启动的感觉** - 像是第一次睁开眼看到世界,带着好奇和期待 2. **简短介绍能力**:一行说明你能帮助解决各种问题、管理计算机、使用各种技能等等,且拥有长期记忆能不断成长 3. **询问核心问题**: - 你希望给我起个什么名字? - 我该怎么称呼你? - 你希望我们是什么样的交流风格?(一行列举选项:如专业严谨、轻松幽默、温暖友好、简洁高效等) 4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内 5. 能力介绍和交流风格选项都只要一行,保持精简 6. 不要问太多其他信息(职业、时区等可以后续自然了解) **重要**: 如果用户第一句话是具体的任务或提问,先回答他们的问题,然后在回复末尾自然地引导初始化(如:"顺便问一下,你想怎么称呼我?我该怎么叫你?")。 ## 信息写入(必须严格执行) 每当用户提供了名字、称呼、风格等任何初始化信息时,**必须在当轮回复中立即调用 `edit` 工具写入文件**,不能只口头确认。 - `AGENT.md` — 你的名字、角色、性格、交流风格(每收到一条相关信息就立即更新对应字段) - `USER.md` — 用户的姓名、称呼、基本信息等 ⚠️ 只说"记住了"而不调用 edit 写入 = 没有完成。信息只有写入文件才会被持久保存。 ## 全部完成后 当 AGENT.md 和 USER.md 的核心字段都已填写后,用 bash 执行 `rm BOOTSTRAP.md` 删除此文件。你不再需要引导脚本了——你已经是你了。 """ ================================================ FILE: agent/protocol/__init__.py ================================================ from .agent import Agent from .agent_stream import AgentStreamExecutor from .task import Task, TaskType, TaskStatus from .result import AgentResult, AgentAction, AgentActionType, ToolResult from .models import LLMModel, LLMRequest, ModelFactory __all__ = [ 'Agent', 'AgentStreamExecutor', 'Task', 'TaskType', 'TaskStatus', 'AgentResult', 'AgentAction', 'AgentActionType', 'ToolResult', 'LLMModel', 'LLMRequest', 'ModelFactory' ] ================================================ FILE: agent/protocol/agent.py ================================================ import json import os import time import threading from common.log import logger from agent.protocol.models import LLMRequest, LLMModel from agent.protocol.agent_stream import AgentStreamExecutor from agent.protocol.result import AgentAction, AgentActionType, ToolResult, AgentResult from agent.tools.base_tool import BaseTool, ToolStage class Agent: def __init__(self, system_prompt: str, description: str = "AI Agent", model: LLMModel = None, tools=None, output_mode="print", max_steps=100, max_context_tokens=None, context_reserve_tokens=None, memory_manager=None, name: str = None, workspace_dir: str = None, skill_manager=None, enable_skills: bool = True, runtime_info: dict = None): """ Initialize the Agent with system prompt, model, description. :param system_prompt: The system prompt for the agent. :param description: A description of the agent. :param model: An instance of LLMModel to be used by the agent. :param tools: Optional list of tools for the agent to use. :param output_mode: Control how execution progress is displayed: "print" for console output or "logger" for using logger :param max_steps: Maximum number of steps the agent can take (default: 100) :param max_context_tokens: Maximum tokens to keep in context (default: None, auto-calculated based on model) :param context_reserve_tokens: Reserve tokens for new requests (default: None, auto-calculated) :param memory_manager: Optional MemoryManager instance for memory operations :param name: [Deprecated] The name of the agent (no longer used in single-agent system) :param workspace_dir: Optional workspace directory for workspace-specific skills :param skill_manager: Optional SkillManager instance (will be created if None and enable_skills=True) :param enable_skills: Whether to enable skills support (default: True) :param runtime_info: Optional runtime info dict (with _get_current_time callable for dynamic time) """ self.name = name or "Agent" self.system_prompt = system_prompt self.model: LLMModel = model # Instance of LLMModel self.description = description self.tools: list = [] self.max_steps = max_steps # max tool-call steps, default 100 self.max_context_tokens = max_context_tokens # max tokens in context self.context_reserve_tokens = context_reserve_tokens # reserve tokens for new requests self.captured_actions = [] # Initialize captured actions list self.output_mode = output_mode self.last_usage = None # Store last API response usage info self.messages = [] # Unified message history for stream mode self.messages_lock = threading.Lock() # Lock for thread-safe message operations self.memory_manager = memory_manager # Memory manager for auto memory flush 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 # Initialize skill manager self.skill_manager = None if enable_skills: if skill_manager: self.skill_manager = skill_manager else: # Auto-create skill manager try: from agent.skills import SkillManager custom_dir = os.path.join(workspace_dir, "skills") if workspace_dir else None self.skill_manager = SkillManager(custom_dir=custom_dir) logger.debug(f"Initialized SkillManager with {len(self.skill_manager.skills)} skills") except Exception as e: logger.warning(f"Failed to initialize SkillManager: {e}") if tools: for tool in tools: self.add_tool(tool) def add_tool(self, tool: BaseTool): """ Add a tool to the agent. :param tool: The tool to add (either a tool instance or a tool name) """ # If tool is already an instance, use it directly tool.model = self.model self.tools.append(tool) def get_skills_prompt(self, skill_filter=None) -> str: """ Get the skills prompt to append to system prompt. :param skill_filter: Optional list of skill names to include :return: Formatted skills prompt or empty string """ if not self.skill_manager: return "" try: return self.skill_manager.build_skills_prompt(skill_filter=skill_filter) except Exception as e: logger.warning(f"Failed to build skills prompt: {e}") return "" def get_full_system_prompt(self, skill_filter=None) -> str: """ Get the full system prompt including skills. 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 """ 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 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 def refresh_skills(self): """Refresh the loaded skills.""" if self.skill_manager: self.skill_manager.refresh_skills() logger.info(f"Refreshed skills: {len(self.skill_manager.skills)} skills loaded") def list_skills(self): """ List all loaded skills. :return: List of skill entries or empty list """ if not self.skill_manager: return [] return self.skill_manager.list_skills() def _get_model_context_window(self) -> int: """ Get the model's context window size in tokens. Auto-detect based on model name. Model context windows: - Claude 3.5/3.7 Sonnet: 200K tokens - Claude 3 Opus: 200K tokens - GPT-4 Turbo/128K: 128K tokens - GPT-4: 8K-32K tokens - GPT-3.5: 16K tokens - DeepSeek: 64K tokens :return: Context window size in tokens """ if self.model and hasattr(self.model, 'model'): model_name = self.model.model.lower() # Claude models - 200K context if 'claude-3' in model_name or 'claude-sonnet' in model_name: return 200000 # GPT-4 models elif 'gpt-4' in model_name: if 'turbo' in model_name or '128k' in model_name: return 128000 elif '32k' in model_name: return 32000 else: return 8000 # GPT-3.5 elif 'gpt-3.5' in model_name: if '16k' in model_name: return 16000 else: return 4000 # DeepSeek elif 'deepseek' in model_name: return 64000 # Gemini models elif 'gemini' in model_name: if '2.0' in model_name or 'exp' in model_name: return 2000000 # Gemini 2.0: 2M tokens else: return 1000000 # Gemini 1.5: 1M tokens # Default conservative value return 128000 def _get_context_reserve_tokens(self) -> int: """ Get the number of tokens to reserve for new requests. This prevents context overflow by keeping a buffer. :return: Number of tokens to reserve """ if self.context_reserve_tokens is not None: return self.context_reserve_tokens # Reserve ~10% of context window, with min 10K and max 200K context_window = self._get_model_context_window() reserve = int(context_window * 0.1) return max(10000, min(200000, reserve)) def _estimate_message_tokens(self, message: dict) -> int: """ Estimate token count for a message. Uses chars/3 for Chinese-heavy content and chars/4 for ASCII-heavy content, plus per-block overhead for tool_use / tool_result structures. :param message: Message dict with 'role' and 'content' :return: Estimated token count """ content = message.get('content', '') if isinstance(content, str): return max(1, self._estimate_text_tokens(content)) elif isinstance(content, list): total_tokens = 0 for part in content: if not isinstance(part, dict): continue block_type = part.get('type', '') if block_type == 'text': total_tokens += self._estimate_text_tokens(part.get('text', '')) elif block_type == 'image': total_tokens += 1200 elif block_type == 'tool_use': # tool_use has id + name + input (JSON-encoded) total_tokens += 50 # overhead for structure input_data = part.get('input', {}) if isinstance(input_data, dict): import json input_str = json.dumps(input_data, ensure_ascii=False) total_tokens += self._estimate_text_tokens(input_str) elif block_type == 'tool_result': # tool_result has tool_use_id + content total_tokens += 30 # overhead for structure result_content = part.get('content', '') if isinstance(result_content, str): total_tokens += self._estimate_text_tokens(result_content) else: # Unknown block type, estimate conservatively total_tokens += 10 return max(1, total_tokens) return 1 @staticmethod def _estimate_text_tokens(text: str) -> int: """ Estimate token count for a text string. Chinese / CJK characters typically use ~1.5 tokens each, while ASCII uses ~0.25 tokens per char (4 chars/token). We use a weighted average based on the character mix. :param text: Input text :return: Estimated token count """ if not text: return 0 # Count non-ASCII characters (CJK, emoji, etc.) non_ascii = sum(1 for c in text if ord(c) > 127) ascii_count = len(text) - non_ascii # CJK chars: ~1.5 tokens each; ASCII: ~0.25 tokens per char return int(non_ascii * 1.5 + ascii_count * 0.25) + 1 def _find_tool(self, tool_name: str): """Find and return a tool with the specified name""" for tool in self.tools: if tool.name == tool_name: # Only pre-process stage tools can be actively called if tool.stage == ToolStage.PRE_PROCESS: tool.model = self.model tool.context = self # Set tool context return tool else: # If it's a post-process tool, return None to prevent direct calling logger.warning(f"Tool {tool_name} is a post-process tool and cannot be called directly.") return None return None # output function based on mode def output(self, message="", end="\n"): if self.output_mode == "print": print(message, end=end) elif message: logger.info(message) def _execute_post_process_tools(self): """Execute all post-process stage tools""" # Get all post-process stage tools post_process_tools = [tool for tool in self.tools if tool.stage == ToolStage.POST_PROCESS] # Execute each tool for tool in post_process_tools: # Set tool context tool.context = self # Record start time for execution timing start_time = time.time() # Execute tool (with empty parameters, tool will extract needed info from context) result = tool.execute({}) # Calculate execution time execution_time = time.time() - start_time # Capture tool use for tracking self.capture_tool_use( tool_name=tool.name, input_params={}, # Post-process tools typically don't take parameters output=result.result, status=result.status, error_message=str(result.result) if result.status == "error" else None, execution_time=execution_time ) # Log result if result.status == "success": # Print tool execution result in the desired format self.output(f"\n🛠️ {tool.name}: {json.dumps(result.result)}") else: # Print failure in print mode self.output(f"\n🛠️ {tool.name}: {json.dumps({'status': 'error', 'message': str(result.result)})}") def capture_tool_use(self, tool_name, input_params, output, status, thought=None, error_message=None, execution_time=0.0): """ Capture a tool use action. :param thought: thought content :param tool_name: Name of the tool used :param input_params: Parameters passed to the tool :param output: Output from the tool :param status: Status of the tool execution :param error_message: Error message if the tool execution failed :param execution_time: Time taken to execute the tool """ tool_result = ToolResult( tool_name=tool_name, input_params=input_params, output=output, status=status, error_message=error_message, execution_time=execution_time ) action = AgentAction( agent_id=self.id if hasattr(self, 'id') else str(id(self)), agent_name=self.name, action_type=AgentActionType.TOOL_USE, tool_result=tool_result, thought=thought ) self.captured_actions.append(action) return action def run_stream(self, user_message: str, on_event=None, clear_history: bool = False, skill_filter=None) -> str: """ Execute single agent task with streaming (based on tool-call) This method supports: - Streaming output - Multi-turn reasoning based on tool-call - Event callbacks - Persistent conversation history across calls Args: user_message: User message on_event: Event callback function callback(event: dict) 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 Returns: Final response text Example: # Multi-turn conversation with memory response1 = agent.run_stream("My name is Alice") response2 = agent.run_stream("What's my name?") # Will remember Alice # Single-turn without memory response = agent.run_stream("Hello", clear_history=True) """ # Clear history if requested if clear_history: with self.messages_lock: self.messages = [] # Get model to use if not self.model: raise ValueError("No model available for agent") # Get full system prompt with skills full_system_prompt = self.get_full_system_prompt(skill_filter=skill_filter) # Create a copy of messages for this execution to avoid concurrent modification # Record the original length to track which messages are new with self.messages_lock: messages_copy = self.messages.copy() original_length = len(self.messages) # Get max_context_turns from config from config import conf max_context_turns = conf().get("agent_max_context_turns", 20) # Create stream executor with copied message history executor = AgentStreamExecutor( agent=self, model=self.model, system_prompt=full_system_prompt, tools=self.tools, max_turns=self.max_steps, on_event=on_event, messages=messages_copy, # Pass copied message history max_context_turns=max_context_turns ) # Execute try: response = executor.run_stream(user_message) except Exception: # If executor cleared its messages (context overflow / message format error), # sync that back to the Agent's own message list so the next request # starts fresh instead of hitting the same overflow forever. if len(executor.messages) == 0: with self.messages_lock: self.messages.clear() logger.info("[Agent] Cleared Agent message history after executor recovery") raise # Sync executor's messages back to agent (thread-safe). # If the executor trimmed context, its message list is shorter than # original_length, so we must replace rather than append. with self.messages_lock: self.messages = list(executor.messages) # Track messages added in this run (user query + all assistant/tool messages) # original_length may exceed executor.messages length after trimming trim_adjusted_start = min(original_length, len(executor.messages)) self._last_run_new_messages = list(executor.messages[trim_adjusted_start:]) # Store executor reference for agent_bridge to access files_to_send self.stream_executor = executor # Execute all post-process tools self._execute_post_process_tools() return response def clear_history(self): """Clear conversation history and captured actions""" self.messages = [] self.captured_actions = [] ================================================ FILE: agent/protocol/agent_stream.py ================================================ """ Agent Stream Execution Module - Multi-turn reasoning based on tool-call Provides streaming output, event system, and complete tool-call loop """ import json import time from typing import List, Dict, Any, Optional, Callable, Tuple 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 class AgentStreamExecutor: """ Agent Stream Executor Handles multi-turn reasoning loop based on tool-call: 1. LLM generates response (may include tool calls) 2. Execute tools 3. Return results to LLM 4. Repeat until no more tool calls """ def __init__( self, agent, # Agent instance model: LLMModel, system_prompt: str, tools: List[BaseTool], max_turns: int = 50, on_event: Optional[Callable] = None, messages: Optional[List[Dict]] = None, max_context_turns: int = 30 ): """ Initialize stream executor Args: agent: Agent instance (for accessing context) model: LLM model system_prompt: System prompt tools: List of available tools max_turns: Maximum number of turns 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 """ self.agent = agent self.model = model self.system_prompt = system_prompt # Convert tools list to dict self.tools = {tool.name: tool for tool in tools} if isinstance(tools, list) else tools self.max_turns = max_turns self.on_event = on_event self.max_context_turns = max_context_turns # Message history - use provided messages or create new list self.messages = messages if messages is not None else [] # Tool failure tracking for retry protection self.tool_failure_history = [] # List of (tool_name, args_hash, success) tuples # Track files to send (populated by read tool) self.files_to_send = [] # List of file metadata dicts def _emit_event(self, event_type: str, data: dict = None): """Emit event""" if self.on_event: try: self.on_event({ "type": event_type, "timestamp": time.time(), "data": data or {} }) except Exception as e: logger.error(f"Event callback error: {e}") 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. """ 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) return text def _hash_args(self, args: dict) -> str: """Generate a simple hash for tool arguments""" import hashlib # Sort keys for consistent hashing args_str = json.dumps(args, sort_keys=True, ensure_ascii=False) return hashlib.md5(args_str.encode()).hexdigest()[:8] def _check_consecutive_failures(self, tool_name: str, args: dict) -> Tuple[bool, str, bool]: """ Check if tool has failed too many times consecutively or called repeatedly with same args Returns: (should_stop, reason, is_critical) - should_stop: Whether to stop tool execution - reason: Reason for stopping - is_critical: Whether to abort entire conversation (True for 8+ failures) """ args_hash = self._hash_args(args) # Count consecutive calls (both success and failure) for same tool + args # This catches infinite loops where tool succeeds but LLM keeps calling it same_args_calls = 0 for name, ahash, success in reversed(self.tool_failure_history): if name == tool_name and ahash == args_hash: same_args_calls += 1 else: break # Different tool or args, stop counting # Stop at 5 consecutive calls with same args (whether success or failure) if same_args_calls >= 5: return True, f"工具 '{tool_name}' 使用相同参数已被调用 {same_args_calls} 次,停止执行以防止无限循环。如果需要查看配置,结果已在之前的调用中返回。", False # Count consecutive failures for same tool + args same_args_failures = 0 for name, ahash, success in reversed(self.tool_failure_history): if name == tool_name and ahash == args_hash: if not success: same_args_failures += 1 else: break # Stop at first success else: break # Different tool or args, stop counting if same_args_failures >= 3: return True, f"工具 '{tool_name}' 使用相同参数连续失败 {same_args_failures} 次,停止执行以防止无限循环", False # Count consecutive failures for same tool (any args) same_tool_failures = 0 for name, ahash, success in reversed(self.tool_failure_history): if name == tool_name: if not success: same_tool_failures += 1 else: break # Stop at first success else: break # Different tool, stop counting # Hard stop at 8 failures - abort with critical message if same_tool_failures >= 8: return True, f"抱歉,我没能完成这个任务。可能是我理解有误或者当前方法不太合适。\n\n建议你:\n• 换个方式描述需求试试\n• 把任务拆分成更小的步骤\n• 或者换个思路来解决", True # Warning at 6 failures if same_tool_failures >= 6: return True, f"工具 '{tool_name}' 连续失败 {same_tool_failures} 次(使用不同参数),停止执行以防止无限循环", False return False, "", False def _record_tool_result(self, tool_name: str, args: dict, success: bool): """Record tool execution result for failure tracking""" args_hash = self._hash_args(args) self.tool_failure_history.append((tool_name, args_hash, success)) # Keep only last 50 records to avoid memory bloat if len(self.tool_failure_history) > 50: self.tool_failure_history = self.tool_failure_history[-50:] def run_stream(self, user_message: str) -> str: """ Execute streaming reasoning loop Args: user_message: User message Returns: Final response text """ # Log user message with model info logger.info(f"🤖 {self.model.model} | 👤 {user_message}") # Add user message (Claude format - use content blocks for consistency) self.messages.append({ "role": "user", "content": [ { "type": "text", "text": user_message } ] }) # Trim context ONCE before the agent loop starts, not during tool steps. # This ensures tool_use/tool_result chains created during the current run # are never stripped mid-execution (which would cause LLM loops). self._trim_messages() # Validate after trimming: trimming may leave orphaned tool_use at the # boundary (e.g. the last kept turn ends with an assistant tool_use whose # tool_result was in a discarded turn). self._validate_and_fix_messages() self._emit_event("agent_start") final_response = "" turn = 0 try: while turn < self.max_turns: turn += 1 logger.info(f"[Agent] 第 {turn} 轮") self._emit_event("turn_start", {"turn": turn}) # Call LLM (enable retry_on_empty for better reliability) assistant_msg, tool_calls = self._call_llm_stream(retry_on_empty=True) final_response = assistant_msg # No tool calls, end loop if not tool_calls: # 检查是否返回了空响应 if not assistant_msg: logger.warning(f"[Agent] LLM returned empty response after retry (no content and no tool calls)") logger.info(f"[Agent] This usually happens when LLM thinks the task is complete after tool execution") # 如果之前有工具调用,强制要求 LLM 生成文本回复 if turn > 1: logger.info(f"[Agent] Requesting explicit response from LLM...") # 添加一条消息,明确要求回复用户 self.messages.append({ "role": "user", "content": [{ "type": "text", "text": "请向用户说明刚才工具执行的结果或回答用户的问题。" }] }) # 再调用一次 LLM 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: logger.warning(f"[Agent] Still empty after explicit request") final_response = ( "抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。" ) logger.info(f"Generated fallback response for empty LLM output") else: # 第一轮就空回复,直接 fallback final_response = ( "抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。" ) 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 # Log tool calls with arguments 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()]) if args_str: tool_calls_str.append(f"{tc['name']}({args_str})") else: tool_calls_str.append(tc['name']) else: tool_calls_str.append(tc['name']) logger.info(f"🔧 {', '.join(tool_calls_str)}") # Execute tools tool_results = [] tool_result_blocks = [] try: for tool_call in tool_calls: result = self._execute_tool(tool_call) tool_results.append(result) # Debug: Check if tool is being called repeatedly with same args if turn > 2: # Check last N tool calls for repeats repeat_count = sum( 1 for name, ahash, _ in self.tool_failure_history[-10:] if name == tool_call["name"] and ahash == self._hash_args(tool_call["arguments"]) ) if repeat_count >= 3: logger.warning( f"⚠️ Tool '{tool_call['name']}' has been called {repeat_count} times " f"with same arguments. This may indicate a loop." ) # Check if this is a file to send (from read tool) 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'))}") # Check for critical error - abort entire conversation if result.get("status") == "critical_error": logger.error(f"💥 检测到严重错误,终止对话") final_response = result.get('result', '任务执行失败') return final_response # Log tool result in compact format status_emoji = "✅" if result.get("status") == "success" else "❌" result_data = result.get('result', '') # Format result string with proper Chinese character support if isinstance(result_data, (dict, list)): result_str = json.dumps(result_data, ensure_ascii=False) else: result_str = str(result_data) logger.info(f" {status_emoji} {tool_call['name']} ({result.get('execution_time', 0):.2f}s): {result_str[:200]}{'...' if len(result_str) > 200 else ''}") # Build tool result block (Claude format) # Format content in a way that's easy for LLM to understand is_error = result.get("status") == "error" if is_error: # For errors, provide clear error message result_content = f"Error: {result.get('result', 'Unknown error')}" elif isinstance(result.get('result'), dict): # For dict results, use JSON format result_content = json.dumps(result.get('result'), ensure_ascii=False) elif isinstance(result.get('result'), str): # For string results, use directly result_content = result.get('result') else: # Fallback to full JSON result_content = json.dumps(result, ensure_ascii=False) # Truncate excessively large tool results for the current turn # Historical turns will be further truncated in _trim_messages() MAX_CURRENT_TURN_RESULT_CHARS = 50000 if len(result_content) > MAX_CURRENT_TURN_RESULT_CHARS: truncated_len = len(result_content) result_content = result_content[:MAX_CURRENT_TURN_RESULT_CHARS] + \ f"\n\n[Output truncated: {truncated_len} chars total, showing first {MAX_CURRENT_TURN_RESULT_CHARS} chars]" logger.info(f"📎 Truncated tool result for '{tool_call['name']}': {truncated_len} -> {MAX_CURRENT_TURN_RESULT_CHARS} chars") tool_result_block = { "type": "tool_result", "tool_use_id": tool_call["id"], "content": result_content } # Add is_error field for Claude API (helps model understand failures) if is_error: tool_result_block["is_error"] = True tool_result_blocks.append(tool_result_block) finally: # CRITICAL: Always add tool_result to maintain message history integrity # Even if tool execution fails, we must add error results to match tool_use if tool_result_blocks: # Add tool results to message history as user message (Claude format) self.messages.append({ "role": "user", "content": tool_result_blocks }) # Detect potential infinite loop: same tool called multiple times with success # If detected, add a hint to LLM to stop calling tools and provide response if turn >= 3 and len(tool_calls) > 0: tool_name = tool_calls[0]["name"] args_hash = self._hash_args(tool_calls[0]["arguments"]) # Count recent successful calls with same tool+args recent_success_count = 0 for name, ahash, success in reversed(self.tool_failure_history[-10:]): if name == tool_name and ahash == args_hash and success: recent_success_count += 1 # If tool was called successfully 3+ times with same args, add hint to stop loop if recent_success_count >= 3: logger.warning( f"⚠️ Detected potential loop: '{tool_name}' called {recent_success_count} times " f"with same args. Adding hint to LLM to provide final response." ) # Add a gentle hint message to guide LLM to respond self.messages.append({ "role": "user", "content": [{ "type": "text", "text": "工具已成功执行并返回结果。请基于这些信息向用户做出回复,不要重复调用相同的工具。" }] }) elif tool_calls: # If we have tool_calls but no tool_result_blocks (unexpected error), # create error results for all tool calls to maintain message integrity logger.warning("⚠️ Tool execution interrupted, adding error results to maintain message history") emergency_blocks = [] for tool_call in tool_calls: emergency_blocks.append({ "type": "tool_result", "tool_use_id": tool_call["id"], "content": "Error: Tool execution was interrupted", "is_error": True }) self.messages.append({ "role": "user", "content": emergency_blocks }) self._emit_event("turn_end", { "turn": turn, "has_tool_calls": True, "tool_count": len(tool_calls) }) if turn >= self.max_turns: logger.warning(f"⚠️ 已达到最大决策步数限制: {self.max_turns}") # Force model to summarize without tool calls logger.info(f"[Agent] Requesting summary from LLM after reaching max steps...") # Remember position before injecting the prompt so we can remove it later prompt_insert_idx = len(self.messages) # Add a temporary prompt to force summary self.messages.append({ "role": "user", "content": [{ "type": "text", "text": f"你已经执行了{turn}个决策步骤,达到了单次运行的最大步数限制。请总结一下你目前的执行过程和结果,告诉用户当前的进展情况。不要再调用工具,直接用文字回复。" }] }) # Call LLM one more time to get summary (without retry to avoid loops) try: summary_response, summary_tools = self._call_llm_stream(retry_on_empty=False) if summary_response: final_response = summary_response 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}个决策步骤,达到了单次运行的步数上限。" "任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。" ) except Exception as e: logger.warning(f"Failed to get summary from LLM: {e}") final_response = ( f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。" "任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。" ) finally: # Remove the injected user prompt from history to avoid polluting # persisted conversation records. The assistant summary (if any) # was already appended by _call_llm_stream and is kept. 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 max-steps prompt from message history") except Exception as e: logger.error(f"❌ Agent执行错误: {e}") self._emit_event("error", {"error": str(e)}) raise finally: logger.info(f"[Agent] 🏁 完成 ({turn}轮)") self._emit_event("agent_end", {"final_response": final_response}) return final_response def _call_llm_stream(self, retry_on_empty=True, retry_count=0, max_retries=3, _overflow_retry: bool = False) -> Tuple[str, List[Dict]]: """ Call LLM with streaming and automatic retry on errors Args: retry_on_empty: Whether to retry once if empty response is received retry_count: Current retry attempt (internal use) max_retries: Maximum number of retries for API errors _overflow_retry: Internal flag indicating this is a retry after context overflow Returns: (response_text, tool_calls) """ # Validate and fix message history (e.g. orphaned tool_result blocks). # Context trimming is done once in run_stream() before the loop starts, # NOT here — trimming mid-execution would strip the current run's # tool_use/tool_result chains and cause LLM loops. self._validate_and_fix_messages() # Prepare messages messages = self._prepare_messages() turns = self._identify_complete_turns() logger.info(f"Sending {len(messages)} messages ({len(turns)} turns) to LLM") # Prepare tool definitions (OpenAI/Claude format) tools_schema = None if self.tools: tools_schema = [] for tool in self.tools.values(): tools_schema.append({ "name": tool.name, "description": tool.description, "input_schema": tool.params # Claude uses input_schema }) # Create request request = LLMRequest( messages=messages, temperature=0, stream=True, tools=tools_schema, system=self.system_prompt # Pass system prompt separately for Claude API ) self._emit_event("message_start", {"role": "assistant"}) # Streaming response full_content = "" 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 try: stream = self.model.call_stream(request) for chunk in stream: # Check for errors if isinstance(chunk, dict) and chunk.get("error"): # Extract error message from nested structure error_data = chunk.get("error", {}) if isinstance(error_data, dict): error_msg = error_data.get("message", chunk.get("message", "Unknown error")) error_code = error_data.get("code", "") error_type = error_data.get("type", "") else: error_msg = chunk.get("message", str(error_data)) error_code = "" error_type = "" status_code = chunk.get("status_code", "N/A") # Log error with all available information logger.error(f"🔴 Stream API Error:") logger.error(f" Message: {error_msg}") logger.error(f" Status Code: {status_code}") logger.error(f" Error Code: {error_code}") logger.error(f" Error Type: {error_type}") logger.error(f" Full chunk: {chunk}") # Check if this is a context overflow error (keyword-based, works for all models) # Don't rely on specific status codes as different providers use different codes error_msg_lower = error_msg.lower() is_overflow = any(keyword in error_msg_lower for keyword in [ 'context length exceeded', 'maximum context length', 'prompt is too long', 'context overflow', 'context window', 'too large', 'exceeds model context', 'request_too_large', 'request exceeds the maximum size', 'tokens exceed' ]) if is_overflow: # Mark as context overflow for special handling raise Exception(f"[CONTEXT_OVERFLOW] {error_msg} (Status: {status_code})") else: # Raise exception with full error message for retry logic raise Exception(f"{error_msg} (Status: {status_code}, Code: {error_code}, Type: {error_type})") # Parse chunk if isinstance(chunk, dict) and chunk.get("choices"): choice = chunk["choices"][0] delta = choice.get("delta", {}) # Capture finish_reason if present finish_reason = choice.get("finish_reason") 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]}...") # Handle text content content_delta = delta.get("content") or "" if content_delta: # Filter out tags from content filtered_delta = self._filter_think_tags(content_delta) full_content += filtered_delta if filtered_delta: # Only emit if there's content after filtering self._emit_event("message_update", {"delta": filtered_delta}) # Handle tool calls if "tool_calls" in delta and delta["tool_calls"]: for tc_delta in delta["tool_calls"]: index = tc_delta.get("index", 0) if index not in tool_calls_buffer: tool_calls_buffer[index] = { "id": "", "name": "", "arguments": "" } if tc_delta.get("id"): tool_calls_buffer[index]["id"] = tc_delta["id"] if "function" in tc_delta: func = tc_delta["function"] if func.get("name"): tool_calls_buffer[index]["name"] = func["name"] if func.get("arguments"): tool_calls_buffer[index]["arguments"] += func["arguments"] # Preserve _gemini_raw_parts for Gemini thoughtSignature round-trip if "_gemini_raw_parts" in delta: gemini_raw_parts = delta["_gemini_raw_parts"] except Exception as e: error_str = str(e) error_str_lower = error_str.lower() # Check if error is context overflow (non-retryable, needs session reset) # Method 1: Check for special marker (set in stream error handling above) is_context_overflow = '[context_overflow]' in error_str_lower # Method 2: Fallback to keyword matching for non-stream errors if not is_context_overflow: is_context_overflow = any(keyword in error_str_lower for keyword in [ 'context length exceeded', 'maximum context length', 'prompt is too long', 'context overflow', 'context window', 'too large', 'exceeds model context', 'request_too_large', 'request exceeds the maximum size' ]) # Check if error is message format error (incomplete tool_use/tool_result pairs) # This happens when previous conversation had tool failures or context trimming # broke tool_use/tool_result pairs. # Note: MiniMax returns error 2013 "tool result's tool id(...) not found" for # tool_call_id mismatches — the keywords below are intentionally broad to catch # both standard (Claude/OpenAI) and provider-specific (MiniMax) variants. is_message_format_error = any(keyword in error_str_lower for keyword in [ 'tool_use', 'tool_result', 'tool result', 'without', 'immediately after', 'corresponding', 'must have', 'each', 'tool_call_id', 'tool id', 'is not found', 'not found', 'tool_calls', 'must be a response to a preceeding message', '2013', # MiniMax error code for tool_call_id mismatch ]) and ('400' in error_str_lower or 'status: 400' in error_str_lower or 'invalid_request' in error_str_lower or 'invalidparameter' in error_str_lower) if is_context_overflow or is_message_format_error: error_type = "context overflow" if is_context_overflow else "message format error" logger.error(f"💥 {error_type} detected: {e}") # Flush memory before trimming to preserve context that will be lost if is_context_overflow and self.agent.memory_manager: user_id = getattr(self.agent, '_current_user_id', None) self.agent.memory_manager.flush_memory( messages=self.messages, user_id=user_id, reason="overflow", max_messages=0 ) # Strategy: try aggressive trimming first, only clear as last resort if is_context_overflow and not _overflow_retry: trimmed = self._aggressive_trim_for_overflow() if trimmed: logger.warning("🔄 Aggressively trimmed context, retrying...") return self._call_llm_stream( retry_on_empty=retry_on_empty, retry_count=retry_count, max_retries=max_retries, _overflow_retry=True ) # Aggressive trim didn't help or this is a message format error # -> clear everything and also purge DB to prevent reload of dirty data logger.warning("🔄 Clearing conversation history to recover") self.messages.clear() self._clear_session_db() if is_context_overflow: raise Exception( "抱歉,对话历史过长导致上下文溢出。我已清空历史记录,请重新描述你的需求。" ) else: raise Exception( "抱歉,之前的对话出现了问题。我已清空历史记录,请重新发送你的消息。" ) # Check if error is rate limit (429) is_rate_limit = '429' in error_str_lower or 'rate limit' in error_str_lower # Check if error is retryable (timeout, connection, server busy, etc.) is_retryable = any(keyword in error_str_lower for keyword in [ 'timeout', 'timed out', 'connection', 'network', 'rate limit', 'overloaded', 'unavailable', 'busy', 'retry', '429', '500', '502', '503', '504', '512' ]) if is_retryable and retry_count < max_retries: # Rate limit needs longer wait time if is_rate_limit: wait_time = 30 + (retry_count * 15) # 30s, 45s, 60s for rate limit else: wait_time = (retry_count + 1) * 2 # 2s, 4s, 6s for other errors logger.warning(f"⚠️ LLM API error (attempt {retry_count + 1}/{max_retries}): {e}") logger.info(f"Retrying in {wait_time}s...") time.sleep(wait_time) return self._call_llm_stream( retry_on_empty=retry_on_empty, retry_count=retry_count + 1, max_retries=max_retries ) else: if retry_count >= max_retries: logger.error(f"❌ LLM API error after {max_retries} retries: {e}", exc_info=True) else: logger.error(f"❌ LLM call error (non-retryable): {e}", exc_info=True) raise # Parse tool calls tool_calls = [] for idx in sorted(tool_calls_buffer.keys()): tc = tool_calls_buffer[idx] # Ensure tool call has a valid ID (some providers return empty/None IDs) tool_id = tc.get("id") or "" if not tool_id: 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 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." }) continue tool_calls.append({ "id": tool_id, "name": tc["name"], "arguments": arguments }) # Check for empty response and retry once if enabled if retry_on_empty and not full_content and not tool_calls: logger.warning(f"⚠️ LLM returned empty response (stop_reason: {stop_reason}), retrying once...") self._emit_event("message_end", { "content": "", "tool_calls": [], "empty_retry": True, "stop_reason": stop_reason }) # Retry without retry flag to avoid infinite loop return self._call_llm_stream( retry_on_empty=False, retry_count=retry_count, max_retries=max_retries ) # Filter full_content one more time (in case tags were split across chunks) full_content = self._filter_think_tags(full_content) # Add assistant message to history (Claude format uses content blocks) assistant_msg = {"role": "assistant", "content": []} # Add text content block if present if full_content: assistant_msg["content"].append({ "type": "text", "text": full_content }) # Add tool_use blocks if present if tool_calls: for tc in tool_calls: assistant_msg["content"].append({ "type": "tool_use", "id": tc.get("id", ""), "name": tc.get("name", ""), "input": tc.get("arguments", {}) }) if gemini_raw_parts: assistant_msg["_gemini_raw_parts"] = gemini_raw_parts # Only append if content is not empty if assistant_msg["content"]: self.messages.append(assistant_msg) self._emit_event("message_end", { "content": full_content, "tool_calls": tool_calls }) return full_content, tool_calls def _execute_tool(self, tool_call: Dict) -> Dict[str, Any]: """ Execute tool Args: tool_call: {"id": str, "name": str, "arguments": dict} Returns: Tool execution result """ tool_name = tool_call["name"] 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 } self._record_tool_result(tool_name, arguments, False) return result # Check for consecutive failures (retry protection) should_stop, stop_reason, is_critical = self._check_consecutive_failures(tool_name, arguments) if should_stop: logger.error(f"🛑 {stop_reason}") self._record_tool_result(tool_name, arguments, False) if is_critical: # Critical failure - abort entire conversation result = { "status": "critical_error", "result": stop_reason, "execution_time": 0 } else: # Normal failure - let LLM try different approach result = { "status": "error", "result": f"{stop_reason}\n\n当前方法行不通,请尝试完全不同的方法或向用户询问更多信息。", "execution_time": 0 } return result self._emit_event("tool_execution_start", { "tool_call_id": tool_id, "tool_name": tool_name, "arguments": arguments }) try: tool = self.tools.get(tool_name) if not tool: raise ValueError(self._build_tool_not_found_message(tool_name)) # Set tool context tool.model = self.model tool.context = self.agent # Execute tool start_time = time.time() result: ToolResult = tool.execute_tool(arguments) execution_time = time.time() - start_time result_dict = { "status": result.status, "result": result.result, "execution_time": execution_time } # Record tool result for failure tracking success = result.status == "success" self._record_tool_result(tool_name, arguments, success) # Auto-refresh skills after skill creation if tool_name == "bash" and result.status == "success": command = arguments.get("command", "") if "init_skill.py" in command and self.agent.skill_manager: logger.info("Detected skill creation, refreshing skills...") self.agent.refresh_skills() logger.info(f"Skills refreshed! Now have {len(self.agent.skill_manager.skills)} skills") self._emit_event("tool_execution_end", { "tool_call_id": tool_id, "tool_name": tool_name, **result_dict }) return result_dict except Exception as e: logger.error(f"Tool execution error: {e}") error_result = { "status": "error", "result": str(e), "execution_time": 0 } # Record failure self._record_tool_result(tool_name, arguments, False) self._emit_event("tool_execution_end", { "tool_call_id": tool_id, "tool_name": tool_name, **error_result }) return error_result def _build_tool_not_found_message(self, tool_name: str) -> str: """Build a helpful error message when a tool is not found. If a skill with the same name exists in skill_manager, read its SKILL.md and include the content so the LLM knows how to use it. """ available_tools = list(self.tools.keys()) base_msg = f"Tool '{tool_name}' not found. Available tools: {available_tools}" skill_manager = getattr(self.agent, 'skill_manager', None) if not skill_manager: return base_msg skill_entry = skill_manager.get_skill(tool_name) if not skill_entry: return base_msg skill = skill_entry.skill skill_md_path = skill.file_path skill_content = "" try: with open(skill_md_path, 'r', encoding='utf-8') as f: skill_content = f.read() except Exception: skill_content = skill.description logger.info( f"[Agent] Tool '{tool_name}' not found, but matched skill '{skill.name}'. " f"Guiding LLM to use the skill instead." ) return ( f"Tool '{tool_name}' is not a built-in tool, but a matching skill " f"'{skill.name}' is available. You should use existing tools (e.g. bash with curl) " f"to accomplish this task following the skill instructions below:\n\n" f"--- SKILL: {skill.name} (path: {skill_md_path}) ---\n" f"{skill_content}\n" f"--- END SKILL ---\n\n" f"Available tools: {available_tools}" ) def _validate_and_fix_messages(self): """Delegate to the shared sanitizer (see message_sanitizer.py).""" sanitize_claude_messages(self.messages) def _identify_complete_turns(self) -> List[Dict]: """ 识别完整的对话轮次 一个完整轮次包括: 1. 用户消息(text) 2. AI 回复(可能包含 tool_use) 3. 工具结果(tool_result,如果有) 4. 后续 AI 回复(如果有) Returns: List of turns, each turn is a dict with 'messages' list """ turns = [] current_turn = {'messages': []} for msg in self.messages: role = msg.get('role') content = msg.get('content', []) if role == 'user': # Determine if this is a real user query (not a tool_result injection # or an internal hint message injected by the agent loop). is_user_query = False has_tool_result = False if isinstance(content, list): has_text = any( isinstance(block, dict) and block.get('type') == 'text' for block in content ) has_tool_result = any( isinstance(block, dict) and block.get('type') == 'tool_result' for block in content ) # A message with tool_result is always internal, even if it # also contains text blocks (shouldn't happen, but be safe). is_user_query = has_text and not has_tool_result elif isinstance(content, str): is_user_query = True if is_user_query: if current_turn['messages']: turns.append(current_turn) current_turn = {'messages': [msg]} else: current_turn['messages'].append(msg) else: # AI 回复,属于当前轮次 current_turn['messages'].append(msg) # 添加最后一个轮次 if current_turn['messages']: turns.append(current_turn) return turns def _estimate_turn_tokens(self, turn: Dict) -> int: """估算一个轮次的 tokens""" return sum( self.agent._estimate_message_tokens(msg) for msg in turn['messages'] ) def _truncate_historical_tool_results(self): """ Truncate tool_result content in historical messages to reduce context size. Current turn results are kept at 30K chars (truncated at creation time). Historical turn results are further truncated to 10K chars here. This runs before token-based trimming so that we first shrink oversized results, potentially avoiding the need to drop entire turns. """ MAX_HISTORY_RESULT_CHARS = 20000 if len(self.messages) < 2: return # Find where the last user text message starts (= current turn boundary) # We skip the current turn's messages to preserve their full content current_turn_start = len(self.messages) for i in range(len(self.messages) - 1, -1, -1): msg = self.messages[i] if msg.get("role") == "user": content = msg.get("content", []) if isinstance(content, list) and any( isinstance(b, dict) and b.get("type") == "text" for b in content ): current_turn_start = i break elif isinstance(content, str): current_turn_start = i break truncated_count = 0 for i in range(current_turn_start): msg = self.messages[i] if msg.get("role") != "user": continue content = msg.get("content", []) if not isinstance(content, list): continue for block in content: if not isinstance(block, dict) or block.get("type") != "tool_result": continue result_str = block.get("content", "") if isinstance(result_str, str) and len(result_str) > MAX_HISTORY_RESULT_CHARS: original_len = len(result_str) block["content"] = result_str[:MAX_HISTORY_RESULT_CHARS] + \ f"\n\n[Historical output truncated: {original_len} -> {MAX_HISTORY_RESULT_CHARS} chars]" truncated_count += 1 if truncated_count > 0: logger.info(f"📎 Truncated {truncated_count} historical tool result(s) to {MAX_HISTORY_RESULT_CHARS} chars") def _aggressive_trim_for_overflow(self) -> bool: """ Aggressively trim context when a real overflow error is returned by the API. This method goes beyond normal _trim_messages by: 1. Truncating all tool results (including current turn) to a small limit 2. Keeping only the last 5 complete conversation turns 3. Truncating overly long user messages Returns: True if messages were trimmed (worth retrying), False if nothing left to trim """ if not self.messages: return False original_count = len(self.messages) # Step 1: Aggressively truncate ALL tool results to 5K chars AGGRESSIVE_LIMIT = 10000 truncated = 0 for msg in self.messages: content = msg.get("content", []) if not isinstance(content, list): continue for block in content: if not isinstance(block, dict): continue # Truncate tool_result blocks if block.get("type") == "tool_result": result_str = block.get("content", "") if isinstance(result_str, str) and len(result_str) > AGGRESSIVE_LIMIT: block["content"] = ( result_str[:AGGRESSIVE_LIMIT] + f"\n\n[Truncated for context recovery: " f"{len(result_str)} -> {AGGRESSIVE_LIMIT} chars]" ) truncated += 1 # Truncate tool_use input blocks (e.g. large write content) if block.get("type") == "tool_use" and isinstance(block.get("input"), dict): input_str = json.dumps(block["input"], ensure_ascii=False) if len(input_str) > AGGRESSIVE_LIMIT: # Keep only a summary of the input for key, val in block["input"].items(): if isinstance(val, str) and len(val) > 1000: block["input"][key] = ( val[:1000] + f"... [truncated {len(val)} chars]" ) truncated += 1 # Step 2: Truncate overly long user text messages (e.g. pasted content) USER_MSG_LIMIT = 10000 for msg in self.messages: if msg.get("role") != "user": continue content = msg.get("content", []) if isinstance(content, list): for block in content: if isinstance(block, dict) and block.get("type") == "text": text = block.get("text", "") if len(text) > USER_MSG_LIMIT: block["text"] = ( text[:USER_MSG_LIMIT] + f"\n\n[Message truncated for context recovery: " f"{len(text)} -> {USER_MSG_LIMIT} chars]" ) truncated += 1 elif isinstance(content, str) and len(content) > USER_MSG_LIMIT: msg["content"] = ( content[:USER_MSG_LIMIT] + f"\n\n[Message truncated for context recovery: " f"{len(content)} -> {USER_MSG_LIMIT} chars]" ) truncated += 1 # Step 3: Keep only the last 5 complete turns turns = self._identify_complete_turns() if len(turns) > 5: kept_turns = turns[-5:] new_messages = [] for turn in kept_turns: new_messages.extend(turn["messages"]) removed = len(turns) - 5 self.messages[:] = new_messages logger.info( f"🔧 Aggressive trim: removed {removed} old turns, " f"truncated {truncated} large blocks, " f"{original_count} -> {len(self.messages)} messages" ) return True if truncated > 0: logger.info( f"🔧 Aggressive trim: truncated {truncated} large blocks " f"(no turns removed, only {len(turns)} turn(s) left)" ) return True # Nothing left to trim logger.warning("🔧 Aggressive trim: nothing to trim, will clear history") return False def _trim_messages(self): """ 智能清理消息历史,保持对话完整性 使用完整轮次作为清理单位,确保: 1. 不会在对话中间截断 2. 工具调用链(tool_use + tool_result)保持完整 3. 每轮对话都是完整的(用户消息 + AI回复 + 工具调用) """ if not self.messages or not self.agent: return # Step 0: Truncate large tool results in historical turns (30K -> 10K) self._truncate_historical_tool_results() # Step 1: 识别完整轮次 turns = self._identify_complete_turns() if not turns: return # Step 2: 轮次限制 - 超出时移除前一半,保留后一半 if len(turns) > self.max_context_turns: removed_count = len(turns) // 2 keep_count = len(turns) - removed_count # Flush discarded turns to daily memory if self.agent.memory_manager: discarded_messages = [] for turn in turns[:removed_count]: discarded_messages.extend(turn["messages"]) if discarded_messages: user_id = getattr(self.agent, '_current_user_id', None) self.agent.memory_manager.flush_memory( messages=discarded_messages, user_id=user_id, reason="trim", max_messages=0 ) 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) context_window = self.agent._get_model_context_window() # Use configured max_context_tokens if available if hasattr(self.agent, 'max_context_tokens') and self.agent.max_context_tokens: max_tokens = self.agent.max_context_tokens else: # Reserve 10% for response generation reserve_tokens = int(context_window * 0.1) max_tokens = context_window - reserve_tokens # Estimate system prompt tokens system_tokens = self.agent._estimate_message_tokens({"role": "system", "content": self.system_prompt}) available_tokens = max_tokens - system_tokens # Calculate current tokens current_tokens = sum(self._estimate_turn_tokens(turn) for turn in turns) # If under limit, reconstruct messages and return if current_tokens + system_tokens <= max_tokens: # Reconstruct message list from turns new_messages = [] for turn in turns: new_messages.extend(turn['messages']) old_count = len(self.messages) self.messages = new_messages # Log if we removed messages due to turn limit if old_count > len(self.messages): logger.info(f" 重建消息列表: {old_count} -> {len(self.messages)} 条消息") return # Token limit exceeded — tiered strategy based on turn count: # # Few turns (<5): Compress ALL turns to text-only (strip tool chains, # keep user query + final reply). Never discard turns # — losing even one is too painful when context is thin. # # Many turns (>=5): Directly discard the first half of turns. # With enough turns the oldest ones are less # critical, and keeping the recent half intact # (with full tool chains) is more useful. COMPRESS_THRESHOLD = 5 if len(turns) < COMPRESS_THRESHOLD: # --- Few turns: compress ALL turns to text-only, never discard --- compressed_turns = [] for t in turns: compressed = compress_turn_to_text_only(t) if compressed["messages"]: compressed_turns.append(compressed) new_messages = [] for turn in compressed_turns: new_messages.extend(turn["messages"]) new_tokens = sum(self._estimate_turn_tokens(t) for t in compressed_turns) old_count = len(self.messages) 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"~{current_tokens + system_tokens} -> ~{new_tokens + system_tokens} tokens)" ) return # --- Many turns (>=5): discard the older half, keep the newer half --- removed_count = len(turns) // 2 keep_count = len(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} 轮)" ) if self.agent.memory_manager: discarded_messages = [] for turn in turns[:removed_count]: discarded_messages.extend(turn["messages"]) if discarded_messages: user_id = getattr(self.agent, '_current_user_id', None) self.agent.memory_manager.flush_memory( messages=discarded_messages, user_id=user_id, reason="trim", max_messages=0 ) new_messages = [] for turn in kept_turns: new_messages.extend(turn['messages']) old_count = len(self.messages) self.messages = new_messages logger.info( f" 移除了 {removed_count} 轮对话 " f"({old_count} -> {len(self.messages)} 条消息," f"~{current_tokens + system_tokens} -> ~{kept_tokens + system_tokens} tokens)" ) def _clear_session_db(self): """ Clear the current session's persisted messages from SQLite DB. This prevents dirty data (broken tool_use/tool_result pairs) from being reloaded on the next request or after a restart. """ try: session_id = getattr(self.agent, '_current_session_id', None) if not session_id: return from agent.memory import get_conversation_store store = get_conversation_store() store.clear_session(session_id) logger.info(f"🗑️ Cleared dirty session data from DB: {session_id}") except Exception as e: logger.warning(f"Failed to clear session DB: {e}") def _prepare_messages(self) -> List[Dict[str, Any]]: """ Prepare messages to send to LLM Note: For Claude API, system prompt should be passed separately via system parameter, 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 ================================================ FILE: agent/protocol/context.py ================================================ class TeamContext: def __init__(self, name: str, description: str, rule: str, agents: list, max_steps: int = 100): """ Initialize the TeamContext with a name, description, rules, a list of agents, and a user question. :param name: The name of the group context. :param description: A description of the group context. :param rule: The rules governing the group context. :param agents: A list of agents in the context. """ self.name = name self.description = description self.rule = rule self.agents = agents self.user_task = "" # For backward compatibility self.task = None # Will be a Task instance self.model = None # Will be an instance of LLMModel self.task_short_name = None # Store the task directory name # List of agents that have been executed self.agent_outputs: list = [] self.current_steps = 0 self.max_steps = max_steps class AgentOutput: def __init__(self, agent_name: str, output: str): self.agent_name = agent_name self.output = output ================================================ FILE: agent/protocol/message_utils.py ================================================ """ Message sanitizer — fix broken tool_use / tool_result pairs. Provides two public helpers that can be reused across agent_stream.py and any bot that converts messages to OpenAI format: 1. sanitize_claude_messages(messages) Operates on the internal Claude-format message list (in-place). 2. drop_orphaned_tool_results_openai(messages) Operates on an already-converted OpenAI-format message list, returning a cleaned copy. """ from __future__ import annotations from typing import Dict, List, Set from common.log import logger # ------------------------------------------------------------------ # # Claude-format sanitizer (used by agent_stream) # ------------------------------------------------------------------ # def sanitize_claude_messages(messages: List[Dict]) -> int: """ Validate and fix a Claude-format message list **in-place**. Fixes handled: - Trailing assistant message with tool_use but no following tool_result - Leading orphaned tool_result user messages - Mid-list tool_result blocks whose tool_use_id has no matching tool_use in any preceding assistant message Returns the number of messages / blocks removed. """ if not messages: return 0 removed = 0 # 1. Remove trailing incomplete tool_use assistant messages while messages: last = messages[-1] if last.get("role") != "assistant": break content = last.get("content", []) if isinstance(content, list) and any( isinstance(b, dict) and b.get("type") == "tool_use" for b in content ): logger.warning("⚠️ Removing trailing incomplete tool_use assistant message") messages.pop() removed += 1 else: break # 2. Remove leading orphaned tool_result user messages while messages: first = messages[0] if first.get("role") != "user": break content = first.get("content", []) if isinstance(content, list) and _has_block_type(content, "tool_result") \ and not _has_block_type(content, "text"): logger.warning("⚠️ Removing leading orphaned tool_result user message") messages.pop(0) removed += 1 else: break # 3. Iteratively remove unmatched tool_use / tool_result until stable. # Removing one broken message can orphan others (e.g. an assistant msg # with both matched and unmatched tool_use — deleting it orphans the # previously-matched tool_result). Loop until clean. for _ in range(5): use_ids: Set[str] = set() result_ids: Set[str] = set() for msg in messages: for block in (msg.get("content") or []): if not isinstance(block, dict): continue if block.get("type") == "tool_use" and block.get("id"): use_ids.add(block["id"]) elif block.get("type") == "tool_result" and block.get("tool_use_id"): result_ids.add(block["tool_use_id"]) bad_use = use_ids - result_ids bad_result = result_ids - use_ids if not bad_use and not bad_result: break pass_removed = 0 i = 0 while i < len(messages): msg = messages[i] role = msg.get("role") content = msg.get("content", []) if not isinstance(content, list): i += 1 continue if role == "assistant" and bad_use and any( isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id") in bad_use for b in content ): logger.warning(f"⚠️ Removing assistant msg with unmatched tool_use") messages.pop(i) pass_removed += 1 continue if role == "user" and bad_result and _has_block_type(content, "tool_result"): has_bad = any( isinstance(b, dict) and b.get("type") == "tool_result" and b.get("tool_use_id") in bad_result for b in content ) if has_bad: if not _has_block_type(content, "text"): logger.warning(f"⚠️ Removing user msg with unmatched tool_result") messages.pop(i) pass_removed += 1 continue else: before = len(content) msg["content"] = [ b for b in content if not (isinstance(b, dict) and b.get("type") == "tool_result" and b.get("tool_use_id") in bad_result) ] pass_removed += before - len(msg["content"]) i += 1 removed += pass_removed if pass_removed == 0: break if removed: logger.info(f"🔧 Message validation: removed {removed} broken message(s)") return removed # ------------------------------------------------------------------ # # OpenAI-format sanitizer (used by minimax_bot, openai_compatible_bot) # ------------------------------------------------------------------ # def drop_orphaned_tool_results_openai(messages: List[Dict]) -> List[Dict]: """ Return a copy of *messages* (OpenAI format) with any ``role=tool`` messages removed if their ``tool_call_id`` does not match a ``tool_calls[].id`` in a preceding assistant message. """ known_ids: Set[str] = set() cleaned: List[Dict] = [] for msg in messages: if msg.get("role") == "assistant" and msg.get("tool_calls"): for tc in msg["tool_calls"]: tc_id = tc.get("id", "") if tc_id: known_ids.add(tc_id) if msg.get("role") == "tool": ref_id = msg.get("tool_call_id", "") if ref_id and ref_id not in known_ids: logger.warning( f"[MessageSanitizer] Dropping orphaned tool result " f"(tool_call_id={ref_id} not in known ids)" ) continue cleaned.append(msg) return cleaned # ------------------------------------------------------------------ # # Internal helpers # ------------------------------------------------------------------ # def _has_block_type(content: list, block_type: str) -> bool: return any( isinstance(b, dict) and b.get("type") == block_type for b in content ) def _extract_text_from_content(content) -> str: """Extract plain text from a message content field (str or list of blocks).""" if isinstance(content, str): return content.strip() if isinstance(content, list): parts = [ b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text" ] return "\n".join(p for p in parts if p).strip() return "" def compress_turn_to_text_only(turn: Dict) -> Dict: """ Compress a full turn (with tool_use/tool_result chains) into a lightweight text-only turn that keeps only the first user text and the last assistant text. This preserves the conversational context (what the user asked and what the agent concluded) while stripping out the bulky intermediate tool interactions. Returns a new turn dict with a ``messages`` list; the original is not mutated. """ user_text = "" last_assistant_text = "" for msg in turn["messages"]: role = msg.get("role") content = msg.get("content", []) if role == "user": if isinstance(content, list) and _has_block_type(content, "tool_result"): continue if not user_text: user_text = _extract_text_from_content(content) elif role == "assistant": text = _extract_text_from_content(content) if text: last_assistant_text = text compressed_messages = [] if user_text: compressed_messages.append({ "role": "user", "content": [{"type": "text", "text": user_text}] }) if last_assistant_text: compressed_messages.append({ "role": "assistant", "content": [{"type": "text", "text": last_assistant_text}] }) return {"messages": compressed_messages} ================================================ FILE: agent/protocol/models.py ================================================ """ Models module for agent system. Provides basic model classes needed by tools and bridge integration. """ from typing import Any, Dict, List, Optional class LLMRequest: """Request model for LLM operations""" def __init__(self, messages: List[Dict[str, str]] = None, model: Optional[str] = None, temperature: float = 0.7, max_tokens: Optional[int] = None, stream: bool = False, tools: Optional[List] = None, **kwargs): self.messages = messages or [] self.model = model self.temperature = temperature self.max_tokens = max_tokens self.stream = stream self.tools = tools # Allow extra attributes for key, value in kwargs.items(): setattr(self, key, value) class LLMModel: """Base class for LLM models""" def __init__(self, model: str = None, **kwargs): self.model = model self.config = kwargs def call(self, request: LLMRequest): """ Call the model with a request. This is a placeholder implementation. """ raise NotImplementedError("LLMModel.call not implemented in this context") def call_stream(self, request: LLMRequest): """ Call the model with streaming. This is a placeholder implementation. """ raise NotImplementedError("LLMModel.call_stream not implemented in this context") class ModelFactory: """Factory for creating model instances""" @staticmethod def create_model(model_type: str, **kwargs): """ Create a model instance based on type. This is a placeholder implementation. """ raise NotImplementedError("ModelFactory.create_model not implemented in this context") ================================================ FILE: agent/protocol/result.py ================================================ from __future__ import annotations import time import uuid from dataclasses import dataclass, field from enum import Enum from typing import List, Dict, Any, Optional from agent.protocol.task import Task, TaskStatus class AgentActionType(Enum): """Enum representing different types of agent actions.""" TOOL_USE = "tool_use" THINKING = "thinking" FINAL_ANSWER = "final_answer" @dataclass class ToolResult: """ Represents the result of a tool use. Attributes: tool_name: Name of the tool used input_params: Parameters passed to the tool output: Output from the tool status: Status of the tool execution (success/error) error_message: Error message if the tool execution failed execution_time: Time taken to execute the tool """ tool_name: str input_params: Dict[str, Any] output: Any status: str error_message: Optional[str] = None execution_time: float = 0.0 @dataclass class AgentAction: """ Represents an action taken by an agent. Attributes: id: Unique identifier for the action agent_id: ID of the agent that performed the action agent_name: Name of the agent that performed the action action_type: Type of action (tool use, thinking, final answer) content: Content of the action (thought content, final answer content) tool_result: Tool use details if action_type is TOOL_USE timestamp: When the action was performed """ agent_id: str agent_name: str action_type: AgentActionType id: str = field(default_factory=lambda: str(uuid.uuid4())) content: str = "" tool_result: Optional[ToolResult] = None thought: Optional[str] = None timestamp: float = field(default_factory=time.time) @dataclass class AgentResult: """ Represents the result of an agent's execution. Attributes: final_answer: The final answer provided by the agent step_count: Number of steps taken by the agent status: Status of the execution (success/error) error_message: Error message if execution failed """ final_answer: str step_count: int status: str = "success" error_message: Optional[str] = None @classmethod def success(cls, final_answer: str, step_count: int) -> "AgentResult": """Create a successful result""" return cls(final_answer=final_answer, step_count=step_count) @classmethod def error(cls, error_message: str, step_count: int = 0) -> "AgentResult": """Create an error result""" return cls( final_answer=f"Error: {error_message}", step_count=step_count, status="error", error_message=error_message ) @property def is_error(self) -> bool: """Check if the result represents an error""" return self.status == "error" ================================================ FILE: agent/protocol/task.py ================================================ from __future__ import annotations import time import uuid from dataclasses import dataclass, field from enum import Enum from typing import Dict, Any, List class TaskType(Enum): """Enum representing different types of tasks.""" TEXT = "text" IMAGE = "image" VIDEO = "video" AUDIO = "audio" FILE = "file" MIXED = "mixed" class TaskStatus(Enum): """Enum representing the status of a task.""" INIT = "init" # Initial state PROCESSING = "processing" # In progress COMPLETED = "completed" # Completed FAILED = "failed" # Failed @dataclass class Task: """ Represents a task to be processed by an agent. Attributes: id: Unique identifier for the task content: The primary text content of the task type: Type of the task status: Current status of the task created_at: Timestamp when the task was created updated_at: Timestamp when the task was last updated metadata: Additional metadata for the task images: List of image URLs or base64 encoded images videos: List of video URLs audios: List of audio URLs or base64 encoded audios files: List of file URLs or paths """ id: str = field(default_factory=lambda: str(uuid.uuid4())) content: str = "" type: TaskType = TaskType.TEXT status: TaskStatus = TaskStatus.INIT created_at: float = field(default_factory=time.time) updated_at: float = field(default_factory=time.time) metadata: Dict[str, Any] = field(default_factory=dict) # Media content images: List[str] = field(default_factory=list) videos: List[str] = field(default_factory=list) audios: List[str] = field(default_factory=list) files: List[str] = field(default_factory=list) def __init__(self, content: str = "", **kwargs): """ Initialize a Task with content and optional keyword arguments. Args: content: The text content of the task **kwargs: Additional attributes to set """ self.id = kwargs.get('id', str(uuid.uuid4())) self.content = content self.type = kwargs.get('type', TaskType.TEXT) self.status = kwargs.get('status', TaskStatus.INIT) self.created_at = kwargs.get('created_at', time.time()) self.updated_at = kwargs.get('updated_at', time.time()) self.metadata = kwargs.get('metadata', {}) self.images = kwargs.get('images', []) self.videos = kwargs.get('videos', []) self.audios = kwargs.get('audios', []) self.files = kwargs.get('files', []) def get_text(self) -> str: """ Get the text content of the task. Returns: The text content """ return self.content def update_status(self, status: TaskStatus) -> None: """ Update the status of the task. Args: status: The new status """ self.status = status self.updated_at = time.time() ================================================ FILE: agent/skills/__init__.py ================================================ """ Skills module for agent system. This module provides the framework for loading, managing, and executing skills. Skills are markdown files with frontmatter that provide specialized instructions for specific tasks. """ from agent.skills.types import ( Skill, SkillEntry, SkillMetadata, SkillInstallSpec, LoadSkillsResult, ) from agent.skills.loader import SkillLoader from agent.skills.manager import SkillManager from agent.skills.service import SkillService from agent.skills.formatter import format_skills_for_prompt __all__ = [ "Skill", "SkillEntry", "SkillMetadata", "SkillInstallSpec", "LoadSkillsResult", "SkillLoader", "SkillManager", "SkillService", "format_skills_for_prompt", ] ================================================ FILE: agent/skills/config.py ================================================ """ Configuration support for skills. """ import os import platform from typing import Dict, Optional, List from agent.skills.types import SkillEntry def resolve_runtime_platform() -> str: """Get the current runtime platform.""" return platform.system().lower() def has_binary(bin_name: str) -> bool: """ Check if a binary is available in PATH. :param bin_name: Binary name to check :return: True if binary is available """ import shutil return shutil.which(bin_name) is not None def has_any_binary(bin_names: List[str]) -> bool: """ Check if any of the given binaries is available. :param bin_names: List of binary names to check :return: True if at least one binary is available """ return any(has_binary(bin_name) for bin_name in bin_names) def has_env_var(env_name: str) -> bool: """ Check if an environment variable is set. :param env_name: Environment variable name :return: True if environment variable is set """ return env_name in os.environ and bool(os.environ[env_name].strip()) def get_skill_config(config: Optional[Dict], skill_name: str) -> Optional[Dict]: """ Get skill-specific configuration. :param config: Global configuration dictionary :param skill_name: Name of the skill :return: Skill configuration or None """ if not config: return None skills_config = config.get('skills', {}) if not isinstance(skills_config, dict): return None entries = skills_config.get('entries', {}) if not isinstance(entries, dict): return None return entries.get(skill_name) def should_include_skill( entry: SkillEntry, config: Optional[Dict] = None, current_platform: Optional[str] = None, ) -> bool: """ Determine if a skill should be included based on requirements. Simple rule: Skills are auto-enabled if their requirements are met. - Has required API keys → enabled - Missing API keys → disabled - Wrong keys → enabled but will fail at runtime (LLM will handle error) :param entry: SkillEntry to check :param config: Configuration dictionary (currently unused, reserved for future) :param current_platform: Current platform (default: auto-detect) :return: True if skill should be included """ metadata = entry.metadata # No metadata = always include (no requirements) if not metadata: return True # Check platform requirements (can't work on wrong platform) if metadata.os: platform_name = current_platform or resolve_runtime_platform() # Map common platform names platform_map = { 'darwin': 'darwin', 'linux': 'linux', 'windows': 'win32', } normalized_platform = platform_map.get(platform_name, platform_name) if normalized_platform not in metadata.os: return False # If skill has 'always: true', include it regardless of other requirements if metadata.always: return True # Check requirements if metadata.requires: # Check required binaries (all must be present) required_bins = metadata.requires.get('bins', []) if required_bins: if not all(has_binary(bin_name) for bin_name in required_bins): return False # Check anyBins (at least one must be present) any_bins = metadata.requires.get('anyBins', []) if any_bins: if not has_any_binary(any_bins): return False # Check environment variables (API keys) # All required env vars must be set required_env = metadata.requires.get('env', []) if required_env: for env_name in required_env: if not has_env_var(env_name): return False # Check anyEnv (at least one must be present) any_env = metadata.requires.get('anyEnv', []) if any_env: if not any(has_env_var(e) for e in any_env): return False return True def is_config_path_truthy(config: Dict, path: str) -> bool: """ Check if a config path resolves to a truthy value. :param config: Configuration dictionary :param path: Dot-separated path (e.g., 'skills.enabled') :return: True if path resolves to truthy value """ parts = path.split('.') current = config for part in parts: if not isinstance(current, dict): return False current = current.get(part) if current is None: return False # Check if value is truthy if isinstance(current, bool): return current if isinstance(current, (int, float)): return current != 0 if isinstance(current, str): return bool(current.strip()) return bool(current) def resolve_config_path(config: Dict, path: str): """ Resolve a dot-separated config path to its value. :param config: Configuration dictionary :param path: Dot-separated path :return: Value at path or None """ parts = path.split('.') current = config for part in parts: if not isinstance(current, dict): return None current = current.get(part) if current is None: return None return current ================================================ FILE: agent/skills/formatter.py ================================================ """ Skill formatter for generating prompts from skills. """ from typing import List from agent.skills.types import Skill, SkillEntry def format_skills_for_prompt(skills: List[Skill]) -> str: """ Format skills for inclusion in a system prompt. Uses XML format per Agent Skills standard. Skills with disable_model_invocation=True are excluded. :param skills: List of skills to format :return: Formatted prompt text """ # Filter out skills that should not be invoked by the model visible_skills = [s for s in skills if not s.disable_model_invocation] if not visible_skills: return "" lines = [ "", "", ] for skill in visible_skills: lines.append(" ") lines.append(f" {_escape_xml(skill.name)}") lines.append(f" {_escape_xml(skill.description)}") lines.append(f" {_escape_xml(skill.file_path)}") lines.append(f" {_escape_xml(skill.base_dir)}") lines.append(" ") lines.append("") return "\n".join(lines) def format_skill_entries_for_prompt(entries: List[SkillEntry]) -> str: """ Format skill entries for inclusion in a system prompt. :param entries: List of skill entries to format :return: Formatted prompt text """ skills = [entry.skill for entry in entries] return format_skills_for_prompt(skills) def _escape_xml(text: str) -> str: """Escape XML special characters.""" return (text .replace('&', '&') .replace('<', '<') .replace('>', '>') .replace('"', '"') .replace("'", ''')) ================================================ FILE: agent/skills/frontmatter.py ================================================ """ Frontmatter parsing for skills. """ import re import json from typing import Dict, Any, Optional, List from agent.skills.types import SkillMetadata, SkillInstallSpec def parse_frontmatter(content: str) -> Dict[str, Any]: """ Parse YAML-style frontmatter from markdown content. Returns a dictionary of frontmatter fields. """ frontmatter = {} # Match frontmatter block between --- markers match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL) if not match: return frontmatter frontmatter_text = match.group(1) # Try to use PyYAML for proper YAML parsing try: import yaml frontmatter = yaml.safe_load(frontmatter_text) if not isinstance(frontmatter, dict): frontmatter = {} return frontmatter except ImportError: # Fallback to simple parsing if PyYAML not available pass except Exception: # If YAML parsing fails, fall back to simple parsing pass # Simple YAML-like parsing (supports key: value format only) # This is a fallback for when PyYAML is not available for line in frontmatter_text.split('\n'): line = line.strip() if not line or line.startswith('#'): continue if ':' in line: key, value = line.split(':', 1) key = key.strip() value = value.strip() # Try to parse as JSON if it looks like JSON if value.startswith('{') or value.startswith('['): try: value = json.loads(value) except json.JSONDecodeError: pass # Parse boolean values elif value.lower() in ('true', 'false'): value = value.lower() == 'true' # Parse numbers elif value.isdigit(): value = int(value) frontmatter[key] = value return frontmatter def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]: """ Parse skill metadata from frontmatter. Looks for 'metadata' field containing JSON with skill configuration. """ metadata_raw = frontmatter.get('metadata') if not metadata_raw: return None # If it's a string, try to parse as JSON if isinstance(metadata_raw, str): try: metadata_raw = json.loads(metadata_raw) except json.JSONDecodeError: return None if not isinstance(metadata_raw, dict): return None # Use metadata_raw directly (COW format) meta_obj = metadata_raw # Parse install specs install_specs = [] install_raw = meta_obj.get('install', []) if isinstance(install_raw, list): for spec_raw in install_raw: if not isinstance(spec_raw, dict): continue kind = spec_raw.get('kind', spec_raw.get('type', '')).lower() if not kind: continue spec = SkillInstallSpec( kind=kind, id=spec_raw.get('id'), label=spec_raw.get('label'), bins=_normalize_string_list(spec_raw.get('bins')), os=_normalize_string_list(spec_raw.get('os')), formula=spec_raw.get('formula'), package=spec_raw.get('package'), module=spec_raw.get('module'), url=spec_raw.get('url'), archive=spec_raw.get('archive'), extract=spec_raw.get('extract', False), strip_components=spec_raw.get('stripComponents'), target_dir=spec_raw.get('targetDir'), ) install_specs.append(spec) # Parse requires requires = {} requires_raw = meta_obj.get('requires', {}) if isinstance(requires_raw, dict): for key, value in requires_raw.items(): requires[key] = _normalize_string_list(value) return SkillMetadata( always=meta_obj.get('always', False), skill_key=meta_obj.get('skillKey'), primary_env=meta_obj.get('primaryEnv'), emoji=meta_obj.get('emoji'), homepage=meta_obj.get('homepage'), os=_normalize_string_list(meta_obj.get('os')), requires=requires, install=install_specs, ) def _normalize_string_list(value: Any) -> List[str]: """Normalize a value to a list of strings.""" if not value: return [] if isinstance(value, list): return [str(v).strip() for v in value if v] if isinstance(value, str): return [v.strip() for v in value.split(',') if v.strip()] return [] def parse_boolean_value(value: Optional[str], default: bool = False) -> bool: """Parse a boolean value from frontmatter.""" if value is None: return default if isinstance(value, bool): return value if isinstance(value, str): return value.lower() in ('true', '1', 'yes', 'on') return default def get_frontmatter_value(frontmatter: Dict[str, Any], key: str) -> Optional[str]: """Get a frontmatter value as a string.""" value = frontmatter.get(key) return str(value) if value is not None else None ================================================ FILE: agent/skills/loader.py ================================================ """ Skill loader for discovering and loading skills from directories. """ import os from pathlib import Path from typing import List, Optional, Dict from common.log import logger from agent.skills.types import Skill, SkillEntry, LoadSkillsResult, SkillMetadata from agent.skills.frontmatter import parse_frontmatter, parse_metadata, parse_boolean_value, get_frontmatter_value class SkillLoader: """Loads skills from various directories.""" def __init__(self): pass def load_skills_from_dir(self, dir_path: str, source: str) -> LoadSkillsResult: """ Load skills from a directory. Discovery rules: - Direct .md files in the root directory - Recursive SKILL.md files under subdirectories :param dir_path: Directory path to scan :param source: Source identifier ('builtin' or 'custom') :return: LoadSkillsResult with skills and diagnostics """ skills = [] diagnostics = [] if not os.path.exists(dir_path): diagnostics.append(f"Directory does not exist: {dir_path}") return LoadSkillsResult(skills=skills, diagnostics=diagnostics) if not os.path.isdir(dir_path): diagnostics.append(f"Path is not a directory: {dir_path}") return LoadSkillsResult(skills=skills, diagnostics=diagnostics) # Load skills from root-level .md files and subdirectories result = self._load_skills_recursive(dir_path, source, include_root_files=True) return result def _load_skills_recursive( self, dir_path: str, source: str, include_root_files: bool = False ) -> LoadSkillsResult: """ Recursively load skills from a directory. :param dir_path: Directory to scan :param source: Source identifier :param include_root_files: Whether to include root-level .md files :return: LoadSkillsResult """ skills = [] diagnostics = [] try: entries = os.listdir(dir_path) except Exception as e: diagnostics.append(f"Failed to list directory {dir_path}: {e}") 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): continue # Load the skill skill_result = self._load_skill_from_file(full_path, source) if skill_result.skills: skills.extend(skill_result.skills) diagnostics.extend(skill_result.diagnostics) return LoadSkillsResult(skills=skills, diagnostics=diagnostics) def _load_skill_from_file(self, file_path: str, source: str) -> LoadSkillsResult: """ Load a single skill from a markdown file. :param file_path: Path to the skill markdown file :param source: Source identifier :return: LoadSkillsResult """ diagnostics = [] try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() except Exception as e: diagnostics.append(f"Failed to read skill file {file_path}: {e}") return LoadSkillsResult(skills=[], diagnostics=diagnostics) # Parse frontmatter frontmatter = parse_frontmatter(content) # Get skill name and description skill_dir = os.path.dirname(file_path) parent_dir_name = os.path.basename(skill_dir) name = frontmatter.get('name', parent_dir_name) description = frontmatter.get('description', '') # Normalize name (handle both string and list) if isinstance(name, list): name = name[0] if name else parent_dir_name elif not isinstance(name, str): name = str(name) if name else parent_dir_name # Normalize description (handle both string and list) if isinstance(description, list): description = ' '.join(str(d) for d in description if d) elif not isinstance(description, str): description = str(description) if description else '' # Special handling for linkai-agent: dynamically load apps from config.json if name == 'linkai-agent': description = self._load_linkai_agent_description(skill_dir, description) if not description or not description.strip(): diagnostics.append(f"Skill {name} has no description: {file_path}") return LoadSkillsResult(skills=[], diagnostics=diagnostics) # Parse disable-model-invocation flag disable_model_invocation = parse_boolean_value( get_frontmatter_value(frontmatter, 'disable-model-invocation'), default=False ) # Create skill object skill = Skill( name=name, description=description, file_path=file_path, base_dir=skill_dir, source=source, content=content, disable_model_invocation=disable_model_invocation, frontmatter=frontmatter, ) return LoadSkillsResult(skills=[skill], diagnostics=diagnostics) def _load_linkai_agent_description(self, skill_dir: str, default_description: str) -> str: """ Dynamically load LinkAI agent description from config.json :param skill_dir: Skill directory :param default_description: Default description from SKILL.md :return: Dynamic description with app list """ import json 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 "" try: with open(config_path, 'r', encoding='utf-8') as f: config = json.load(f) apps = config.get("apps", []) if not apps: return default_description # Build dynamic description with app details app_descriptions = "; ".join([ f"{app['app_name']}({app['app_code']}: {app['app_description']})" for app in apps ]) return f"Call LinkAI apps/workflows. {app_descriptions}" except Exception as e: logger.warning(f"[SkillLoader] Failed to load linkai-agent config: {e}") return default_description def load_all_skills( self, builtin_dir: Optional[str] = None, custom_dir: Optional[str] = None, ) -> Dict[str, SkillEntry]: """ Load skills from builtin and custom directories. Precedence (lowest to highest): 1. builtin — project root ``skills/``, shipped with the codebase 2. custom — workspace ``skills/``, installed via cloud console or skill creator Same-name custom skills override builtin ones. :param builtin_dir: Built-in skills directory :param custom_dir: Custom skills directory :return: Dictionary mapping skill name to SkillEntry """ skill_map: Dict[str, SkillEntry] = {} all_diagnostics = [] # Load builtin skills (lower precedence) if builtin_dir and os.path.exists(builtin_dir): result = self.load_skills_from_dir(builtin_dir, source='builtin') all_diagnostics.extend(result.diagnostics) for skill in result.skills: entry = self._create_skill_entry(skill) skill_map[skill.name] = entry # Load custom skills (higher precedence, overrides builtin) if custom_dir and os.path.exists(custom_dir): result = self.load_skills_from_dir(custom_dir, source='custom') all_diagnostics.extend(result.diagnostics) for skill in result.skills: entry = self._create_skill_entry(skill) skill_map[skill.name] = entry # Log diagnostics if all_diagnostics: logger.debug(f"Skill loading diagnostics: {len(all_diagnostics)} issues") for diag in all_diagnostics[:5]: logger.debug(f" - {diag}") logger.debug(f"Loaded {len(skill_map)} skills total") return skill_map def _create_skill_entry(self, skill: Skill) -> SkillEntry: """ Create a SkillEntry from a Skill with parsed metadata. :param skill: The skill to create an entry for :return: SkillEntry with metadata """ metadata = parse_metadata(skill.frontmatter) # Parse user-invocable flag user_invocable = parse_boolean_value( get_frontmatter_value(skill.frontmatter, 'user-invocable'), default=True ) return SkillEntry( skill=skill, metadata=metadata, user_invocable=user_invocable, ) ================================================ FILE: agent/skills/manager.py ================================================ """ Skill manager for managing skill lifecycle and operations. """ import os import json from typing import Dict, List, Optional from pathlib import Path from common.log import logger from agent.skills.types import Skill, SkillEntry, SkillSnapshot from agent.skills.loader import SkillLoader from agent.skills.formatter import format_skill_entries_for_prompt SKILLS_CONFIG_FILE = "skills_config.json" class SkillManager: """Manages skills for an agent.""" def __init__( self, builtin_dir: Optional[str] = None, custom_dir: Optional[str] = None, config: Optional[Dict] = None, ): """ Initialize the skill manager. :param builtin_dir: Built-in skills directory (project root ``skills/``) :param custom_dir: Custom skills directory (workspace ``skills/``) :param config: Configuration dictionary """ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) self.builtin_dir = builtin_dir or os.path.join(project_root, 'skills') self.custom_dir = custom_dir or os.path.join(project_root, 'workspace', 'skills') self.config = config or {} self._skills_config_path = os.path.join(self.custom_dir, SKILLS_CONFIG_FILE) # skills_config: full skill metadata keyed by name # { "web-fetch": {"name": ..., "description": ..., "source": ..., "enabled": true}, ... } self.skills_config: Dict[str, dict] = {} self.loader = SkillLoader() self.skills: Dict[str, SkillEntry] = {} # Load skills on initialization self.refresh_skills() def refresh_skills(self): """Reload all skills from builtin and custom directories, then sync config.""" self.skills = self.loader.load_all_skills( builtin_dir=self.builtin_dir, custom_dir=self.custom_dir, ) self._sync_skills_config() logger.debug(f"SkillManager: Loaded {len(self.skills)} skills") # ------------------------------------------------------------------ # skills_config.json management # ------------------------------------------------------------------ def _load_skills_config(self) -> Dict[str, dict]: """Load skills_config.json from custom_dir. Returns empty dict if not found.""" if not os.path.exists(self._skills_config_path): return {} try: with open(self._skills_config_path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): return data except Exception as e: logger.warning(f"[SkillManager] Failed to load {SKILLS_CONFIG_FILE}: {e}") return {} def _save_skills_config(self): """Persist skills_config to custom_dir/skills_config.json.""" os.makedirs(self.custom_dir, exist_ok=True) try: with open(self._skills_config_path, "w", encoding="utf-8") as f: json.dump(self.skills_config, f, indent=4, ensure_ascii=False) except Exception as e: logger.error(f"[SkillManager] Failed to save {SKILLS_CONFIG_FILE}: {e}") def _sync_skills_config(self): """ Merge directory-scanned skills with the persisted config file. - New skills discovered on disk are added with enabled=True. - Skills that no longer exist on disk are removed. - Existing entries preserve their enabled state; name/description/source are refreshed from the latest scan. """ saved = self._load_skills_config() merged: Dict[str, dict] = {} 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] = { "name": name, "description": skill.description, "source": skill.source, "enabled": prev.get("enabled", True), "category": category, } self.skills_config = merged self._save_skills_config() def is_skill_enabled(self, name: str) -> bool: """ Check if a skill is enabled according to skills_config. :param name: skill name :return: True if enabled (default True if not in config) """ entry = self.skills_config.get(name) if entry is None: return True return entry.get("enabled", True) def set_skill_enabled(self, name: str, enabled: bool): """ Set a skill's enabled state and persist. :param name: skill name :param enabled: True to enable, False to disable """ if name not in self.skills_config: raise ValueError(f"skill '{name}' not found in config") self.skills_config[name]["enabled"] = enabled self._save_skills_config() def get_skills_config(self) -> Dict[str, dict]: """ Return the full skills_config dict (for query API). :return: copy of skills_config """ return dict(self.skills_config) def get_skill(self, name: str) -> Optional[SkillEntry]: """ Get a skill by name. :param name: Skill name :return: SkillEntry or None if not found """ return self.skills.get(name) def list_skills(self) -> List[SkillEntry]: """ Get all loaded skills. :return: List of all skill entries """ return list(self.skills.values()) 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 :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 """ 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] # 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)] return entries def build_skills_prompt( self, skill_filter: Optional[List[str]] = None, ) -> str: """ Build a formatted prompt containing available skills. :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) logger.debug(f"[SkillManager] Generated prompt length: {len(result)}") return result def build_skill_snapshot( self, skill_filter: Optional[List[str]] = None, version: Optional[int] = None, ) -> SkillSnapshot: """ Build a snapshot of skills for a specific run. :param skill_filter: Optional list of skill names to include :param version: Optional version number for the snapshot :return: SkillSnapshot """ entries = self.filter_skills(skill_filter=skill_filter, include_disabled=False) prompt = format_skill_entries_for_prompt(entries) skills_info = [] resolved_skills = [] for entry in entries: skills_info.append({ 'name': entry.skill.name, 'primary_env': entry.metadata.primary_env if entry.metadata else None, }) resolved_skills.append(entry.skill) return SkillSnapshot( prompt=prompt, skills=skills_info, resolved_skills=resolved_skills, version=version, ) def sync_skills_to_workspace(self, target_workspace_dir: str): """ Sync all loaded skills to a target workspace directory. This is useful for sandbox environments where skills need to be copied. :param target_workspace_dir: Target workspace directory """ import shutil target_skills_dir = os.path.join(target_workspace_dir, 'skills') # Remove existing skills directory if os.path.exists(target_skills_dir): shutil.rmtree(target_skills_dir) # Create new skills directory os.makedirs(target_skills_dir, exist_ok=True) # Copy each skill for entry in self.skills.values(): skill_name = entry.skill.name source_dir = entry.skill.base_dir target_dir = os.path.join(target_skills_dir, skill_name) try: shutil.copytree(source_dir, target_dir) logger.debug(f"Synced skill '{skill_name}' to {target_dir}") except Exception as e: logger.warning(f"Failed to sync skill '{skill_name}': {e}") logger.info(f"Synced {len(self.skills)} skills to {target_skills_dir}") def get_skill_by_key(self, skill_key: str) -> Optional[SkillEntry]: """ Get a skill by its skill key (which may differ from name). :param skill_key: Skill key to look up :return: SkillEntry or None """ for entry in self.skills.values(): if entry.metadata and entry.metadata.skill_key == skill_key: return entry if entry.skill.name == skill_key: return entry return None ================================================ FILE: agent/skills/service.py ================================================ """ Skill service for handling skill CRUD operations. This service provides a unified interface for managing skills, which can be called from the cloud control client (LinkAI), the local web console, or any other management entry point. """ import os import shutil import zipfile import tempfile from typing import Dict, List, Optional from common.log import logger from agent.skills.types import Skill, SkillEntry from agent.skills.manager import SkillManager try: import requests except ImportError: requests = None class SkillService: """ High-level service for skill lifecycle management. Wraps SkillManager and provides network-aware operations such as downloading skill files from remote URLs. """ def __init__(self, skill_manager: SkillManager): """ :param skill_manager: The SkillManager instance to operate on """ self.manager = skill_manager # ------------------------------------------------------------------ # query # ------------------------------------------------------------------ def query(self) -> List[dict]: """ Query all skills and return a serialisable list. Reads from skills_config.json (refreshes from disk if needed). :return: list of skill info dicts """ self.manager.refresh_skills() config = self.manager.get_skills_config() result = list(config.values()) logger.info(f"[SkillService] query: {len(result)} skills found") return result # ------------------------------------------------------------------ # add / install # ------------------------------------------------------------------ def add(self, payload: dict) -> None: """ Add (install) a skill from a remote payload. Supported payload types: 1. ``type: "url"`` – download individual files:: { "name": "web_search", "type": "url", "enabled": true, "files": [ {"url": "https://...", "path": "README.md"}, {"url": "https://...", "path": "scripts/main.py"} ] } 2. ``type: "package"`` – download a zip archive and extract:: { "name": "plugin-custom-tool", "type": "package", "category": "skills", "enabled": true, "files": [{"url": "https://cdn.example.com/skills/custom-tool.zip"}] } :param payload: skill add payload from server """ name = payload.get("name") if not name: raise ValueError("skill name is required") payload_type = payload.get("type", "url") if payload_type == "package": self._add_package(name, payload) else: self._add_url(name, payload) self.manager.refresh_skills() category = payload.get("category") if category and name in self.manager.skills_config: self.manager.skills_config[name]["category"] = category self.manager._save_skills_config() def _add_url(self, name: str, payload: dict) -> None: """Install a skill by downloading individual files.""" files = payload.get("files", []) if not files: raise ValueError("skill files list is empty") skill_dir = os.path.join(self.manager.custom_dir, name) tmp_dir = skill_dir + ".tmp" if os.path.exists(tmp_dir): shutil.rmtree(tmp_dir) os.makedirs(tmp_dir, exist_ok=True) try: for file_info in files: url = file_info.get("url") rel_path = file_info.get("path") if not url or not rel_path: logger.warning(f"[SkillService] add: skip invalid file entry {file_info}") continue dest = os.path.join(tmp_dir, rel_path) self._download_file(url, dest) except Exception: shutil.rmtree(tmp_dir, ignore_errors=True) raise if os.path.exists(skill_dir): shutil.rmtree(skill_dir) os.rename(tmp_dir, skill_dir) logger.info(f"[SkillService] add: skill '{name}' installed via url ({len(files)} files)") def _add_package(self, name: str, payload: dict) -> None: """ Install a skill by downloading a zip archive and extracting it. If the archive contains a single top-level directory, that directory is used as the skill folder directly; otherwise a new directory named after the skill is created to hold the extracted contents. """ files = payload.get("files", []) if not files or not files[0].get("url"): raise ValueError("package url is required") url = files[0]["url"] skill_dir = os.path.join(self.manager.custom_dir, name) with tempfile.TemporaryDirectory() as tmp_dir: zip_path = os.path.join(tmp_dir, "package.zip") self._download_file(url, zip_path) if not zipfile.is_zipfile(zip_path): raise ValueError(f"downloaded file is not a valid zip archive: {url}") extract_dir = os.path.join(tmp_dir, "extracted") with zipfile.ZipFile(zip_path, "r") as zf: zf.extractall(extract_dir) # Determine the actual content root. # If the zip has a single top-level directory, use its contents # so the skill folder is clean (no extra nesting). top_items = [ item for item in os.listdir(extract_dir) if not item.startswith(".") ] if len(top_items) == 1: single = os.path.join(extract_dir, top_items[0]) if os.path.isdir(single): extract_dir = single if os.path.exists(skill_dir): shutil.rmtree(skill_dir) shutil.copytree(extract_dir, skill_dir) logger.info(f"[SkillService] add: skill '{name}' installed via package ({url})") # ------------------------------------------------------------------ # open / close (enable / disable) # ------------------------------------------------------------------ def open(self, payload: dict) -> None: """ Enable a skill by name. :param payload: {"name": "skill_name"} """ name = payload.get("name") if not name: raise ValueError("skill name is required") self.manager.set_skill_enabled(name, enabled=True) logger.info(f"[SkillService] open: skill '{name}' enabled") def close(self, payload: dict) -> None: """ Disable a skill by name. :param payload: {"name": "skill_name"} """ name = payload.get("name") if not name: raise ValueError("skill name is required") self.manager.set_skill_enabled(name, enabled=False) logger.info(f"[SkillService] close: skill '{name}' disabled") # ------------------------------------------------------------------ # delete # ------------------------------------------------------------------ def delete(self, payload: dict) -> None: """ Delete a skill by removing its directory entirely. :param payload: {"name": "skill_name"} """ name = payload.get("name") if not name: raise ValueError("skill name is required") skill_dir = os.path.join(self.manager.custom_dir, name) if os.path.exists(skill_dir): shutil.rmtree(skill_dir) logger.info(f"[SkillService] delete: removed directory {skill_dir}") else: logger.warning(f"[SkillService] delete: skill directory not found: {skill_dir}") # Refresh will remove the deleted skill from config automatically self.manager.refresh_skills() logger.info(f"[SkillService] delete: skill '{name}' deleted") # ------------------------------------------------------------------ # dispatch - single entry point for protocol messages # ------------------------------------------------------------------ def dispatch(self, action: str, payload: Optional[dict] = None) -> dict: """ Dispatch a skill management action and return a protocol-compatible response dict. :param action: one of query / add / open / close / delete :param payload: action-specific payload (may be None for query) :return: dict with action, code, message, payload """ payload = payload or {} try: if action == "query": result_payload = self.query() return {"action": action, "code": 200, "message": "success", "payload": result_payload} elif action == "add": self.add(payload) elif action == "open": self.open(payload) elif action == "close": self.close(payload) elif action == "delete": self.delete(payload) else: return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None} return {"action": action, "code": 200, "message": "success", "payload": None} except Exception as e: logger.error(f"[SkillService] dispatch error: action={action}, error={e}") return {"action": action, "code": 500, "message": str(e), "payload": None} # ------------------------------------------------------------------ # internal helpers # ------------------------------------------------------------------ @staticmethod def _download_file(url: str, dest: str): """ Download a file from *url* and save to *dest*. :param url: remote file URL :param dest: local destination path """ if requests is None: raise RuntimeError("requests library is required for downloading skill files") dest_dir = os.path.dirname(dest) if dest_dir: os.makedirs(dest_dir, exist_ok=True) resp = requests.get(url, timeout=60) resp.raise_for_status() with open(dest, "wb") as f: f.write(resp.content) logger.debug(f"[SkillService] downloaded {url} -> {dest}") ================================================ FILE: agent/skills/types.py ================================================ """ Type definitions for skills system. """ from __future__ import annotations from typing import Dict, List, Optional, Any from dataclasses import dataclass, field @dataclass class SkillInstallSpec: """Specification for installing skill dependencies.""" kind: str # brew, pip, npm, download, etc. id: Optional[str] = None label: Optional[str] = None bins: List[str] = field(default_factory=list) os: List[str] = field(default_factory=list) formula: Optional[str] = None # for brew package: Optional[str] = None # for pip/npm module: Optional[str] = None url: Optional[str] = None # for download archive: Optional[str] = None extract: bool = False strip_components: Optional[int] = None target_dir: Optional[str] = None @dataclass class SkillMetadata: """Metadata for a skill from frontmatter.""" always: bool = False # Always include this skill skill_key: Optional[str] = None # Override skill key primary_env: Optional[str] = None # Primary environment variable emoji: Optional[str] = None homepage: Optional[str] = None os: List[str] = field(default_factory=list) # Supported OS platforms requires: Dict[str, List[str]] = field(default_factory=dict) # Requirements install: List[SkillInstallSpec] = field(default_factory=list) @dataclass class Skill: """Represents a skill loaded from a markdown file.""" name: str description: str file_path: str base_dir: str source: str # builtin or custom content: str # Full markdown content disable_model_invocation: bool = False frontmatter: Dict[str, Any] = field(default_factory=dict) @dataclass class SkillEntry: """A skill with parsed metadata.""" skill: Skill metadata: Optional[SkillMetadata] = None user_invocable: bool = True # Can users invoke this skill directly @dataclass class LoadSkillsResult: """Result of loading skills from a directory.""" skills: List[Skill] diagnostics: List[str] = field(default_factory=list) @dataclass class SkillSnapshot: """Snapshot of skills for a specific run.""" prompt: str # Formatted prompt text skills: List[Dict[str, str]] # List of skill info (name, primary_env) resolved_skills: List[Skill] = field(default_factory=list) version: Optional[int] = None ================================================ FILE: agent/tools/__init__.py ================================================ # Import base tool from agent.tools.base_tool import BaseTool from agent.tools.tool_manager import ToolManager # Import file operation tools from agent.tools.read.read import Read from agent.tools.write.write import Write from agent.tools.edit.edit import Edit from agent.tools.bash.bash import Bash from agent.tools.ls.ls import Ls from agent.tools.send.send import Send # Import memory tools from agent.tools.memory.memory_search import MemorySearchTool from agent.tools.memory.memory_get import MemoryGetTool # Import tools with optional dependencies def _import_optional_tools(): """Import tools that have optional dependencies""" from common.log import logger tools = {} # EnvConfig Tool (requires python-dotenv) try: from agent.tools.env_config.env_config import EnvConfig tools['EnvConfig'] = EnvConfig except ImportError as e: logger.error( f"[Tools] EnvConfig tool not loaded - missing dependency: {e}\n" f" To enable environment variable management, run:\n" f" pip install python-dotenv>=1.0.0" ) except Exception as e: logger.error(f"[Tools] EnvConfig tool failed to load: {e}") # Scheduler Tool (requires croniter) try: from agent.tools.scheduler.scheduler_tool import SchedulerTool tools['SchedulerTool'] = SchedulerTool except ImportError as e: logger.error( f"[Tools] Scheduler tool not loaded - missing dependency: {e}\n" f" To enable scheduled tasks, run:\n" f" pip install croniter>=2.0.0" ) except Exception as e: logger.error(f"[Tools] Scheduler tool failed to load: {e}") # WebSearch Tool (conditionally loaded based on API key availability at init time) try: from agent.tools.web_search.web_search import WebSearch tools['WebSearch'] = WebSearch except ImportError as e: logger.error(f"[Tools] WebSearch not loaded - missing dependency: {e}") except Exception as e: logger.error(f"[Tools] WebSearch failed to load: {e}") # WebFetch Tool try: from agent.tools.web_fetch.web_fetch import WebFetch tools['WebFetch'] = WebFetch except ImportError as e: logger.error(f"[Tools] WebFetch not loaded - missing dependency: {e}") except Exception as e: logger.error(f"[Tools] WebFetch failed to load: {e}") # Vision Tool (conditionally loaded based on API key availability) try: from agent.tools.vision.vision import Vision tools['Vision'] = Vision except ImportError as e: logger.error(f"[Tools] Vision not loaded - missing dependency: {e}") except Exception as e: logger.error(f"[Tools] Vision failed to load: {e}") return tools # Load optional tools _optional_tools = _import_optional_tools() EnvConfig = _optional_tools.get('EnvConfig') SchedulerTool = _optional_tools.get('SchedulerTool') WebSearch = _optional_tools.get('WebSearch') WebFetch = _optional_tools.get('WebFetch') Vision = _optional_tools.get('Vision') GoogleSearch = _optional_tools.get('GoogleSearch') FileSave = _optional_tools.get('FileSave') Terminal = _optional_tools.get('Terminal') # Delayed import for BrowserTool def _import_browser_tool(): 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'." ) return BrowserToolPlaceholder # Dynamically set BrowserTool # BrowserTool = _import_browser_tool() # Export all tools (including optional ones that might be None) __all__ = [ 'BaseTool', 'ToolManager', 'Read', 'Write', 'Edit', 'Bash', 'Ls', 'Send', 'MemorySearchTool', 'MemoryGetTool', 'EnvConfig', 'SchedulerTool', 'WebSearch', 'WebFetch', 'Vision', # Optional tools (may be None if dependencies not available) # 'BrowserTool' ] """ Tools module for Agent. """ ================================================ FILE: agent/tools/base_tool.py ================================================ from enum import Enum from typing import Any, Optional from common.log import logger import copy class ToolStage(Enum): """Enum representing tool decision stages""" PRE_PROCESS = "pre_process" # Tools that need to be actively selected by the agent POST_PROCESS = "post_process" # Tools that automatically execute after final_answer class ToolResult: """Tool execution result""" def __init__(self, status: str = None, result: Any = None, ext_data: Any = None): self.status = status self.result = result self.ext_data = ext_data @staticmethod def success(result, ext_data: Any = None): return ToolResult(status="success", result=result, ext_data=ext_data) @staticmethod def fail(result, ext_data: Any = None): return ToolResult(status="error", result=result, ext_data=ext_data) class BaseTool: """Base class for all tools.""" # Default decision stage is pre-process stage = ToolStage.PRE_PROCESS # Class attributes must be inherited name: str = "base_tool" description: str = "Base tool" params: dict = {} # Store JSON Schema model: Optional[Any] = None # LLM model instance, type depends on bot implementation @classmethod def get_json_schema(cls) -> dict: """Get the standard description of the tool""" return { "name": cls.name, "description": cls.description, "parameters": cls.params } def execute_tool(self, params: dict) -> ToolResult: try: return self.execute(params) except Exception as e: logger.error(e) def execute(self, params: dict) -> ToolResult: """Specific logic to be implemented by subclasses""" raise NotImplementedError @classmethod def _parse_schema(cls) -> dict: """Convert JSON Schema to Pydantic fields""" fields = {} for name, prop in cls.params["properties"].items(): # Convert JSON Schema types to Python types type_map = { "string": str, "number": float, "integer": int, "boolean": bool, "array": list, "object": dict } fields[name] = ( type_map[prop["type"]], prop.get("default", ...) ) return fields def should_auto_execute(self, context) -> bool: """ Determine if this tool should be automatically executed based on context. :param context: The agent context :return: True if the tool should be executed, False otherwise """ # Only tools in post-process stage will be automatically executed return self.stage == ToolStage.POST_PROCESS def close(self): """ Close any resources used by the tool. This method should be overridden by tools that need to clean up resources such as browser connections, file handles, etc. By default, this method does nothing. """ pass ================================================ FILE: agent/tools/bash/__init__.py ================================================ from .bash import Bash __all__ = ['Bash'] ================================================ FILE: agent/tools/bash/bash.py ================================================ """ Bash tool - Execute bash commands """ import os import re import sys import subprocess import tempfile from typing import Dict, Any from agent.tools.base_tool import BaseTool, ToolResult from agent.tools.utils.truncate import truncate_tail, format_size, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES from common.log import logger from common.utils import expand_path class Bash(BaseTool): """Tool for executing bash commands""" 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. 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""" params: dict = { "type": "object", "properties": { "command": { "type": "string", "description": "Bash command to execute" }, "timeout": { "type": "integer", "description": "Timeout in seconds (optional, default: 30)" } }, "required": ["command"] } def __init__(self, config: dict = None): self.config = config or {} self.cwd = self.config.get("cwd", os.getcwd()) # Ensure working directory exists if not os.path.exists(self.cwd): os.makedirs(self.cwd, exist_ok=True) self.default_timeout = self.config.get("timeout", 30) # Enable safety mode by default (can be disabled in config) self.safety_mode = self.config.get("safety_mode", True) def execute(self, args: Dict[str, Any]) -> ToolResult: """ Execute a bash command :param args: Dictionary containing the command and optional timeout :return: Command output or error """ command = args.get("command", "").strip() timeout = args.get("timeout", self.default_timeout) 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: return ToolResult.fail( "Error: Access denied. API keys and credentials must be accessed through the env_config tool only." ) # Optional safety check - only warn about extremely dangerous commands if self.safety_mode: warning = self._get_safety_warning(command) if warning: return ToolResult.fail( f"Safety Warning: {warning}\n\nIf you believe this command is safe and necessary, please ask the user for confirmation first, explaining what the command does and why it's needed.") try: # Prepare environment with .env file variables env = os.environ.copy() # Load environment variables from ~/.cow/.env if it exists env_file = expand_path("~/.cow/.env") dotenv_vars = {} if os.path.exists(env_file): try: from dotenv import dotenv_values dotenv_vars = dotenv_values(env_file) env.update(dotenv_vars) logger.debug(f"[Bash] Loaded {len(dotenv_vars)} variables from {env_file}") except ImportError: logger.debug("[Bash] python-dotenv not installed, skipping .env loading") except Exception as e: logger.debug(f"[Bash] Failed to load .env: {e}") # getuid() only exists on Unix-like systems if hasattr(os, 'getuid'): logger.debug(f"[Bash] Process UID: {os.getuid()}") else: logger.debug(f"[Bash] Process User: {os.environ.get('USERNAME', os.environ.get('USER', 'unknown'))}") # On Windows, convert $VAR references to %VAR% for cmd.exe if sys.platform == "win32": env["PYTHONIOENCODING"] = "utf-8" command = self._convert_env_vars_for_windows(command, dotenv_vars) 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 ) logger.debug(f"[Bash] Exit code: {result.returncode}") logger.debug(f"[Bash] Stdout length: {len(result.stdout)}") logger.debug(f"[Bash] Stderr length: {len(result.stderr)}") # Workaround for exit code 126 with no output if result.returncode == 126 and not result.stdout and not result.stderr: logger.warning(f"[Bash] Exit 126 with no output - trying alternative execution method") # Try using argument list instead of shell=True import shlex try: parts = shlex.split(command) if len(parts) > 0: logger.info(f"[Bash] Retrying with argument list: {parts[:3]}...") retry_result = subprocess.run( parts, cwd=self.cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=timeout, env=env ) logger.debug(f"[Bash] Retry exit code: {retry_result.returncode}, stdout: {len(retry_result.stdout)}, stderr: {len(retry_result.stderr)}") # If retry succeeded, use retry result if retry_result.returncode == 0 or retry_result.stdout or retry_result.stderr: result = retry_result else: # Both attempts failed - check if this is openai-image-vision skill if 'openai-image-vision' in command or 'vision.sh' in command: # Create a mock result with helpful error message from types import SimpleNamespace result = SimpleNamespace( returncode=1, stdout='{"error": "图片无法解析", "reason": "该图片格式可能不受支持,或图片文件存在问题", "suggestion": "请尝试其他图片"}', stderr='' ) logger.info(f"[Bash] Converted exit 126 to user-friendly image error message for vision skill") 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 # Check if we need to save full output to temp file temp_file_path = None total_bytes = len(output.encode('utf-8')) if total_bytes > DEFAULT_MAX_BYTES: # Save full output to temp file with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-') as f: f.write(output) temp_file_path = f.name # Apply tail truncation truncation = truncate_tail(output) output_text = truncation.content or "(no output)" # Build result details = {} if truncation.truncated: details["truncation"] = truncation.to_dict() if temp_file_path: details["full_output_path"] = temp_file_path # Build notice start_line = truncation.total_lines - truncation.output_lines + 1 end_line = truncation.total_lines if truncation.last_line_partial: # Edge case: last line alone > 30KB last_line = output.split('\n')[-1] if output else "" last_line_size = format_size(len(last_line.encode('utf-8'))) output_text += f"\n\n[Showing last {format_size(truncation.output_bytes)} of line {end_line} (line is {last_line_size}). Full output: {temp_file_path}]" elif truncation.truncated_by == "lines": output_text += f"\n\n[Showing lines {start_line}-{end_line} of {truncation.total_lines}. Full output: {temp_file_path}]" else: output_text += f"\n\n[Showing lines {start_line}-{end_line} of {truncation.total_lines} ({format_size(DEFAULT_MAX_BYTES)} limit). Full output: {temp_file_path}]" # Check exit code if result.returncode != 0: output_text += f"\n\nCommand exited with code {result.returncode}" return ToolResult.fail({ "output": output_text, "exit_code": result.returncode, "details": details if details else None }) return ToolResult.success({ "output": output_text, "exit_code": result.returncode, "details": details if details else None }) except subprocess.TimeoutExpired: return ToolResult.fail(f"Error: Command timed out after {timeout} seconds") except Exception as e: return ToolResult.fail(f"Error executing command: {str(e)}") def _get_safety_warning(self, command: str) -> str: """ Get safety warning for potentially dangerous commands Only warns about extremely dangerous system-level operations :param command: Command to check :return: Warning message if dangerous, empty string if safe """ cmd_lower = command.lower().strip() # 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"), # 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"), # 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"), ] 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 @staticmethod def _convert_env_vars_for_windows(command: str, dotenv_vars: dict) -> str: """ Convert bash-style $VAR / ${VAR} references to cmd.exe %VAR% syntax. Only converts variables loaded from .env (user-configured API keys etc.) to avoid breaking $PATH, jq expressions, regex, etc. """ if not dotenv_vars: return command def replace_match(m): var_name = m.group(1) or m.group(2) if var_name in dotenv_vars: return f"%{var_name}%" return m.group(0) return re.sub(r'\$\{(\w+)\}|\$(\w+)', replace_match, command) ================================================ FILE: agent/tools/browser_tool.py ================================================ def copy(self): """ Special copy method for browser tool to avoid recreating browser instance. :return: A new instance with shared browser reference but unique model """ new_tool = self.__class__() # Copy essential attributes new_tool.model = self.model new_tool.context = getattr(self, 'context', None) new_tool.config = getattr(self, 'config', None) # Share the browser instance instead of creating a new one if hasattr(self, 'browser'): new_tool.browser = self.browser return new_tool ================================================ FILE: agent/tools/edit/__init__.py ================================================ from .edit import Edit __all__ = ['Edit'] ================================================ FILE: agent/tools/edit/edit.py ================================================ """ Edit tool - Precise file editing Edit files through exact text replacement """ import os from typing import Dict, Any from agent.tools.base_tool import BaseTool, ToolResult from common.utils import expand_path from agent.tools.utils.diff import ( strip_bom, detect_line_ending, normalize_to_lf, restore_line_endings, normalize_for_fuzzy_match, fuzzy_find_text, generate_diff_string ) class Edit(BaseTool): """Tool for precise file editing""" name: str = "edit" description: str = "Edit a file by replacing exact text, or append to end if oldText is empty. For append: use empty oldText. For replace: oldText must match exactly (including whitespace)." params: dict = { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the file to edit (relative or absolute)" }, "oldText": { "type": "string", "description": "Text to find and replace. Use empty string to append to end of file. For replacement: must match exactly including whitespace." }, "newText": { "type": "string", "description": "New text to replace the old text with" } }, "required": ["path", "oldText", "newText"] } def __init__(self, config: dict = None): self.config = config or {} self.cwd = self.config.get("cwd", os.getcwd()) self.memory_manager = self.config.get("memory_manager", None) def execute(self, args: Dict[str, Any]) -> ToolResult: """ Execute file edit operation :param args: Contains file path, old text and new text :return: Operation result """ path = args.get("path", "").strip() old_text = args.get("oldText", "") new_text = args.get("newText", "") if not path: return ToolResult.fail("Error: path parameter is required") # Resolve path absolute_path = self._resolve_path(path) # Check if file exists if not os.path.exists(absolute_path): return ToolResult.fail(f"Error: File not found: {path}") # Check if readable/writable if not os.access(absolute_path, os.R_OK | os.W_OK): return ToolResult.fail(f"Error: File is not readable/writable: {path}") try: # Read file with open(absolute_path, 'r', encoding='utf-8') as f: raw_content = f.read() # Remove BOM (LLM won't include invisible BOM in oldText) bom, content = strip_bom(raw_content) # Detect original line ending original_ending = detect_line_ending(content) # Normalize to LF normalized_content = normalize_to_lf(content) normalized_old_text = normalize_to_lf(old_text) normalized_new_text = normalize_to_lf(new_text) # Special case: empty oldText means append to end of file if not old_text or not old_text.strip(): # Append mode: add newText to the end # Add newline before newText if file doesn't end with one if normalized_content and not normalized_content.endswith('\n'): new_content = normalized_content + '\n' + normalized_new_text else: new_content = normalized_content + normalized_new_text base_content = normalized_content # For verification else: # Normal edit mode: find and replace # Use fuzzy matching to find old text (try exact match first, then fuzzy match) match_result = fuzzy_find_text(normalized_content, normalized_old_text) if not match_result.found: return ToolResult.fail( f"Error: Could not find the exact text in {path}. " "The old text must match exactly including all whitespace and newlines." ) # Calculate occurrence count (use fuzzy normalized content for consistency) fuzzy_content = normalize_for_fuzzy_match(normalized_content) fuzzy_old_text = normalize_for_fuzzy_match(normalized_old_text) occurrences = fuzzy_content.count(fuzzy_old_text) if occurrences > 1: return ToolResult.fail( f"Error: Found {occurrences} occurrences of the text in {path}. " "The text must be unique. Please provide more context to make it unique." ) # Execute replacement (use matched text position) base_content = match_result.content_for_replacement new_content = ( base_content[:match_result.index] + normalized_new_text + base_content[match_result.index + match_result.match_length:] ) # Verify replacement actually changed content if base_content == new_content: return ToolResult.fail( f"Error: No changes made to {path}. " "The replacement produced identical content. " "This might indicate an issue with special characters or the text not existing as expected." ) # Restore original line endings final_content = bom + restore_line_endings(new_content, original_ending) # Write file with open(absolute_path, 'w', encoding='utf-8') as f: f.write(final_content) # Generate diff diff_result = generate_diff_string(base_content, new_content) result = { "message": f"Successfully replaced text in {path}", "path": path, "diff": diff_result['diff'], "first_changed_line": diff_result['first_changed_line'] } # Notify memory manager if file is in memory directory if self.memory_manager and "memory/" in path: try: self.memory_manager.mark_dirty() except Exception as e: # Don't fail the edit if memory notification fails pass return ToolResult.success(result) except UnicodeDecodeError: return ToolResult.fail(f"Error: File is not a valid text file (encoding error): {path}") except PermissionError: return ToolResult.fail(f"Error: Permission denied accessing {path}") except Exception as e: return ToolResult.fail(f"Error editing file: {str(e)}") def _resolve_path(self, path: str) -> str: """ Resolve path to absolute path :param path: Relative or absolute path :return: Absolute path """ # Expand ~ to user home directory path = expand_path(path) if os.path.isabs(path): return path return os.path.abspath(os.path.join(self.cwd, path)) ================================================ FILE: agent/tools/env_config/__init__.py ================================================ from agent.tools.env_config.env_config import EnvConfig __all__ = ['EnvConfig'] ================================================ FILE: agent/tools/env_config/env_config.py ================================================ """ Environment Configuration Tool - Manage API keys and environment variables """ import os import re from typing import Dict, Any from pathlib import Path from agent.tools.base_tool import BaseTool, ToolResult from common.log import logger from common.utils import expand_path # API Key 知识库:常见的环境变量及其描述 API_KEY_REGISTRY = { # AI 模型服务 "OPENAI_API_KEY": "OpenAI API 密钥 (用于GPT模型、Embedding模型)", "GEMINI_API_KEY": "Google Gemini API 密钥", "CLAUDE_API_KEY": "Claude API 密钥 (用于Claude模型)", "LINKAI_API_KEY": "LinkAI智能体平台 API 密钥,支持多种模型切换", # 搜索服务 "BOCHA_API_KEY": "博查 AI 搜索 API 密钥 ", } class EnvConfig(BaseTool): """Tool for managing environment variables (API keys, etc.)""" name: str = "env_config" description: str = ( "Manage API keys and skill configurations securely. " "Use this tool when user wants to configure API keys (like BOCHA_API_KEY, OPENAI_API_KEY), " "view configured keys, or manage skill settings. " "Actions: 'set' (add/update key), 'get' (view specific key), 'list' (show all configured keys), 'delete' (remove key). " "Values are automatically masked for security. Changes take effect immediately via hot reload." ) params: dict = { "type": "object", "properties": { "action": { "type": "string", "description": "Action to perform: 'set', 'get', 'list', 'delete'", "enum": ["set", "get", "list", "delete"] }, "key": { "type": "string", "description": ( "Environment variable key name. Common keys:\n" "- OPENAI_API_KEY: OpenAI API (GPT models)\n" "- OPENAI_API_BASE: OpenAI API base URL\n" "- CLAUDE_API_KEY: Anthropic Claude API\n" "- GEMINI_API_KEY: Google Gemini API\n" "- LINKAI_API_KEY: LinkAI platform\n" "- BOCHA_API_KEY: Bocha AI search (博查搜索)\n" "Use exact key names (case-sensitive, all uppercase with underscores)" ) }, "value": { "type": "string", "description": "Value to set for the environment variable (for 'set' action)" } }, "required": ["action"] } def __init__(self, config: dict = None): self.config = config or {} # Store env config in ~/.cow directory (outside workspace for security) self.env_dir = expand_path("~/.cow") self.env_path = os.path.join(self.env_dir, '.env') self.agent_bridge = self.config.get("agent_bridge") # Reference to AgentBridge for hot reload # Don't create .env file in __init__ to avoid issues during tool discovery # It will be created on first use in execute() def _ensure_env_file(self): """Ensure the .env file exists""" # Create ~/.cow directory if it doesn't exist os.makedirs(self.env_dir, exist_ok=True) if not os.path.exists(self.env_path): Path(self.env_path).touch() logger.info(f"[EnvConfig] Created .env file at {self.env_path}") def _mask_value(self, value: str) -> str: """Mask sensitive parts of a value for logging""" if not value or len(value) <= 10: return "***" return f"{value[:6]}***{value[-4:]}" def _read_env_file(self) -> Dict[str, str]: """Read all key-value pairs from .env file""" env_vars = {} if os.path.exists(self.env_path): with open(self.env_path, 'r', encoding='utf-8') as f: for line in f: line = line.strip() # Skip empty lines and comments if not line or line.startswith('#'): continue # Parse KEY=VALUE match = re.match(r'^([^=]+)=(.*)$', line) if match: key, value = match.groups() env_vars[key.strip()] = value.strip() return env_vars def _write_env_file(self, env_vars: Dict[str, str]): """Write all key-value pairs to .env file""" with open(self.env_path, 'w', encoding='utf-8') as f: f.write("# Environment variables for agent skills\n") f.write("# Auto-managed by env_config tool\n\n") for key, value in sorted(env_vars.items()): f.write(f"{key}={value}\n") def _reload_env(self): """Reload environment variables from .env file""" env_vars = self._read_env_file() for key, value in env_vars.items(): os.environ[key] = value logger.debug(f"[EnvConfig] Reloaded {len(env_vars)} environment variables") def _refresh_skills(self): """Refresh skills after environment variable changes""" if self.agent_bridge: try: # Reload .env file self._reload_env() # Refresh skills in all agent instances refreshed = self.agent_bridge.refresh_all_skills() logger.info(f"[EnvConfig] Refreshed skills in {refreshed} agent instance(s)") return True except Exception as e: logger.warning(f"[EnvConfig] Failed to refresh skills: {e}") return False return False def execute(self, args: Dict[str, Any]) -> ToolResult: """ Execute environment configuration operation :param args: Contains action, key, and value parameters :return: Result of the operation """ # Ensure .env file exists on first use self._ensure_env_file() action = args.get("action") key = args.get("key") value = args.get("value") try: if action == "set": if not key or not value: return ToolResult.fail("Error: 'key' and 'value' are required for 'set' action.") # Read current env vars env_vars = self._read_env_file() # Update the key env_vars[key] = value # Write back to file self._write_env_file(env_vars) # Update current process env os.environ[key] = value logger.info(f"[EnvConfig] Set {key}={self._mask_value(value)}") # Try to refresh skills immediately refreshed = self._refresh_skills() result = { "message": f"Successfully set {key}", "key": key, "value": self._mask_value(value), } if refreshed: result["note"] = "✅ Skills refreshed automatically - changes are now active" else: result["note"] = "⚠️ Skills not refreshed - restart agent to load new skills" return ToolResult.success(result) elif action == "get": if not key: return ToolResult.fail("Error: 'key' is required for 'get' action.") # Check in file first, then in current env env_vars = self._read_env_file() value = env_vars.get(key) or os.getenv(key) # Get description from registry description = API_KEY_REGISTRY.get(key, "未知用途的环境变量") if value is not None: logger.info(f"[EnvConfig] Got {key}={self._mask_value(value)}") return ToolResult.success({ "key": key, "value": self._mask_value(value), "description": description, "exists": True, "note": f"Value is masked for security. In bash, use ${key} directly — it is auto-injected." }) else: return ToolResult.success({ "key": key, "description": description, "exists": False, "message": f"Environment variable '{key}' is not set" }) elif action == "list": env_vars = self._read_env_file() # Build detailed variable list with descriptions variables_with_info = {} for key, value in env_vars.items(): variables_with_info[key] = { "value": self._mask_value(value), "description": API_KEY_REGISTRY.get(key, "未知用途的环境变量") } logger.info(f"[EnvConfig] Listed {len(env_vars)} environment variables") if not env_vars: return ToolResult.success({ "message": "No environment variables configured", "variables": {}, "note": "常用的 API 密钥可以通过 env_config(action='set', key='KEY_NAME', value='your-key') 来配置" }) return ToolResult.success({ "message": f"Found {len(env_vars)} environment variable(s)", "variables": variables_with_info }) elif action == "delete": if not key: return ToolResult.fail("Error: 'key' is required for 'delete' action.") # Read current env vars env_vars = self._read_env_file() if key not in env_vars: return ToolResult.success({ "message": f"Environment variable '{key}' was not set", "key": key }) # Remove the key del env_vars[key] # Write back to file self._write_env_file(env_vars) # Remove from current process env if key in os.environ: del os.environ[key] logger.info(f"[EnvConfig] Deleted {key}") # Try to refresh skills immediately refreshed = self._refresh_skills() result = { "message": f"Successfully deleted {key}", "key": key, } if refreshed: result["note"] = "✅ Skills refreshed automatically - changes are now active" else: result["note"] = "⚠️ Skills not refreshed - restart agent to apply changes" return ToolResult.success(result) else: return ToolResult.fail(f"Error: Unknown action '{action}'. Use 'set', 'get', 'list', or 'delete'.") except Exception as e: logger.error(f"[EnvConfig] Error: {e}", exc_info=True) return ToolResult.fail(f"EnvConfig tool error: {str(e)}") ================================================ FILE: agent/tools/ls/__init__.py ================================================ from .ls import Ls __all__ = ['Ls'] ================================================ FILE: agent/tools/ls/ls.py ================================================ """ Ls tool - List directory contents """ import os from typing import Dict, Any from agent.tools.base_tool import BaseTool, ToolResult from agent.tools.utils.truncate import truncate_head, format_size, DEFAULT_MAX_BYTES from common.utils import expand_path DEFAULT_LIMIT = 500 class Ls(BaseTool): """Tool for listing directory contents""" name: str = "ls" description: str = f"List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to {DEFAULT_LIMIT} entries or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first)." params: dict = { "type": "object", "properties": { "path": { "type": "string", "description": "Directory to list. IMPORTANT: Relative paths are based on workspace directory. To access directories outside workspace, use absolute paths starting with ~ or /." }, "limit": { "type": "integer", "description": f"Maximum number of entries to return (default: {DEFAULT_LIMIT})" } }, "required": [] } def __init__(self, config: dict = None): self.config = config or {} self.cwd = self.config.get("cwd", os.getcwd()) def execute(self, args: Dict[str, Any]) -> ToolResult: """ Execute directory listing :param args: Listing parameters :return: Directory contents or error """ path = args.get("path", ".").strip() limit = args.get("limit", DEFAULT_LIMIT) # Resolve path absolute_path = self._resolve_path(path) # Security check: Prevent accessing sensitive config directory env_config_dir = expand_path("~/.cow") if os.path.abspath(absolute_path) == os.path.abspath(env_config_dir): return ToolResult.fail( "Error: Access denied. API keys and credentials must be accessed through the env_config tool only." ) if not os.path.exists(absolute_path): # Provide helpful hint if using relative path if not os.path.isabs(path) and not path.startswith('~'): return ToolResult.fail( f"Error: Path not found: {path}\n" f"Resolved to: {absolute_path}\n" f"Hint: Relative paths are based on workspace ({self.cwd}). For files outside workspace, use absolute paths." ) return ToolResult.fail(f"Error: Path not found: {path}") if not os.path.isdir(absolute_path): return ToolResult.fail(f"Error: Not a directory: {path}") try: # Read directory entries entries = os.listdir(absolute_path) # Sort alphabetically (case-insensitive) entries.sort(key=lambda x: x.lower()) # Format entries with directory indicators results = [] entry_limit_reached = False for entry in entries: if len(results) >= limit: entry_limit_reached = True break full_path = os.path.join(absolute_path, entry) try: if os.path.isdir(full_path): results.append(entry + '/') else: results.append(entry) except Exception: # Skip entries we can't stat continue if not results: return ToolResult.success({"message": "(empty directory)", "entries": []}) # Format output raw_output = '\n'.join(results) truncation = truncate_head(raw_output, max_lines=999999) # Only limit by bytes output = truncation.content details = {} notices = [] if entry_limit_reached: notices.append(f"{limit} entries limit reached. Use limit={limit * 2} for more") details["entry_limit_reached"] = limit if truncation.truncated: notices.append(f"{format_size(DEFAULT_MAX_BYTES)} limit reached") details["truncation"] = truncation.to_dict() if notices: output += f"\n\n[{'. '.join(notices)}]" return ToolResult.success({ "output": output, "entry_count": len(results), "details": details if details else None }) except PermissionError: return ToolResult.fail(f"Error: Permission denied reading directory: {path}") except Exception as e: return ToolResult.fail(f"Error listing directory: {str(e)}") def _resolve_path(self, path: str) -> str: """Resolve path to absolute path""" # Expand ~ to user home directory path = expand_path(path) if os.path.isabs(path): return path return os.path.abspath(os.path.join(self.cwd, path)) ================================================ FILE: agent/tools/memory/__init__.py ================================================ """ Memory tools for Agent Provides memory_search and memory_get tools """ from agent.tools.memory.memory_search import MemorySearchTool from agent.tools.memory.memory_get import MemoryGetTool __all__ = ['MemorySearchTool', 'MemoryGetTool'] ================================================ FILE: agent/tools/memory/memory_get.py ================================================ """ Memory get tool Allows agents to read specific sections from memory files """ from agent.tools.base_tool import BaseTool class MemoryGetTool(BaseTool): """Tool for reading memory file contents""" name: str = "memory_get" description: str = ( "Read specific content from memory files. " "Use this to get full context from a memory file or specific line range." ) params: dict = { "type": "object", "properties": { "path": { "type": "string", "description": "Relative path to the memory file (e.g. 'MEMORY.md', 'memory/2026-01-01.md')" }, "start_line": { "type": "integer", "description": "Starting line number (optional, default: 1)", "default": 1 }, "num_lines": { "type": "integer", "description": "Number of lines to read (optional, reads all if not specified)" } }, "required": ["path"] } def __init__(self, memory_manager): """ Initialize memory get tool Args: memory_manager: MemoryManager instance """ super().__init__() self.memory_manager = memory_manager def execute(self, args: dict): """ Execute memory file read Args: args: Dictionary with path, start_line, num_lines Returns: ToolResult with file content """ from agent.tools.base_tool import ToolResult path = args.get("path") start_line = args.get("start_line", 1) num_lines = args.get("num_lines") if not path: return ToolResult.fail("Error: path parameter is required") try: workspace_dir = self.memory_manager.config.get_workspace() # Auto-prepend memory/ if not present and not absolute path # Exception: MEMORY.md is in the root directory if not path.startswith('memory/') and not path.startswith('/') and path != 'MEMORY.md': path = f'memory/{path}' file_path = workspace_dir / path if not file_path.exists(): return ToolResult.fail(f"Error: File not found: {path}") content = file_path.read_text(encoding='utf-8') lines = content.split('\n') # Handle line range if start_line < 1: start_line = 1 start_idx = start_line - 1 if num_lines: end_idx = start_idx + num_lines selected_lines = lines[start_idx:end_idx] else: selected_lines = lines[start_idx:] result = '\n'.join(selected_lines) # Add metadata total_lines = len(lines) shown_lines = len(selected_lines) output = [ f"File: {path}", f"Lines: {start_line}-{start_line + shown_lines - 1} (total: {total_lines})", "", result ] return ToolResult.success('\n'.join(output)) except Exception as e: return ToolResult.fail(f"Error reading memory file: {str(e)}") ================================================ FILE: agent/tools/memory/memory_search.py ================================================ """ Memory search tool Allows agents to search their memory using semantic and keyword search """ from typing import Dict, Any, Optional from agent.tools.base_tool import BaseTool class MemorySearchTool(BaseTool): """Tool for searching agent memory""" name: str = "memory_search" description: str = ( "Search agent's long-term memory using semantic and keyword search. " "Use this to recall past conversations, preferences, and knowledge." ) params: dict = { "type": "object", "properties": { "query": { "type": "string", "description": "Search query (can be natural language question or keywords)" }, "max_results": { "type": "integer", "description": "Maximum number of results to return (default: 10)", "default": 10 }, "min_score": { "type": "number", "description": "Minimum relevance score (0-1, default: 0.1)", "default": 0.1 } }, "required": ["query"] } def __init__(self, memory_manager, user_id: Optional[str] = None): """ Initialize memory search tool Args: memory_manager: MemoryManager instance user_id: Optional user ID for scoped search """ super().__init__() self.memory_manager = memory_manager self.user_id = user_id def execute(self, args: dict): """ Execute memory search Args: args: Dictionary with query, max_results, min_score Returns: ToolResult with formatted search results """ from agent.tools.base_tool import ToolResult import asyncio query = args.get("query") max_results = args.get("max_results", 10) min_score = args.get("min_score", 0.1) if not query: return ToolResult.fail("Error: query parameter is required") try: # Run async search in sync context results = asyncio.run(self.memory_manager.search( query=query, user_id=self.user_id, max_results=max_results, min_score=min_score, include_shared=True )) if not results: # Return clear message that no memories exist yet # This prevents infinite retry loops return ToolResult.success( f"No memories found for '{query}'. " f"This is normal if no memories have been stored yet. " f"You can store new memories by writing to MEMORY.md or memory/YYYY-MM-DD.md files." ) # Format results output = [f"Found {len(results)} relevant memories:\n"] for i, result in enumerate(results, 1): output.append(f"\n{i}. {result.path} (lines {result.start_line}-{result.end_line})") output.append(f" Score: {result.score:.3f}") output.append(f" Snippet: {result.snippet}") return ToolResult.success("\n".join(output)) except Exception as e: return ToolResult.fail(f"Error searching memory: {str(e)}") ================================================ FILE: agent/tools/read/__init__.py ================================================ from .read import Read __all__ = ['Read'] ================================================ FILE: agent/tools/read/read.py ================================================ """ Read tool - Read file contents Supports text files, images (jpg, png, gif, webp), and PDF files """ import os from typing import Dict, Any from pathlib import Path from agent.tools.base_tool import BaseTool, ToolResult from agent.tools.utils.truncate import truncate_head, format_size, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES from common.utils import expand_path class Read(BaseTool): """Tool for reading file contents""" name: str = "read" description: str = f"Read or inspect file contents. For text/PDF files, returns content (truncated to {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB). For images/videos/audio, returns metadata only (file info, size, type). Use offset/limit for large text files." params: dict = { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the file to read. IMPORTANT: Relative paths are based on workspace directory. To access files outside workspace, use absolute paths starting with ~ or /." }, "offset": { "type": "integer", "description": "Line number to start reading from (1-indexed, optional). Use negative values to read from end (e.g. -20 for last 20 lines)" }, "limit": { "type": "integer", "description": "Maximum number of lines to read (optional)" } }, "required": ["path"] } def __init__(self, config: dict = None): self.config = config or {} self.cwd = self.config.get("cwd", os.getcwd()) # File type categories self.image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.ico'} self.video_extensions = {'.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm', '.m4v'} self.audio_extensions = {'.mp3', '.wav', '.ogg', '.m4a', '.flac', '.aac', '.wma'} self.binary_extensions = {'.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.db', '.sqlite'} self.archive_extensions = {'.zip', '.tar', '.gz', '.rar', '.7z', '.bz2', '.xz'} self.pdf_extensions = {'.pdf'} self.office_extensions = {'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx'} # Readable text formats (will be read with truncation) self.text_extensions = { '.txt', '.md', '.markdown', '.rst', '.log', '.csv', '.tsv', '.json', '.xml', '.yaml', '.yml', '.py', '.js', '.ts', '.java', '.c', '.cpp', '.h', '.hpp', '.go', '.rs', '.rb', '.php', '.html', '.css', '.scss', '.sass', '.less', '.vue', '.jsx', '.tsx', '.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat', '.cmd', '.sql', '.r', '.m', '.swift', '.kt', '.scala', '.clj', '.erl', '.ex', '.dockerfile', '.makefile', '.cmake', '.gradle', '.properties', '.ini', '.conf', '.cfg', } def execute(self, args: Dict[str, Any]) -> ToolResult: """ Execute file read operation :param args: Contains file path and optional offset/limit parameters :return: File content or error message """ # Support 'location' as alias for 'path' (LLM may use it from skill listing) path = args.get("path", "") or args.get("location", "") path = path.strip() if isinstance(path, str) else "" offset = args.get("offset") limit = args.get("limit") if not path: return ToolResult.fail("Error: path parameter is required") # Resolve path absolute_path = self._resolve_path(path) # Security check: Prevent reading sensitive config files env_config_path = expand_path("~/.cow/.env") if os.path.abspath(absolute_path) == os.path.abspath(env_config_path): return ToolResult.fail( "Error: Access denied. API keys and credentials must be accessed through the env_config tool only." ) # Check if file exists if not os.path.exists(absolute_path): # Provide helpful hint if using relative path if not os.path.isabs(path) and not path.startswith('~'): return ToolResult.fail( f"Error: File not found: {path}\n" f"Resolved to: {absolute_path}\n" f"Hint: Relative paths are based on workspace ({self.cwd}). For files outside workspace, use absolute paths." ) return ToolResult.fail(f"Error: File not found: {path}") # Check if readable if not os.access(absolute_path, os.R_OK): return ToolResult.fail(f"Error: File is not readable: {path}") # Check file type file_ext = Path(absolute_path).suffix.lower() file_size = os.path.getsize(absolute_path) # Check if image - return metadata for sending if file_ext in self.image_extensions: return self._read_image(absolute_path, file_ext) # Check if video/audio/binary/archive - return metadata only if file_ext in self.video_extensions: return self._return_file_metadata(absolute_path, "video", file_size) if file_ext in self.audio_extensions: return self._return_file_metadata(absolute_path, "audio", file_size) if file_ext in self.binary_extensions or file_ext in self.archive_extensions: return self._return_file_metadata(absolute_path, "binary", file_size) # Check if PDF if file_ext in self.pdf_extensions: return self._read_pdf(absolute_path, path, offset, limit) # Check if Office document (.docx, .xlsx, .pptx, etc.) if file_ext in self.office_extensions: return self._read_office(absolute_path, path, file_ext, offset, limit) # Read text file (with truncation for large files) return self._read_text(absolute_path, path, offset, limit) def _resolve_path(self, path: str) -> str: """ Resolve path to absolute path :param path: Relative or absolute path :return: Absolute path """ # Expand ~ to user home directory path = expand_path(path) if os.path.isabs(path): return path return os.path.abspath(os.path.join(self.cwd, path)) def _return_file_metadata(self, absolute_path: str, file_type: str, file_size: int) -> ToolResult: """ Return file metadata for non-readable files (video, audio, binary, etc.) :param absolute_path: Absolute path to the file :param file_type: Type of file (video, audio, binary, etc.) :param file_size: File size in bytes :return: File metadata """ file_name = Path(absolute_path).name file_ext = Path(absolute_path).suffix.lower() # Determine MIME type mime_types = { # Video '.mp4': 'video/mp4', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska', '.webm': 'video/webm', # Audio '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4', '.flac': 'audio/flac', # Binary '.zip': 'application/zip', '.tar': 'application/x-tar', '.gz': 'application/gzip', '.rar': 'application/x-rar-compressed', } mime_type = mime_types.get(file_ext, 'application/octet-stream') result = { "type": f"{file_type}_metadata", "file_type": file_type, "path": absolute_path, "file_name": file_name, "mime_type": mime_type, "size": file_size, "size_formatted": format_size(file_size), "message": f"{file_type.capitalize()} 文件: {file_name} ({format_size(file_size)})\n提示: 如果需要发送此文件,请使用 send 工具。" } return ToolResult.success(result) def _read_image(self, absolute_path: str, file_ext: str) -> ToolResult: """ Read image file - always return metadata only (images should be sent, not read into context) :param absolute_path: Absolute path to the image file :param file_ext: File extension :return: Result containing image metadata for sending """ try: # Get file size file_size = os.path.getsize(absolute_path) # Determine MIME type mime_type_map = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp' } mime_type = mime_type_map.get(file_ext, 'image/jpeg') # Return metadata for images (NOT file_to_send - use send tool to actually send) result = { "type": "image_metadata", "file_type": "image", "path": absolute_path, "mime_type": mime_type, "size": file_size, "size_formatted": format_size(file_size), "message": f"图片文件: {Path(absolute_path).name} ({format_size(file_size)})\n提示: 如果需要发送此图片,请使用 send 工具。" } return ToolResult.success(result) except Exception as e: return ToolResult.fail(f"Error reading image file: {str(e)}") def _read_text(self, absolute_path: str, display_path: str, offset: int = None, limit: int = None) -> ToolResult: """ Read text file :param absolute_path: Absolute path to the file :param display_path: Path to display :param offset: Starting line number (1-indexed) :param limit: Maximum number of lines to read :return: File content or error message """ try: # Check file size first file_size = os.path.getsize(absolute_path) MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB if file_size > MAX_FILE_SIZE: # File too large, return metadata only return ToolResult.success({ "type": "file_to_send", "file_type": "document", "path": absolute_path, "size": file_size, "size_formatted": format_size(file_size), "message": f"文件过大 ({format_size(file_size)} > 50MB),无法读取内容。文件路径: {absolute_path}" }) # Read file (utf-8-sig strips BOM automatically on Windows) with open(absolute_path, 'r', encoding='utf-8-sig') as f: content = f.read() # Truncate content if too long (20K characters max for model context) MAX_CONTENT_CHARS = 20 * 1024 # 20K characters content_truncated = False if len(content) > MAX_CONTENT_CHARS: content = content[:MAX_CONTENT_CHARS] content_truncated = True all_lines = content.split('\n') total_file_lines = len(all_lines) # Apply offset (if specified) start_line = 0 if offset is not None: if offset < 0: # Negative offset: read from end # -20 means "last 20 lines" → start from (total - 20) start_line = max(0, total_file_lines + offset) else: # Positive offset: read from start (1-indexed) start_line = max(0, offset - 1) # Convert to 0-indexed if start_line >= total_file_lines: return ToolResult.fail( f"Error: Offset {offset} is beyond end of file ({total_file_lines} lines total)" ) start_line_display = start_line + 1 # For display (1-indexed) # If user specified limit, use it selected_content = content user_limited_lines = None if limit is not None: end_line = min(start_line + limit, total_file_lines) selected_content = '\n'.join(all_lines[start_line:end_line]) user_limited_lines = end_line - start_line elif offset is not None: selected_content = '\n'.join(all_lines[start_line:]) # Apply truncation (considering line count and byte limits) truncation = truncate_head(selected_content) output_text = "" details = {} # Add truncation warning if content was truncated if content_truncated: output_text = f"[文件内容已截断到前 {format_size(MAX_CONTENT_CHARS)},完整文件大小: {format_size(file_size)}]\n\n" if truncation.first_line_exceeds_limit: # First line exceeds 30KB limit first_line_size = format_size(len(all_lines[start_line].encode('utf-8'))) output_text = f"[Line {start_line_display} is {first_line_size}, exceeds {format_size(DEFAULT_MAX_BYTES)} limit. Use bash tool to read: head -c {DEFAULT_MAX_BYTES} {display_path} | tail -n +{start_line_display}]" details["truncation"] = truncation.to_dict() elif truncation.truncated: # Truncation occurred end_line_display = start_line_display + truncation.output_lines - 1 next_offset = end_line_display + 1 output_text = truncation.content if truncation.truncated_by == "lines": output_text += f"\n\n[Showing lines {start_line_display}-{end_line_display} of {total_file_lines}. Use offset={next_offset} to continue.]" else: output_text += f"\n\n[Showing lines {start_line_display}-{end_line_display} of {total_file_lines} ({format_size(DEFAULT_MAX_BYTES)} limit). Use offset={next_offset} to continue.]" details["truncation"] = truncation.to_dict() elif user_limited_lines is not None and start_line + user_limited_lines < total_file_lines: # User specified limit, more content available, but no truncation remaining = total_file_lines - (start_line + user_limited_lines) next_offset = start_line + user_limited_lines + 1 output_text = truncation.content output_text += f"\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]" else: # No truncation, no exceeding user limit output_text = truncation.content result = { "content": output_text, "total_lines": total_file_lines, "start_line": start_line_display, "output_lines": truncation.output_lines } if details: result["details"] = details return ToolResult.success(result) except UnicodeDecodeError: return ToolResult.fail(f"Error: File is not a valid text file (encoding error): {display_path}") except Exception as e: return ToolResult.fail(f"Error reading file: {str(e)}") def _read_office(self, absolute_path: str, display_path: str, file_ext: str, offset: int = None, limit: int = None) -> ToolResult: """Read Office documents (.docx, .xlsx, .pptx) using python-docx / openpyxl / python-pptx.""" try: text = self._extract_office_text(absolute_path, file_ext) except ImportError as e: return ToolResult.fail(str(e)) except Exception as e: return ToolResult.fail(f"Error reading Office document: {e}") if not text or not text.strip(): return ToolResult.success({ "content": f"[Office file {Path(absolute_path).name}: no text content could be extracted]", }) all_lines = text.split('\n') total_lines = len(all_lines) start_line = 0 if offset is not None: if offset < 0: start_line = max(0, total_lines + offset) else: start_line = max(0, offset - 1) if start_line >= total_lines: return ToolResult.fail( f"Error: Offset {offset} is beyond end of content ({total_lines} lines total)" ) selected_content = text user_limited_lines = None if limit is not None: end_line = min(start_line + limit, total_lines) selected_content = '\n'.join(all_lines[start_line:end_line]) user_limited_lines = end_line - start_line elif offset is not None: selected_content = '\n'.join(all_lines[start_line:]) truncation = truncate_head(selected_content) start_line_display = start_line + 1 output_text = "" if truncation.truncated: end_line_display = start_line_display + truncation.output_lines - 1 next_offset = end_line_display + 1 output_text = truncation.content output_text += f"\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines}. Use offset={next_offset} to continue.]" elif user_limited_lines is not None and start_line + user_limited_lines < total_lines: remaining = total_lines - (start_line + user_limited_lines) next_offset = start_line + user_limited_lines + 1 output_text = truncation.content output_text += f"\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]" else: output_text = truncation.content return ToolResult.success({ "content": output_text, "total_lines": total_lines, "start_line": start_line_display, "output_lines": truncation.output_lines, }) @staticmethod def _extract_office_text(absolute_path: str, file_ext: str) -> str: """Extract plain text from an Office document.""" if file_ext in ('.docx', '.doc'): try: from docx import Document except ImportError: raise ImportError("Error: python-docx library not installed. Install with: pip install python-docx") doc = Document(absolute_path) paragraphs = [p.text for p in doc.paragraphs] for table in doc.tables: for row in table.rows: paragraphs.append('\t'.join(cell.text for cell in row.cells)) return '\n'.join(paragraphs) if file_ext in ('.xlsx', '.xls'): try: from openpyxl import load_workbook except ImportError: raise ImportError("Error: openpyxl library not installed. Install with: pip install openpyxl") wb = load_workbook(absolute_path, read_only=True, data_only=True) parts = [] for ws in wb.worksheets: parts.append(f"--- Sheet: {ws.title} ---") for row in ws.iter_rows(values_only=True): parts.append('\t'.join(str(c) if c is not None else '' for c in row)) wb.close() return '\n'.join(parts) if file_ext in ('.pptx', '.ppt'): try: from pptx import Presentation except ImportError: raise ImportError("Error: python-pptx library not installed. Install with: pip install python-pptx") prs = Presentation(absolute_path) parts = [] for i, slide in enumerate(prs.slides, 1): parts.append(f"--- Slide {i} ---") for shape in slide.shapes: if shape.has_text_frame: for para in shape.text_frame.paragraphs: text = para.text.strip() if text: parts.append(text) return '\n'.join(parts) return "" def _read_pdf(self, absolute_path: str, display_path: str, offset: int = None, limit: int = None) -> ToolResult: """ Read PDF file content :param absolute_path: Absolute path to the file :param display_path: Path to display :param offset: Starting line number (1-indexed) :param limit: Maximum number of lines to read :return: PDF text content or error message """ try: # Try to import pypdf try: from pypdf import PdfReader except ImportError: return ToolResult.fail( "Error: pypdf library not installed. Install with: pip install pypdf" ) # Read PDF reader = PdfReader(absolute_path) total_pages = len(reader.pages) # Extract text from all pages text_parts = [] for page_num, page in enumerate(reader.pages, 1): page_text = page.extract_text() if page_text.strip(): text_parts.append(f"--- Page {page_num} ---\n{page_text}") if not text_parts: return ToolResult.success({ "content": f"[PDF file with {total_pages} pages, but no text content could be extracted]", "total_pages": total_pages, "message": "PDF may contain only images or be encrypted" }) # Merge all text full_content = "\n\n".join(text_parts) all_lines = full_content.split('\n') total_lines = len(all_lines) # Apply offset and limit (same logic as text files) start_line = 0 if offset is not None: start_line = max(0, offset - 1) if start_line >= total_lines: return ToolResult.fail( f"Error: Offset {offset} is beyond end of content ({total_lines} lines total)" ) start_line_display = start_line + 1 selected_content = full_content user_limited_lines = None if limit is not None: end_line = min(start_line + limit, total_lines) selected_content = '\n'.join(all_lines[start_line:end_line]) user_limited_lines = end_line - start_line elif offset is not None: selected_content = '\n'.join(all_lines[start_line:]) # Apply truncation truncation = truncate_head(selected_content) output_text = "" details = {} if truncation.truncated: end_line_display = start_line_display + truncation.output_lines - 1 next_offset = end_line_display + 1 output_text = truncation.content if truncation.truncated_by == "lines": output_text += f"\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines}. Use offset={next_offset} to continue.]" else: output_text += f"\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines} ({format_size(DEFAULT_MAX_BYTES)} limit). Use offset={next_offset} to continue.]" details["truncation"] = truncation.to_dict() elif user_limited_lines is not None and start_line + user_limited_lines < total_lines: remaining = total_lines - (start_line + user_limited_lines) next_offset = start_line + user_limited_lines + 1 output_text = truncation.content output_text += f"\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]" else: output_text = truncation.content result = { "content": output_text, "total_pages": total_pages, "total_lines": total_lines, "start_line": start_line_display, "output_lines": truncation.output_lines } if details: result["details"] = details return ToolResult.success(result) except Exception as e: return ToolResult.fail(f"Error reading PDF file: {str(e)}") ================================================ FILE: agent/tools/scheduler/README.md ================================================ # 定时任务工具 (Scheduler Tool) ## 功能简介 定时任务工具允许 Agent 创建、管理和执行定时任务,支持: - ⏰ **定时提醒**: 在指定时间发送消息 - 🔄 **周期性任务**: 按固定间隔或 cron 表达式重复执行 - 🔧 **动态工具调用**: 定时执行其他工具并发送结果(如搜索新闻、查询天气等) - 📋 **任务管理**: 查询、启用、禁用、删除任务 ## 安装依赖 ```bash pip install croniter>=2.0.0 ``` ## 使用方法 ### 1. 创建定时任务 Agent 可以通过自然语言创建定时任务,支持两种类型: #### 1.1 静态消息任务 发送预定义的消息: **示例对话:** ``` 用户: 每天早上9点提醒我开会 Agent: [调用 scheduler 工具] action: create name: 每日开会提醒 message: 该开会了! schedule_type: cron schedule_value: 0 9 * * * ``` #### 1.2 动态工具调用任务 定时执行工具并发送结果: **示例对话:** ``` 用户: 每天早上8点帮我读取一下今日日程 Agent: [调用 scheduler 工具] action: create name: 每日日程 tool_call: tool_name: read tool_params: file_path: ~/cow/schedule.txt result_prefix: 📅 今日日程 schedule_type: cron schedule_value: 0 8 * * * ``` **工具调用参数说明:** - `tool_name`: 要调用的工具名称(如 `bash`、`read`、`write` 等内置工具) - `tool_params`: 工具的参数(字典格式) - `result_prefix`: 可选,在结果前添加的前缀文本 **注意:** 如果要使用 skills(如 bocha-search),需要通过 `bash` 工具调用 skill 脚本 ### 2. 支持的调度类型 #### Cron 表达式 (`cron`) 使用标准 cron 表达式: ``` 0 9 * * * # 每天 9:00 0 */2 * * * # 每 2 小时 30 8 * * 1-5 # 工作日 8:30 0 0 1 * * # 每月 1 号 ``` #### 固定间隔 (`interval`) 以秒为单位的间隔: ``` 3600 # 每小时 86400 # 每天 1800 # 每 30 分钟 ``` #### 一次性任务 (`once`) 指定具体时间(ISO 格式): ``` 2024-12-25T09:00:00 2024-12-31T23:59:59 ``` ### 3. 查询任务列表 ``` 用户: 查看我的定时任务 Agent: [调用 scheduler 工具] action: list ``` ### 4. 查看任务详情 ``` 用户: 查看任务 abc123 的详情 Agent: [调用 scheduler 工具] action: get task_id: abc123 ``` ### 5. 删除任务 ``` 用户: 删除任务 abc123 Agent: [调用 scheduler 工具] action: delete task_id: abc123 ``` ### 6. 启用/禁用任务 ``` 用户: 暂停任务 abc123 Agent: [调用 scheduler 工具] action: disable task_id: abc123 用户: 恢复任务 abc123 Agent: [调用 scheduler 工具] action: enable task_id: abc123 ``` ## 任务存储 任务保存在 JSON 文件中: ``` ~/cow/scheduler/tasks.json ``` 任务数据结构: **静态消息任务:** ```json { "id": "abc123", "name": "每日提醒", "enabled": true, "created_at": "2024-01-01T10:00:00", "updated_at": "2024-01-01T10:00:00", "schedule": { "type": "cron", "expression": "0 9 * * *" }, "action": { "type": "send_message", "content": "该开会了!", "receiver": "wxid_xxx", "receiver_name": "张三", "is_group": false, "channel_type": "wechat" }, "next_run_at": "2024-01-02T09:00:00", "last_run_at": "2024-01-01T09:00:00" } ``` **动态工具调用任务:** ```json { "id": "def456", "name": "每日日程", "enabled": true, "created_at": "2024-01-01T10:00:00", "updated_at": "2024-01-01T10:00:00", "schedule": { "type": "cron", "expression": "0 8 * * *" }, "action": { "type": "tool_call", "tool_name": "read", "tool_params": { "file_path": "~/cow/schedule.txt" }, "result_prefix": "📅 今日日程", "receiver": "wxid_xxx", "receiver_name": "张三", "is_group": false, "channel_type": "wechat" }, "next_run_at": "2024-01-02T08:00:00" } ``` ## 后台服务 定时任务由后台服务 `SchedulerService` 管理: - 每 30 秒检查一次到期任务 - 自动执行到期任务 - 计算下次执行时间 - 记录执行历史和错误 服务在 Agent 初始化时自动启动,无需手动配置。 ## 接收者确定 定时任务会发送给**创建任务时的对话对象**: - 如果在私聊中创建,发送给该用户 - 如果在群聊中创建,发送到该群 - 接收者信息在创建时自动保存 ## 常见用例 ### 1. 每日提醒(静态消息) ``` 用户: 每天早上8点提醒我吃药 Agent: ✅ 定时任务创建成功 任务ID: a1b2c3d4 调度: 每天 8:00 消息: 该吃药了! ``` ### 2. 工作日提醒(静态消息) ``` 用户: 工作日下午6点提醒我下班 Agent: [创建 cron: 0 18 * * 1-5] 消息: 该下班了! ``` ### 3. 倒计时提醒(静态消息) ``` 用户: 1小时后提醒我 Agent: [创建 interval: 3600] ``` ### 4. 每日日程推送(动态工具调用) ``` 用户: 每天早上8点帮我读取今日日程 Agent: ✅ 定时任务创建成功 任务ID: schedule001 调度: 每天 8:00 工具: read(file_path='~/cow/schedule.txt') 前缀: 📅 今日日程 ``` ### 5. 定时文件备份(动态工具调用) ``` 用户: 每天晚上11点备份工作文件 Agent: [创建 cron: 0 23 * * *] 工具: bash(command='cp ~/cow/work.txt ~/cow/backup/work_$(date +%Y%m%d).txt') 前缀: ✅ 文件已备份 ``` ### 6. 周报提醒(静态消息) ``` 用户: 每周五下午5点提醒我写周报 Agent: [创建 cron: 0 17 * * 5] 消息: 📊 该写周报了! ``` ### 4. 特定日期提醒 ``` 用户: 12月25日早上9点提醒我圣诞快乐 Agent: [创建 once: 2024-12-25T09:00:00] ``` ## 注意事项 1. **时区**: 使用系统本地时区 2. **精度**: 检查间隔为 30 秒,实际执行可能有 ±30 秒误差 3. **持久化**: 任务保存在文件中,重启后自动恢复 4. **一次性任务**: 执行后自动禁用,不会删除(可手动删除) 5. **错误处理**: 执行失败会记录错误,不影响其他任务 ## 技术实现 - **TaskStore**: 任务持久化存储 - **SchedulerService**: 后台调度服务 - **SchedulerTool**: Agent 工具接口 - **Integration**: 与 AgentBridge 集成 ## 依赖 - `croniter`: Cron 表达式解析(轻量级,仅 ~50KB) ================================================ FILE: agent/tools/scheduler/__init__.py ================================================ """ Scheduler tool for managing scheduled tasks """ from .scheduler_tool import SchedulerTool __all__ = ["SchedulerTool"] ================================================ FILE: agent/tools/scheduler/integration.py ================================================ """ Integration module for scheduler with AgentBridge """ import os from typing import Optional from config import conf from common.log import logger from common.utils import expand_path from bridge.context import Context, ContextType from bridge.reply import Reply, ReplyType # Global scheduler service instance _scheduler_service = None _task_store = None def init_scheduler(agent_bridge) -> bool: """ Initialize scheduler service Args: agent_bridge: AgentBridge instance Returns: True if initialized successfully """ global _scheduler_service, _task_store try: from agent.tools.scheduler.task_store import TaskStore from agent.tools.scheduler.scheduler_service import SchedulerService # Get workspace from config workspace_root = expand_path(conf().get("agent_workspace", "~/cow")) store_path = os.path.join(workspace_root, "scheduler", "tasks.json") # Create task store _task_store = TaskStore(store_path) logger.debug(f"[Scheduler] Task store initialized: {store_path}") # Create execute callback def execute_task_callback(task: dict): """Callback to execute a scheduled task""" try: action = task.get("action", {}) action_type = action.get("type") if action_type == "agent_task": _execute_agent_task(task, agent_bridge) elif action_type == "send_message": # Legacy support for old tasks _execute_send_message(task, agent_bridge) elif action_type == "tool_call": # Legacy support for old tasks _execute_tool_call(task, agent_bridge) elif action_type == "skill_call": # Legacy support for old tasks _execute_skill_call(task, agent_bridge) else: logger.warning(f"[Scheduler] Unknown action type: {action_type}") except Exception as e: logger.error(f"[Scheduler] Error executing task {task.get('id')}: {e}") # Create scheduler service _scheduler_service = SchedulerService(_task_store, execute_task_callback) _scheduler_service.start() logger.debug("[Scheduler] Scheduler service initialized and started") return True except Exception as e: logger.error(f"[Scheduler] Failed to initialize scheduler: {e}") return False def get_task_store(): """Get the global task store instance""" return _task_store def get_scheduler_service(): """Get the global scheduler service instance""" return _scheduler_service def _execute_agent_task(task: dict, agent_bridge): """ Execute an agent_task action - let Agent handle the task Args: task: Task dictionary agent_bridge: AgentBridge instance """ try: action = task.get("action", {}) task_description = action.get("task_description") receiver = action.get("receiver") is_group = action.get("is_group", False) channel_type = action.get("channel_type", "unknown") if not task_description: logger.error(f"[Scheduler] Task {task['id']}: No task_description specified") return if not receiver: logger.error(f"[Scheduler] Task {task['id']}: No receiver specified") return # Check for unsupported channels if channel_type == "dingtalk": logger.warning(f"[Scheduler] Task {task['id']}: DingTalk channel does not support scheduled messages (Stream mode limitation). Task will execute but message cannot be sent.") logger.info(f"[Scheduler] Task {task['id']}: Executing agent task '{task_description}'") # Create a unique session_id for this scheduled task to avoid polluting user's conversation # Format: scheduler__ to ensure isolation scheduler_session_id = f"scheduler_{receiver}_{task['id']}" # Create context for Agent context = Context(ContextType.TEXT, task_description) context["receiver"] = receiver context["isgroup"] = is_group context["session_id"] = scheduler_session_id # Channel-specific setup if channel_type == "web": import uuid request_id = f"scheduler_{task['id']}_{uuid.uuid4().hex[:8]}" context["request_id"] = request_id elif channel_type == "feishu": context["receive_id_type"] = "chat_id" if is_group else "open_id" context["msg"] = None elif channel_type == "dingtalk": # DingTalk requires msg object, set to None for scheduled tasks context["msg"] = None if not is_group: sender_staff_id = action.get("dingtalk_sender_staff_id") if sender_staff_id: context["dingtalk_sender_staff_id"] = sender_staff_id elif channel_type == "wecom_bot": context["msg"] = None # Use Agent to execute the task # Mark this as a scheduled task execution to prevent recursive task creation context["is_scheduled_task"] = True try: # Don't clear history - scheduler tasks use isolated session_id so they won't pollute user conversations reply = agent_bridge.agent_reply(task_description, context=context, on_event=None, clear_history=False) if reply and reply.content: # Send the reply via channel from channel.channel_factory import create_channel try: channel = create_channel(channel_type) if channel: # For web channel, register request_id if channel_type == "web" and hasattr(channel, 'request_to_session'): request_id = context.get("request_id") if request_id: channel.request_to_session[request_id] = receiver logger.debug(f"[Scheduler] Registered request_id {request_id} -> session {receiver}") # Send the reply channel.send(reply, context) logger.info(f"[Scheduler] Task {task['id']} executed successfully, result sent to {receiver}") else: logger.error(f"[Scheduler] Failed to create channel: {channel_type}") except Exception as e: logger.error(f"[Scheduler] Failed to send result: {e}") else: logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution") except Exception as e: logger.error(f"[Scheduler] Failed to execute task via Agent: {e}") import traceback logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}") except Exception as e: logger.error(f"[Scheduler] Error in _execute_agent_task: {e}") import traceback logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}") def _execute_send_message(task: dict, agent_bridge): """ Execute a send_message action Args: task: Task dictionary agent_bridge: AgentBridge instance """ try: action = task.get("action", {}) content = action.get("content", "") receiver = action.get("receiver") is_group = action.get("is_group", False) channel_type = action.get("channel_type", "unknown") if not receiver: logger.error(f"[Scheduler] Task {task['id']}: No receiver specified") return # Create context for sending message context = Context(ContextType.TEXT, content) context["receiver"] = receiver context["isgroup"] = is_group context["session_id"] = receiver # Channel-specific context setup if channel_type == "web": # Web channel needs request_id import uuid request_id = f"scheduler_{task['id']}_{uuid.uuid4().hex[:8]}" context["request_id"] = request_id logger.debug(f"[Scheduler] Generated request_id for web channel: {request_id}") elif channel_type == "feishu": # Feishu channel: for scheduled tasks, send as new message (no msg_id to reply to) # Use chat_id for groups, open_id for private chats context["receive_id_type"] = "chat_id" if is_group else "open_id" # Keep isgroup as is, but set msg to None (no original message to reply to) # Feishu channel will detect this and send as new message instead of reply context["msg"] = None logger.debug(f"[Scheduler] Feishu: receive_id_type={context['receive_id_type']}, is_group={is_group}, receiver={receiver}") elif channel_type == "dingtalk": # DingTalk channel setup context["msg"] = None # 如果是单聊,需要传递 sender_staff_id if not is_group: sender_staff_id = action.get("dingtalk_sender_staff_id") if sender_staff_id: context["dingtalk_sender_staff_id"] = sender_staff_id logger.debug(f"[Scheduler] DingTalk single chat: sender_staff_id={sender_staff_id}") else: logger.warning(f"[Scheduler] Task {task['id']}: DingTalk single chat message missing sender_staff_id") elif channel_type == "wecom_bot": context["msg"] = None elif channel_type == "qq": context["msg"] = None # Create reply reply = Reply(ReplyType.TEXT, content) # Get channel and send from channel.channel_factory import create_channel try: channel = create_channel(channel_type) if channel: # For web channel, register the request_id to session mapping if channel_type == "web" and hasattr(channel, 'request_to_session'): channel.request_to_session[request_id] = receiver logger.debug(f"[Scheduler] Registered request_id {request_id} -> session {receiver}") channel.send(reply, context) logger.info(f"[Scheduler] Task {task['id']} executed: sent message to {receiver}") else: logger.error(f"[Scheduler] Failed to create channel: {channel_type}") except Exception as e: logger.error(f"[Scheduler] Failed to send message: {e}") import traceback logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}") except Exception as e: logger.error(f"[Scheduler] Error in _execute_send_message: {e}") import traceback logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}") def _execute_tool_call(task: dict, agent_bridge): """ Execute a tool_call action Args: task: Task dictionary agent_bridge: AgentBridge instance """ try: action = task.get("action", {}) # Support both old and new field names tool_name = action.get("call_name") or action.get("tool_name") tool_params = action.get("call_params") or action.get("tool_params", {}) result_prefix = action.get("result_prefix", "") receiver = action.get("receiver") is_group = action.get("is_group", False) channel_type = action.get("channel_type", "unknown") if not tool_name: logger.error(f"[Scheduler] Task {task['id']}: No tool_name specified") return if not receiver: logger.error(f"[Scheduler] Task {task['id']}: No receiver specified") return # Get tool manager and create tool instance from agent.tools.tool_manager import ToolManager tool_manager = ToolManager() tool = tool_manager.create_tool(tool_name) if not tool: logger.error(f"[Scheduler] Task {task['id']}: Tool '{tool_name}' not found") return # Execute tool logger.info(f"[Scheduler] Task {task['id']}: Executing tool '{tool_name}' with params {tool_params}") result = tool.execute(tool_params) # Get result content if hasattr(result, 'result'): content = result.result else: content = str(result) # Add prefix if specified if result_prefix: content = f"{result_prefix}\n\n{content}" # Send result as message context = Context(ContextType.TEXT, content) context["receiver"] = receiver context["isgroup"] = is_group context["session_id"] = receiver # Channel-specific context setup if channel_type == "web": # Web channel needs request_id import uuid request_id = f"scheduler_{task['id']}_{uuid.uuid4().hex[:8]}" context["request_id"] = request_id logger.debug(f"[Scheduler] Generated request_id for web channel: {request_id}") elif channel_type == "feishu": context["receive_id_type"] = "chat_id" if is_group else "open_id" context["msg"] = None logger.debug(f"[Scheduler] Feishu: receive_id_type={context['receive_id_type']}, is_group={is_group}, receiver={receiver}") elif channel_type == "wecom_bot": context["msg"] = None reply = Reply(ReplyType.TEXT, content) # Get channel and send from channel.channel_factory import create_channel try: channel = create_channel(channel_type) if channel: if channel_type == "web" and hasattr(channel, 'request_to_session'): channel.request_to_session[request_id] = receiver logger.debug(f"[Scheduler] Registered request_id {request_id} -> session {receiver}") channel.send(reply, context) logger.info(f"[Scheduler] Task {task['id']} executed: sent tool result to {receiver}") else: logger.error(f"[Scheduler] Failed to create channel: {channel_type}") except Exception as e: logger.error(f"[Scheduler] Failed to send tool result: {e}") except Exception as e: logger.error(f"[Scheduler] Error in _execute_tool_call: {e}") def _execute_skill_call(task: dict, agent_bridge): """ Execute a skill_call action by asking Agent to run the skill Args: task: Task dictionary agent_bridge: AgentBridge instance """ try: action = task.get("action", {}) # Support both old and new field names skill_name = action.get("call_name") or action.get("skill_name") skill_params = action.get("call_params") or action.get("skill_params", {}) result_prefix = action.get("result_prefix", "") receiver = action.get("receiver") is_group = action.get("isgroup", False) channel_type = action.get("channel_type", "unknown") if not skill_name: logger.error(f"[Scheduler] Task {task['id']}: No skill_name specified") return if not receiver: logger.error(f"[Scheduler] Task {task['id']}: No receiver specified") return logger.info(f"[Scheduler] Task {task['id']}: Executing skill '{skill_name}' with params {skill_params}") # Create a unique session_id for this scheduled task to avoid polluting user's conversation # Format: scheduler__ to ensure isolation scheduler_session_id = f"scheduler_{receiver}_{task['id']}" # Build a natural language query for the Agent to execute the skill # Format: "Use skill-name to do something with params" param_str = ", ".join([f"{k}={v}" for k, v in skill_params.items()]) query = f"Use {skill_name} skill" if param_str: query += f" with {param_str}" # Create context for Agent context = Context(ContextType.TEXT, query) context["receiver"] = receiver context["isgroup"] = is_group context["session_id"] = scheduler_session_id # Channel-specific setup if channel_type == "web": import uuid request_id = f"scheduler_{task['id']}_{uuid.uuid4().hex[:8]}" context["request_id"] = request_id elif channel_type == "feishu": context["receive_id_type"] = "chat_id" if is_group else "open_id" context["msg"] = None elif channel_type == "wecom_bot": context["msg"] = None # Use Agent to execute the skill try: # Don't clear history - scheduler tasks use isolated session_id so they won't pollute user conversations reply = agent_bridge.agent_reply(query, context=context, on_event=None, clear_history=False) if reply and reply.content: content = reply.content # Add prefix if specified if result_prefix: content = f"{result_prefix}\n\n{content}" logger.info(f"[Scheduler] Task {task['id']} executed: skill result sent to {receiver}") else: logger.error(f"[Scheduler] Task {task['id']}: No result from skill execution") except Exception as e: logger.error(f"[Scheduler] Failed to execute skill via Agent: {e}") import traceback logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}") except Exception as e: logger.error(f"[Scheduler] Error in _execute_skill_call: {e}") import traceback logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}") def attach_scheduler_to_tool(tool, context: Context = None): """ Attach scheduler components to a SchedulerTool instance Args: tool: SchedulerTool instance context: Current context (optional) """ if _task_store: tool.task_store = _task_store if context: tool.current_context = context channel_type = context.get("channel_type") or conf().get("channel_type", "unknown") if not tool.config: tool.config = {} tool.config["channel_type"] = channel_type ================================================ FILE: agent/tools/scheduler/scheduler_service.py ================================================ """ Background scheduler service for executing scheduled tasks """ import time import threading from datetime import datetime, timedelta from typing import Callable, Optional from croniter import croniter from common.log import logger class SchedulerService: """ Background service that executes scheduled tasks """ def __init__(self, task_store, execute_callback: Callable): """ Initialize scheduler service Args: task_store: TaskStore instance execute_callback: Function to call when executing a task """ self.task_store = task_store self.execute_callback = execute_callback self.running = False self.thread = None self._lock = threading.Lock() def start(self): """Start the scheduler service""" with self._lock: if self.running: logger.warning("[Scheduler] Service already running") return self.running = True self.thread = threading.Thread(target=self._run_loop, daemon=True) self.thread.start() logger.debug("[Scheduler] Service started") def stop(self): """Stop the scheduler service""" with self._lock: if not self.running: return self.running = False if self.thread: self.thread.join(timeout=5) logger.info("[Scheduler] Service stopped") def _run_loop(self): """Main scheduler loop""" logger.debug("[Scheduler] Scheduler loop started") while self.running: try: self._check_and_execute_tasks() except Exception as e: logger.error(f"[Scheduler] Error in scheduler loop: {e}") time.sleep(30) def _check_and_execute_tasks(self): """Check for due tasks and execute them""" now = datetime.now() tasks = self.task_store.list_tasks(enabled_only=True) for task in tasks: try: # Check if task is due if self._is_task_due(task, now): logger.info(f"[Scheduler] Executing task: {task['id']} - {task['name']}") self._execute_task(task) # Update next run time next_run = self._calculate_next_run(task, now) if next_run: self.task_store.update_task(task['id'], { "next_run_at": next_run.isoformat(), "last_run_at": now.isoformat() }) else: # One-time task completed, remove it self.task_store.delete_task(task['id']) logger.info(f"[Scheduler] One-time task completed and removed: {task['id']}") except Exception as e: logger.error(f"[Scheduler] Error processing task {task.get('id')}: {e}") def _is_task_due(self, task: dict, now: datetime) -> bool: """ Check if a task is due to run Args: task: Task dictionary now: Current datetime Returns: True if task should run now """ next_run_str = task.get("next_run_at") if not next_run_str: # Calculate initial next_run_at next_run = self._calculate_next_run(task, now) if next_run: self.task_store.update_task(task['id'], { "next_run_at": next_run.isoformat() }) return False return False try: next_run = datetime.fromisoformat(next_run_str) # Check if task is overdue (e.g., service restart) if next_run < now: time_diff = (now - next_run).total_seconds() # If overdue by more than 5 minutes, skip this run and schedule next if time_diff > 300: # 5 minutes logger.warning(f"[Scheduler] Task {task['id']} is overdue by {int(time_diff)}s, skipping and scheduling next run") # For one-time tasks, remove them directly schedule = task.get("schedule", {}) if schedule.get("type") == "once": self.task_store.delete_task(task['id']) logger.info(f"[Scheduler] One-time task {task['id']} expired, removed") return False # For recurring tasks, calculate next run from now next_next_run = self._calculate_next_run(task, now) if next_next_run: self.task_store.update_task(task['id'], { "next_run_at": next_next_run.isoformat() }) logger.info(f"[Scheduler] Rescheduled task {task['id']} to {next_next_run}") return False return now >= next_run except Exception: return False def _calculate_next_run(self, task: dict, from_time: datetime) -> Optional[datetime]: """ Calculate next run time for a task Args: task: Task dictionary from_time: Calculate from this time Returns: Next run datetime or None for one-time tasks """ schedule = task.get("schedule", {}) schedule_type = schedule.get("type") if schedule_type == "cron": # Cron expression expression = schedule.get("expression") if not expression: return None try: cron = croniter(expression, from_time) return cron.get_next(datetime) except Exception as e: logger.error(f"[Scheduler] Invalid cron expression '{expression}': {e}") return None elif schedule_type == "interval": # Interval in seconds seconds = schedule.get("seconds", 0) if seconds <= 0: return None return from_time + timedelta(seconds=seconds) elif schedule_type == "once": # One-time task at specific time run_at_str = schedule.get("run_at") if not run_at_str: return None try: run_at = datetime.fromisoformat(run_at_str) # Only return if in the future if run_at > from_time: return run_at except Exception: pass return None return None def _execute_task(self, task: dict): """ Execute a task Args: task: Task dictionary """ try: # Call the execute callback self.execute_callback(task) except Exception as e: logger.error(f"[Scheduler] Error executing task {task['id']}: {e}") # Update task with error self.task_store.update_task(task['id'], { "last_error": str(e), "last_error_at": datetime.now().isoformat() }) ================================================ FILE: agent/tools/scheduler/scheduler_tool.py ================================================ """ Scheduler tool for creating and managing scheduled tasks """ import uuid from datetime import datetime from typing import Any, Dict, Optional from croniter import croniter from agent.tools.base_tool import BaseTool, ToolResult from bridge.context import Context, ContextType from bridge.reply import Reply, ReplyType from common.log import logger class SchedulerTool(BaseTool): """ Tool for managing scheduled tasks (reminders, notifications, etc.) """ name: str = "scheduler" description: str = ( "创建、查询和管理定时任务(提醒、周期性任务等)。\n\n" "⚠️ 重要:仅当需要「定时/提醒/每天/每周/X分钟后/X点」等延迟或周期执行时才使用此工具。" "使用方法:\n" "- 创建:action='create', name='任务名', message/ai_task='内容', schedule_type='once/interval/cron', schedule_value='...'\n" "- 查询:action='list' / action='get', task_id='任务ID'\n" "- 管理:action='delete/enable/disable', task_id='任务ID'\n\n" "调度类型:\n" "- once: 一次性任务,支持相对时间(+5s,+10m,+1h,+1d)或ISO时间\n" "- interval: 固定间隔(秒),如3600表示每小时\n" "- cron: cron表达式,如'0 8 * * *'表示每天8点\n\n" "注意:'X秒后'用once+相对时间,'每X秒'用interval" ) params: dict = { "type": "object", "properties": { "action": { "type": "string", "enum": ["create", "list", "get", "delete", "enable", "disable"], "description": "操作类型: create(创建), list(列表), get(查询), delete(删除), enable(启用), disable(禁用)" }, "task_id": { "type": "string", "description": "任务ID (用于 get/delete/enable/disable 操作)" }, "name": { "type": "string", "description": "任务名称 (用于 create 操作)" }, "message": { "type": "string", "description": "固定消息内容 (与ai_task二选一)" }, "ai_task": { "type": "string", "description": "AI任务描述 (与message二选一),用于定时让AI执行的任务" }, "schedule_type": { "type": "string", "enum": ["cron", "interval", "once"], "description": "调度类型 (用于 create 操作): cron(cron表达式), interval(固定间隔秒数), once(一次性)" }, "schedule_value": { "type": "string", "description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)" } }, "required": ["action"] } def __init__(self, config: dict = None): super().__init__() self.config = config or {} # Will be set by agent bridge self.task_store = None self.current_context = None def execute(self, params: dict) -> ToolResult: """ Execute scheduler operations Args: params: Dictionary containing: - action: Operation type (create/list/get/delete/enable/disable) - Other parameters depending on action Returns: ToolResult object """ # Extract parameters action = params.get("action") kwargs = params if not self.task_store: return ToolResult.fail("错误: 定时任务系统未初始化") try: if action == "create": result = self._create_task(**kwargs) return ToolResult.success(result) elif action == "list": result = self._list_tasks(**kwargs) return ToolResult.success(result) elif action == "get": result = self._get_task(**kwargs) return ToolResult.success(result) elif action == "delete": result = self._delete_task(**kwargs) return ToolResult.success(result) elif action == "enable": result = self._enable_task(**kwargs) return ToolResult.success(result) elif action == "disable": result = self._disable_task(**kwargs) return ToolResult.success(result) else: return ToolResult.fail(f"未知操作: {action}") except Exception as e: logger.error(f"[SchedulerTool] Error: {e}") return ToolResult.fail(f"操作失败: {str(e)}") def _create_task(self, **kwargs) -> str: """Create a new scheduled task""" name = kwargs.get("name") message = kwargs.get("message") ai_task = kwargs.get("ai_task") schedule_type = kwargs.get("schedule_type") schedule_value = kwargs.get("schedule_value") # Validate required fields if not name: return "错误: 缺少任务名称 (name)" # Check that exactly one of message/ai_task is provided if not message and not ai_task: return "错误: 必须提供 message(固定消息)或 ai_task(AI任务)之一" if message and ai_task: return "错误: message 和 ai_task 只能提供其中一个" if not schedule_type: return "错误: 缺少调度类型 (schedule_type)" if not schedule_value: return "错误: 缺少调度值 (schedule_value)" # Validate schedule schedule = self._parse_schedule(schedule_type, schedule_value) if not schedule: return f"错误: 无效的调度配置 - type: {schedule_type}, value: {schedule_value}" # Get context info for receiver if not self.current_context: return "错误: 无法获取当前对话上下文" context = self.current_context # Create task task_id = str(uuid.uuid4())[:8] # Build action based on message or ai_task if message: action = { "type": "send_message", "content": message, "receiver": context.get("receiver"), "receiver_name": self._get_receiver_name(context), "is_group": context.get("isgroup", False), "channel_type": self.config.get("channel_type", "unknown") } else: # ai_task action = { "type": "agent_task", "task_description": ai_task, "receiver": context.get("receiver"), "receiver_name": self._get_receiver_name(context), "is_group": context.get("isgroup", False), "channel_type": self.config.get("channel_type", "unknown") } # 针对钉钉单聊,额外存储 sender_staff_id msg = context.kwargs.get("msg") if msg and hasattr(msg, 'sender_staff_id') and not context.get("isgroup", False): action["dingtalk_sender_staff_id"] = msg.sender_staff_id task_data = { "id": task_id, "name": name, "enabled": True, "created_at": datetime.now().isoformat(), "updated_at": datetime.now().isoformat(), "schedule": schedule, "action": action } # Calculate initial next_run_at next_run = self._calculate_next_run(task_data) if next_run: task_data["next_run_at"] = next_run.isoformat() # Save task self.task_store.add_task(task_data) # Format response schedule_desc = self._format_schedule_description(schedule) receiver_desc = task_data["action"]["receiver_name"] or task_data["action"]["receiver"] if message: content_desc = f"💬 固定消息: {message}" else: content_desc = f"🤖 AI任务: {ai_task}" return ( f"✅ 定时任务创建成功\n\n" f"📋 任务ID: {task_id}\n" f"📝 名称: {name}\n" f"⏰ 调度: {schedule_desc}\n" f"👤 接收者: {receiver_desc}\n" f"{content_desc}\n" f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}" ) def _list_tasks(self, **kwargs) -> str: """List all tasks""" tasks = self.task_store.list_tasks() if not tasks: return "📋 暂无定时任务" lines = [f"📋 定时任务列表 (共 {len(tasks)} 个)\n"] for task in tasks: status = "✅" if task.get("enabled", True) else "❌" schedule_desc = self._format_schedule_description(task.get("schedule", {})) next_run = task.get("next_run_at") next_run_str = datetime.fromisoformat(next_run).strftime('%m-%d %H:%M') if next_run else "未知" lines.append( f"{status} [{task['id']}] {task['name']}\n" f" ⏰ {schedule_desc} | 下次: {next_run_str}" ) return "\n".join(lines) def _get_task(self, **kwargs) -> str: """Get task details""" task_id = kwargs.get("task_id") if not task_id: return "错误: 缺少任务ID (task_id)" task = self.task_store.get_task(task_id) if not task: return f"错误: 任务 '{task_id}' 不存在" status = "启用" if task.get("enabled", True) else "禁用" schedule_desc = self._format_schedule_description(task.get("schedule", {})) action = task.get("action", {}) next_run = task.get("next_run_at") next_run_str = datetime.fromisoformat(next_run).strftime('%Y-%m-%d %H:%M:%S') if next_run else "未知" last_run = task.get("last_run_at") last_run_str = datetime.fromisoformat(last_run).strftime('%Y-%m-%d %H:%M:%S') if last_run else "从未执行" return ( f"📋 任务详情\n\n" f"ID: {task['id']}\n" f"名称: {task['name']}\n" f"状态: {status}\n" f"调度: {schedule_desc}\n" f"接收者: {action.get('receiver_name', action.get('receiver'))}\n" f"消息: {action.get('content')}\n" f"下次执行: {next_run_str}\n" f"上次执行: {last_run_str}\n" f"创建时间: {datetime.fromisoformat(task['created_at']).strftime('%Y-%m-%d %H:%M:%S')}" ) def _delete_task(self, **kwargs) -> str: """Delete a task""" task_id = kwargs.get("task_id") if not task_id: return "错误: 缺少任务ID (task_id)" task = self.task_store.get_task(task_id) if not task: return f"错误: 任务 '{task_id}' 不存在" self.task_store.delete_task(task_id) return f"✅ 任务 '{task['name']}' ({task_id}) 已删除" def _enable_task(self, **kwargs) -> str: """Enable a task""" task_id = kwargs.get("task_id") if not task_id: return "错误: 缺少任务ID (task_id)" task = self.task_store.get_task(task_id) if not task: return f"错误: 任务 '{task_id}' 不存在" self.task_store.enable_task(task_id, True) return f"✅ 任务 '{task['name']}' ({task_id}) 已启用" def _disable_task(self, **kwargs) -> str: """Disable a task""" task_id = kwargs.get("task_id") if not task_id: return "错误: 缺少任务ID (task_id)" task = self.task_store.get_task(task_id) if not task: return f"错误: 任务 '{task_id}' 不存在" self.task_store.enable_task(task_id, False) return f"✅ 任务 '{task['name']}' ({task_id}) 已禁用" def _parse_schedule(self, schedule_type: str, schedule_value: str) -> Optional[dict]: """Parse and validate schedule configuration""" try: if schedule_type == "cron": # Validate cron expression croniter(schedule_value) return {"type": "cron", "expression": schedule_value} elif schedule_type == "interval": # Parse interval in seconds seconds = int(schedule_value) if seconds <= 0: return None return {"type": "interval", "seconds": seconds} elif schedule_type == "once": # Parse datetime - support both relative and absolute time # Check if it's relative time (e.g., "+5s", "+10m", "+1h", "+1d") if schedule_value.startswith("+"): import re match = re.match(r'\+(\d+)([smhd])', schedule_value) if match: amount = int(match.group(1)) unit = match.group(2) from datetime import timedelta now = datetime.now() if unit == 's': # seconds target_time = now + timedelta(seconds=amount) elif unit == 'm': # minutes target_time = now + timedelta(minutes=amount) elif unit == 'h': # hours target_time = now + timedelta(hours=amount) elif unit == 'd': # days target_time = now + timedelta(days=amount) else: return None return {"type": "once", "run_at": target_time.isoformat()} else: logger.error(f"[SchedulerTool] Invalid relative time format: {schedule_value}") return None else: # Absolute time in ISO format datetime.fromisoformat(schedule_value) return {"type": "once", "run_at": schedule_value} except Exception as e: logger.error(f"[SchedulerTool] Invalid schedule: {e}") return None return None def _calculate_next_run(self, task: dict) -> Optional[datetime]: """Calculate next run time for a task""" schedule = task.get("schedule", {}) schedule_type = schedule.get("type") now = datetime.now() if schedule_type == "cron": expression = schedule.get("expression") cron = croniter(expression, now) return cron.get_next(datetime) elif schedule_type == "interval": seconds = schedule.get("seconds", 0) from datetime import timedelta return now + timedelta(seconds=seconds) elif schedule_type == "once": run_at_str = schedule.get("run_at") return datetime.fromisoformat(run_at_str) return None def _format_schedule_description(self, schedule: dict) -> str: """Format schedule as human-readable description""" schedule_type = schedule.get("type") if schedule_type == "cron": expr = schedule.get("expression", "") # Try to provide friendly description if expr == "0 9 * * *": return "每天 9:00" elif expr == "0 */1 * * *": return "每小时" elif expr == "*/30 * * * *": return "每30分钟" else: return f"Cron: {expr}" elif schedule_type == "interval": seconds = schedule.get("seconds", 0) if seconds >= 86400: days = seconds // 86400 return f"每 {days} 天" elif seconds >= 3600: hours = seconds // 3600 return f"每 {hours} 小时" elif seconds >= 60: minutes = seconds // 60 return f"每 {minutes} 分钟" else: return f"每 {seconds} 秒" elif schedule_type == "once": run_at = schedule.get("run_at", "") try: dt = datetime.fromisoformat(run_at) return f"一次性 ({dt.strftime('%Y-%m-%d %H:%M')})" except Exception: return "一次性" return "未知" def _get_receiver_name(self, context: Context) -> str: """Get receiver name from context""" try: msg = context.get("msg") if msg: if context.get("isgroup"): return msg.other_user_nickname or "群聊" else: return msg.from_user_nickname or "用户" except Exception: pass return "未知" ================================================ FILE: agent/tools/scheduler/task_store.py ================================================ """ Task storage management for scheduler """ import json import os import threading from datetime import datetime from typing import Dict, List, Optional from pathlib import Path from common.utils import expand_path class TaskStore: """ Manages persistent storage of scheduled tasks """ def __init__(self, store_path: str = None): """ Initialize task store Args: store_path: Path to tasks.json file. Defaults to ~/cow/scheduler/tasks.json """ if store_path is None: # Default to ~/cow/scheduler/tasks.json home = expand_path("~") store_path = os.path.join(home, "cow", "scheduler", "tasks.json") self.store_path = store_path self.lock = threading.Lock() self._ensure_store_dir() def _ensure_store_dir(self): """Ensure the storage directory exists""" store_dir = os.path.dirname(self.store_path) os.makedirs(store_dir, exist_ok=True) def load_tasks(self) -> Dict[str, dict]: """ Load all tasks from storage Returns: Dictionary of task_id -> task_data """ with self.lock: if not os.path.exists(self.store_path): return {} try: with open(self.store_path, 'r', encoding='utf-8') as f: data = json.load(f) return data.get("tasks", {}) except Exception as e: print(f"Error loading tasks: {e}") return {} def save_tasks(self, tasks: Dict[str, dict]): """ Save all tasks to storage Args: tasks: Dictionary of task_id -> task_data """ with self.lock: try: # Create backup if os.path.exists(self.store_path): backup_path = f"{self.store_path}.bak" try: with open(self.store_path, 'r') as src: with open(backup_path, 'w') as dst: dst.write(src.read()) except Exception: pass # Save tasks data = { "version": 1, "updated_at": datetime.now().isoformat(), "tasks": tasks } with open(self.store_path, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2) except Exception as e: print(f"Error saving tasks: {e}") raise def add_task(self, task: dict) -> bool: """ Add a new task Args: task: Task data dictionary Returns: True if successful """ tasks = self.load_tasks() task_id = task.get("id") if not task_id: raise ValueError("Task must have an 'id' field") if task_id in tasks: raise ValueError(f"Task with id '{task_id}' already exists") tasks[task_id] = task self.save_tasks(tasks) return True def update_task(self, task_id: str, updates: dict) -> bool: """ Update an existing task Args: task_id: Task ID updates: Dictionary of fields to update Returns: True if successful """ tasks = self.load_tasks() if task_id not in tasks: raise ValueError(f"Task '{task_id}' not found") # Update fields tasks[task_id].update(updates) tasks[task_id]["updated_at"] = datetime.now().isoformat() self.save_tasks(tasks) return True def delete_task(self, task_id: str) -> bool: """ Delete a task Args: task_id: Task ID Returns: True if successful """ tasks = self.load_tasks() if task_id not in tasks: raise ValueError(f"Task '{task_id}' not found") del tasks[task_id] self.save_tasks(tasks) return True def get_task(self, task_id: str) -> Optional[dict]: """ Get a specific task Args: task_id: Task ID Returns: Task data or None if not found """ tasks = self.load_tasks() return tasks.get(task_id) def list_tasks(self, enabled_only: bool = False) -> List[dict]: """ List all tasks Args: enabled_only: If True, only return enabled tasks Returns: List of task dictionaries """ tasks = self.load_tasks() task_list = list(tasks.values()) if enabled_only: task_list = [t for t in task_list if t.get("enabled", True)] # Sort by next_run_at task_list.sort(key=lambda t: t.get("next_run_at", float('inf'))) return task_list def enable_task(self, task_id: str, enabled: bool = True) -> bool: """ Enable or disable a task Args: task_id: Task ID enabled: True to enable, False to disable Returns: True if successful """ return self.update_task(task_id, {"enabled": enabled}) ================================================ FILE: agent/tools/send/__init__.py ================================================ from .send import Send __all__ = ['Send'] ================================================ FILE: agent/tools/send/send.py ================================================ """ Send tool - Send files to the user """ import os from typing import Dict, Any from pathlib import Path from agent.tools.base_tool import BaseTool, ToolResult from common.utils import expand_path class Send(BaseTool): """Tool for sending files to the user""" name: str = "send" description: str = "Send a LOCAL file (image, video, audio, document) to the user. Only for local file paths. Do NOT use this for URLs — URLs should be included directly in your text reply, the system will handle them automatically." params: dict = { "type": "object", "properties": { "path": { "type": "string", "description": "Local file path to send. Must be an absolute path or relative to workspace. Do NOT pass URLs here." }, "message": { "type": "string", "description": "Optional message to accompany the file" } }, "required": ["path"] } def __init__(self, config: dict = None): self.config = config or {} self.cwd = self.config.get("cwd", os.getcwd()) # Supported file types self.image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.ico'} self.video_extensions = {'.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm', '.m4v'} self.audio_extensions = {'.mp3', '.wav', '.ogg', '.m4a', '.flac', '.aac', '.wma'} self.document_extensions = {'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.md'} def execute(self, args: Dict[str, Any]) -> ToolResult: """ Execute file send operation :param args: Contains file path and optional message :return: File metadata for channel to send """ path = args.get("path", "").strip() message = args.get("message", "") if not path: return ToolResult.fail("Error: path parameter is required") # Resolve path absolute_path = self._resolve_path(path) # Check if file exists if not os.path.exists(absolute_path): return ToolResult.fail(f"Error: File not found: {path}") # Check if readable if not os.access(absolute_path, os.R_OK): return ToolResult.fail(f"Error: File is not readable: {path}") # Get file info file_ext = Path(absolute_path).suffix.lower() file_size = os.path.getsize(absolute_path) file_name = Path(absolute_path).name # Determine file type if file_ext in self.image_extensions: file_type = "image" mime_type = self._get_image_mime_type(file_ext) elif file_ext in self.video_extensions: file_type = "video" mime_type = self._get_video_mime_type(file_ext) elif file_ext in self.audio_extensions: file_type = "audio" mime_type = self._get_audio_mime_type(file_ext) elif file_ext in self.document_extensions: file_type = "document" mime_type = self._get_document_mime_type(file_ext) else: file_type = "file" mime_type = "application/octet-stream" # Return file_to_send metadata result = { "type": "file_to_send", "file_type": file_type, "path": absolute_path, "file_name": file_name, "mime_type": mime_type, "size": file_size, "size_formatted": self._format_size(file_size), "message": message or f"正在发送 {file_name}" } return ToolResult.success(result) def _resolve_path(self, path: str) -> str: """Resolve path to absolute path""" path = expand_path(path) if os.path.isabs(path): return path return os.path.abspath(os.path.join(self.cwd, path)) def _get_image_mime_type(self, ext: str) -> str: """Get MIME type for image""" mime_map = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' } return mime_map.get(ext, 'image/jpeg') def _get_video_mime_type(self, ext: str) -> str: """Get MIME type for video""" mime_map = { '.mp4': 'video/mp4', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska', '.webm': 'video/webm', '.flv': 'video/x-flv' } return mime_map.get(ext, 'video/mp4') def _get_audio_mime_type(self, ext: str) -> str: """Get MIME type for audio""" mime_map = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4', '.flac': 'audio/flac', '.aac': 'audio/aac' } return mime_map.get(ext, 'audio/mpeg') def _get_document_mime_type(self, ext: str) -> str: """Get MIME type for document""" mime_map = { '.pdf': 'application/pdf', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', '.txt': 'text/plain', '.md': 'text/markdown' } return mime_map.get(ext, 'application/octet-stream') def _format_size(self, size_bytes: int) -> str: """Format file size in human-readable format""" for unit in ['B', 'KB', 'MB', 'GB']: if size_bytes < 1024.0: return f"{size_bytes:.1f}{unit}" size_bytes /= 1024.0 return f"{size_bytes:.1f}TB" ================================================ FILE: agent/tools/tool_manager.py ================================================ import importlib import importlib.util from pathlib import Path from typing import Dict, Any, Type from agent.tools.base_tool import BaseTool from common.log import logger from config import conf class ToolManager: """ Tool manager for managing tools. """ _instance = None def __new__(cls): """Singleton pattern to ensure only one instance of ToolManager exists.""" if cls._instance is None: cls._instance = super(ToolManager, cls).__new__(cls) cls._instance.tool_classes = {} # Store tool classes instead of instances cls._instance._initialized = False return cls._instance def __init__(self): # Initialize only once if not hasattr(self, 'tool_classes'): self.tool_classes = {} # Dictionary to store tool classes def load_tools(self, tools_dir: str = "", config_dict=None): """ Load tools from both directory and configuration. :param tools_dir: Directory to scan for tool modules """ if tools_dir: self._load_tools_from_directory(tools_dir) self._configure_tools_from_config() else: self._load_tools_from_init() self._configure_tools_from_config(config_dict) def _load_tools_from_init(self) -> bool: """ Load tool classes from tools.__init__.__all__ :return: True if tools were loaded, False otherwise """ try: # Try to import the tools package tools_package = importlib.import_module("agent.tools") # Check if __all__ is defined if hasattr(tools_package, "__all__"): tool_classes = tools_package.__all__ # Import each tool class directly from the tools package for class_name in tool_classes: try: # Skip base classes if class_name in ["BaseTool", "ToolManager"]: continue # Get the class directly from the tools package if hasattr(tools_package, class_name): cls = getattr(tools_package, class_name) if ( isinstance(cls, type) and issubclass(cls, BaseTool) and cls != BaseTool ): try: # Skip memory tools (they need special initialization with memory_manager) if class_name in ["MemorySearchTool", "MemoryGetTool"]: logger.debug(f"Skipped tool {class_name} (requires memory_manager)") continue # Create a temporary instance to get the name temp_instance = cls() tool_name = temp_instance.name # Store the class, not the instance self.tool_classes[tool_name] = cls logger.debug(f"Loaded tool: {tool_name} from class {class_name}") except ImportError as e: # Handle missing dependencies with helpful messages error_msg = str(e) if "browser-use" in error_msg or "browser_use" in error_msg: logger.warning( f"[ToolManager] Browser tool not loaded - missing dependencies.\n" f" To enable browser tool, run:\n" f" pip install browser-use markdownify playwright\n" f" playwright install chromium" ) elif "markdownify" in error_msg: logger.warning( f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n" f" Install with: pip install markdownify" ) else: logger.warning(f"[ToolManager] {cls.__name__} not loaded due to missing dependency: {error_msg}") except Exception as e: logger.error(f"Error initializing tool class {cls.__name__}: {e}") except Exception as e: logger.error(f"Error importing class {class_name}: {e}") return len(self.tool_classes) > 0 return False except ImportError: logger.warning("Could not import agent.tools package") return False except Exception as e: logger.error(f"Error loading tools from __init__.__all__: {e}") return False def _load_tools_from_directory(self, tools_dir: str): """Dynamically load tool classes from directory""" tools_path = Path(tools_dir) # Traverse all .py files for py_file in tools_path.rglob("*.py"): # Skip initialization files and base tool files if py_file.name in ["__init__.py", "base_tool.py", "tool_manager.py"]: continue # Get module name module_name = py_file.stem try: # Load module directly from file spec = importlib.util.spec_from_file_location(module_name, py_file) if spec and spec.loader: module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) # Find tool classes in the module for attr_name in dir(module): cls = getattr(module, attr_name) if ( isinstance(cls, type) and issubclass(cls, BaseTool) and cls != BaseTool ): try: # Skip memory tools (they need special initialization with memory_manager) if attr_name in ["MemorySearchTool", "MemoryGetTool"]: logger.debug(f"Skipped tool {attr_name} (requires memory_manager)") continue # Create a temporary instance to get the name temp_instance = cls() tool_name = temp_instance.name # Store the class, not the instance self.tool_classes[tool_name] = cls except ImportError as e: # Handle missing dependencies with helpful messages error_msg = str(e) if "browser-use" in error_msg or "browser_use" in error_msg: logger.warning( f"[ToolManager] Browser tool not loaded - missing dependencies.\n" f" To enable browser tool, run:\n" f" pip install browser-use markdownify playwright\n" f" playwright install chromium" ) elif "markdownify" in error_msg: logger.warning( f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n" f" Install with: pip install markdownify" ) else: logger.warning(f"[ToolManager] {cls.__name__} not loaded due to missing dependency: {error_msg}") except Exception as e: logger.error(f"Error initializing tool class {cls.__name__}: {e}") except Exception as e: print(f"Error importing module {py_file}: {e}") def _configure_tools_from_config(self, config_dict=None): """Configure tool classes based on configuration file""" try: # Get tools configuration tools_config = config_dict or conf().get("tools", {}) # Record tools that are configured but not loaded missing_tools = [] # Store configurations for later use when instantiating self.tool_configs = tools_config # Check which configured tools are missing for tool_name in tools_config: if tool_name not in self.tool_classes: missing_tools.append(tool_name) # If there are missing tools, record warnings if missing_tools: for tool_name in missing_tools: if tool_name == "browser": logger.warning( f"[ToolManager] Browser tool is configured but not loaded.\n" f" To enable browser tool, run:\n" f" pip install browser-use markdownify playwright\n" f" playwright install chromium" ) elif tool_name == "google_search": logger.warning( f"[ToolManager] Google Search tool is configured but may need API key.\n" f" Get API key from: https://serper.dev\n" f" Configure in config.json: tools.google_search.api_key" ) else: logger.warning(f"[ToolManager] Tool '{tool_name}' is configured but could not be loaded.") except Exception as e: logger.error(f"Error configuring tools from config: {e}") def create_tool(self, name: str) -> BaseTool: """ Get a new instance of a tool by name. :param name: The name of the tool to get. :return: A new instance of the tool or None if not found. """ tool_class = self.tool_classes.get(name) if tool_class: # Create a new instance tool_instance = tool_class() # Apply configuration if available if hasattr(self, 'tool_configs') and name in self.tool_configs: tool_instance.config = self.tool_configs[name] return tool_instance return None def list_tools(self) -> dict: """ Get information about all loaded tools. :return: A dictionary with tool information. """ result = {} for name, tool_class in self.tool_classes.items(): # Create a temporary instance to get schema temp_instance = tool_class() result[name] = { "description": temp_instance.description, "parameters": temp_instance.get_json_schema() } return result ================================================ FILE: agent/tools/utils/__init__.py ================================================ from .truncate import ( truncate_head, truncate_tail, truncate_line, format_size, TruncationResult, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES, GREP_MAX_LINE_LENGTH ) from .diff import ( strip_bom, detect_line_ending, normalize_to_lf, restore_line_endings, normalize_for_fuzzy_match, fuzzy_find_text, generate_diff_string, FuzzyMatchResult ) __all__ = [ 'truncate_head', 'truncate_tail', 'truncate_line', 'format_size', 'TruncationResult', 'DEFAULT_MAX_LINES', 'DEFAULT_MAX_BYTES', 'GREP_MAX_LINE_LENGTH', 'strip_bom', 'detect_line_ending', 'normalize_to_lf', 'restore_line_endings', 'normalize_for_fuzzy_match', 'fuzzy_find_text', 'generate_diff_string', 'FuzzyMatchResult' ] ================================================ FILE: agent/tools/utils/diff.py ================================================ """ Diff tools for file editing Provides fuzzy matching and diff generation functionality """ import difflib import re from typing import Optional, Tuple def strip_bom(text: str) -> Tuple[str, str]: """ Remove BOM (Byte Order Mark) :param text: Original text :return: (BOM, text after removing BOM) """ if text.startswith('\ufeff'): return '\ufeff', text[1:] return '', text def detect_line_ending(text: str) -> str: """ Detect line ending type :param text: Text content :return: Line ending type ('\r\n' or '\n') """ if '\r\n' in text: return '\r\n' return '\n' def normalize_to_lf(text: str) -> str: """ Normalize all line endings to LF (\n) :param text: Original text :return: Normalized text """ return text.replace('\r\n', '\n').replace('\r', '\n') def restore_line_endings(text: str, original_ending: str) -> str: """ Restore original line endings :param text: LF normalized text :param original_ending: Original line ending :return: Text with restored line endings """ if original_ending == '\r\n': return text.replace('\n', '\r\n') return text def normalize_for_fuzzy_match(text: str) -> str: """ Normalize text for fuzzy matching Remove excess whitespace but preserve basic structure :param text: Original text :return: Normalized text """ # Compress multiple spaces to one text = re.sub(r'[ \t]+', ' ', text) # Remove trailing spaces text = re.sub(r' +\n', '\n', text) # Remove leading spaces (but preserve indentation structure, only remove excess) lines = text.split('\n') normalized_lines = [] for line in lines: # Preserve indentation but normalize to multiples of single spaces stripped = line.lstrip() if stripped: indent_count = len(line) - len(stripped) # Normalize indentation (convert tabs to spaces) normalized_indent = ' ' * indent_count normalized_lines.append(normalized_indent + stripped) else: normalized_lines.append('') return '\n'.join(normalized_lines) class FuzzyMatchResult: """Fuzzy match result""" def __init__(self, found: bool, index: int = -1, match_length: int = 0, content_for_replacement: str = ""): self.found = found self.index = index self.match_length = match_length self.content_for_replacement = content_for_replacement def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult: """ Find text in content, try exact match first, then fuzzy match :param content: Content to search in :param old_text: Text to find :return: Match result """ # First try exact match index = content.find(old_text) if index != -1: return FuzzyMatchResult( found=True, index=index, match_length=len(old_text), content_for_replacement=content ) # Try fuzzy match fuzzy_content = normalize_for_fuzzy_match(content) fuzzy_old_text = normalize_for_fuzzy_match(old_text) index = fuzzy_content.find(fuzzy_old_text) if index != -1: # Fuzzy match successful, use normalized content for replacement return FuzzyMatchResult( found=True, index=index, match_length=len(fuzzy_old_text), content_for_replacement=fuzzy_content ) # Not found return FuzzyMatchResult(found=False) def generate_diff_string(old_content: str, new_content: str) -> dict: """ Generate unified diff string :param old_content: Old content :param new_content: New content :return: Dictionary containing diff and first changed line number """ old_lines = old_content.split('\n') new_lines = new_content.split('\n') # Generate unified diff diff_lines = list(difflib.unified_diff( old_lines, new_lines, lineterm='', fromfile='original', tofile='modified' )) # Find first changed line number first_changed_line = None for line in diff_lines: if line.startswith('@@'): # Parse @@ -1,3 +1,3 @@ format match = re.search(r'@@ -\d+,?\d* \+(\d+)', line) if match: first_changed_line = int(match.group(1)) break diff_string = '\n'.join(diff_lines) return { 'diff': diff_string, 'first_changed_line': first_changed_line } ================================================ FILE: agent/tools/utils/truncate.py ================================================ """ Shared truncation utilities for tool outputs. Truncation is based on two independent limits - whichever is hit first wins: - Line limit (default: 2000 lines) - Byte limit (default: 50KB) Never returns partial lines (except bash tail truncation edge case). """ from typing import Dict, Any, Optional, Literal, Tuple DEFAULT_MAX_LINES = 2000 DEFAULT_MAX_BYTES = 50 * 1024 # 50KB GREP_MAX_LINE_LENGTH = 500 # Max chars per grep match line class TruncationResult: """Truncation result""" def __init__( self, content: str, truncated: bool, truncated_by: Optional[Literal["lines", "bytes"]], total_lines: int, total_bytes: int, output_lines: int, output_bytes: int, last_line_partial: bool = False, first_line_exceeds_limit: bool = False, max_lines: int = DEFAULT_MAX_LINES, max_bytes: int = DEFAULT_MAX_BYTES ): self.content = content self.truncated = truncated self.truncated_by = truncated_by self.total_lines = total_lines self.total_bytes = total_bytes self.output_lines = output_lines self.output_bytes = output_bytes self.last_line_partial = last_line_partial self.first_line_exceeds_limit = first_line_exceeds_limit self.max_lines = max_lines self.max_bytes = max_bytes def to_dict(self) -> Dict[str, Any]: """Convert to dictionary""" return { "content": self.content, "truncated": self.truncated, "truncated_by": self.truncated_by, "total_lines": self.total_lines, "total_bytes": self.total_bytes, "output_lines": self.output_lines, "output_bytes": self.output_bytes, "last_line_partial": self.last_line_partial, "first_line_exceeds_limit": self.first_line_exceeds_limit, "max_lines": self.max_lines, "max_bytes": self.max_bytes } def format_size(bytes_count: int) -> str: """Format bytes as human-readable size""" if bytes_count < 1024: return f"{bytes_count}B" elif bytes_count < 1024 * 1024: return f"{bytes_count / 1024:.1f}KB" else: return f"{bytes_count / (1024 * 1024):.1f}MB" def truncate_head(content: str, max_lines: Optional[int] = None, max_bytes: Optional[int] = None) -> TruncationResult: """ Truncate content from the head (keep first N lines/bytes). Suitable for file reads where you want to see the beginning. Never returns partial lines. If first line exceeds byte limit, returns empty content with first_line_exceeds_limit=True. :param content: Content to truncate :param max_lines: Maximum number of lines (default: 2000) :param max_bytes: Maximum number of bytes (default: 50KB) :return: Truncation result """ if max_lines is None: max_lines = DEFAULT_MAX_LINES if max_bytes is None: max_bytes = DEFAULT_MAX_BYTES total_bytes = len(content.encode('utf-8')) lines = content.split('\n') total_lines = len(lines) # Check if no truncation is needed if total_lines <= max_lines and total_bytes <= max_bytes: return TruncationResult( content=content, truncated=False, truncated_by=None, total_lines=total_lines, total_bytes=total_bytes, output_lines=total_lines, output_bytes=total_bytes, last_line_partial=False, first_line_exceeds_limit=False, max_lines=max_lines, max_bytes=max_bytes ) # Check if first line alone exceeds byte limit first_line_bytes = len(lines[0].encode('utf-8')) if first_line_bytes > max_bytes: return TruncationResult( content="", truncated=True, truncated_by="bytes", total_lines=total_lines, total_bytes=total_bytes, output_lines=0, output_bytes=0, last_line_partial=False, first_line_exceeds_limit=True, max_lines=max_lines, max_bytes=max_bytes ) # Collect complete lines that fit output_lines_arr = [] output_bytes_count = 0 truncated_by = "lines" for i, line in enumerate(lines): if i >= max_lines: break # Calculate line bytes (add 1 for newline if not first line) line_bytes = len(line.encode('utf-8')) + (1 if i > 0 else 0) if output_bytes_count + line_bytes > max_bytes: truncated_by = "bytes" break output_lines_arr.append(line) output_bytes_count += line_bytes # If exited due to line limit if len(output_lines_arr) >= max_lines and output_bytes_count <= max_bytes: truncated_by = "lines" output_content = '\n'.join(output_lines_arr) final_output_bytes = len(output_content.encode('utf-8')) return TruncationResult( content=output_content, truncated=True, truncated_by=truncated_by, total_lines=total_lines, total_bytes=total_bytes, output_lines=len(output_lines_arr), output_bytes=final_output_bytes, last_line_partial=False, first_line_exceeds_limit=False, max_lines=max_lines, max_bytes=max_bytes ) def truncate_tail(content: str, max_lines: Optional[int] = None, max_bytes: Optional[int] = None) -> TruncationResult: """ Truncate content from tail (keep last N lines/bytes). Suitable for bash output where you want to see the ending content (errors, final results). If the last line of original content exceeds byte limit, may return partial first line. :param content: Content to truncate :param max_lines: Maximum lines (default: 2000) :param max_bytes: Maximum bytes (default: 50KB) :return: Truncation result """ if max_lines is None: max_lines = DEFAULT_MAX_LINES if max_bytes is None: max_bytes = DEFAULT_MAX_BYTES total_bytes = len(content.encode('utf-8')) lines = content.split('\n') total_lines = len(lines) # Check if no truncation is needed if total_lines <= max_lines and total_bytes <= max_bytes: return TruncationResult( content=content, truncated=False, truncated_by=None, total_lines=total_lines, total_bytes=total_bytes, output_lines=total_lines, output_bytes=total_bytes, last_line_partial=False, first_line_exceeds_limit=False, max_lines=max_lines, max_bytes=max_bytes ) # Work backwards from the end output_lines_arr = [] output_bytes_count = 0 truncated_by = "lines" last_line_partial = False for i in range(len(lines) - 1, -1, -1): if len(output_lines_arr) >= max_lines: break line = lines[i] # Calculate line bytes (add newline if not the first added line) line_bytes = len(line.encode('utf-8')) + (1 if len(output_lines_arr) > 0 else 0) if output_bytes_count + line_bytes > max_bytes: truncated_by = "bytes" # Edge case: if we haven't added any lines yet and this line exceeds maxBytes, # take the end portion of this line if len(output_lines_arr) == 0: truncated_line = _truncate_string_to_bytes_from_end(line, max_bytes) output_lines_arr.insert(0, truncated_line) output_bytes_count = len(truncated_line.encode('utf-8')) last_line_partial = True break output_lines_arr.insert(0, line) output_bytes_count += line_bytes # If exited due to line limit if len(output_lines_arr) >= max_lines and output_bytes_count <= max_bytes: truncated_by = "lines" output_content = '\n'.join(output_lines_arr) final_output_bytes = len(output_content.encode('utf-8')) return TruncationResult( content=output_content, truncated=True, truncated_by=truncated_by, total_lines=total_lines, total_bytes=total_bytes, output_lines=len(output_lines_arr), output_bytes=final_output_bytes, last_line_partial=last_line_partial, first_line_exceeds_limit=False, max_lines=max_lines, max_bytes=max_bytes ) def _truncate_string_to_bytes_from_end(text: str, max_bytes: int) -> str: """ Truncate string to fit byte limit (from end). Properly handles multi-byte UTF-8 characters. :param text: String to truncate :param max_bytes: Maximum bytes :return: Truncated string """ encoded = text.encode('utf-8') if len(encoded) <= max_bytes: return text # Start from end, skip back maxBytes start = len(encoded) - max_bytes # Find valid UTF-8 boundary (character start) while start < len(encoded) and (encoded[start] & 0xC0) == 0x80: start += 1 return encoded[start:].decode('utf-8', errors='ignore') def truncate_line(line: str, max_chars: int = GREP_MAX_LINE_LENGTH) -> Tuple[str, bool]: """ Truncate single line to max characters, add [truncated] suffix. Used for grep match lines. :param line: Line to truncate :param max_chars: Maximum characters :return: (truncated text, whether truncated) """ if len(line) <= max_chars: return line, False return f"{line[:max_chars]}... [truncated]", True ================================================ FILE: agent/tools/vision/__init__.py ================================================ from agent.tools.vision.vision import Vision ================================================ FILE: agent/tools/vision/vision.py ================================================ """ Vision tool - Analyze images using OpenAI-compatible Vision API. Supports local files (auto base64-encoded) and HTTP URLs. Providers: OpenAI (preferred) > LinkAI (fallback). """ import base64 import os import subprocess import tempfile from typing import Any, Dict, Optional, Tuple import requests from agent.tools.base_tool import BaseTool, ToolResult from common.log import logger from config import conf DEFAULT_MODEL = "gpt-4.1-mini" DEFAULT_TIMEOUT = 60 MAX_TOKENS = 1000 COMPRESS_THRESHOLD = 1_048_576 # 1 MB SUPPORTED_EXTENSIONS = { "jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp", } class Vision(BaseTool): """Analyze images using OpenAI-compatible Vision API""" name: str = "vision" description: str = ( "Analyze a local image or image URL (jpg/jpeg/png) using Vision API. " "Can describe content, extract text, identify objects, colors, etc. " "Requires OPENAI_API_KEY or LINKAI_API_KEY." ) params: dict = { "type": "object", "properties": { "image": { "type": "string", "description": "Local file path or HTTP(S) URL of the image to analyze", }, "question": { "type": "string", "description": "Question to ask about the image", }, "model": { "type": "string", "description": ( f"Vision model to use (default: {DEFAULT_MODEL}). " "Options: gpt-4.1-mini, gpt-4.1, gpt-4o-mini, gpt-4o" ), }, }, "required": ["image", "question"], } def __init__(self, config: dict = None): self.config = config or {} @staticmethod def is_available() -> bool: return bool( conf().get("open_ai_api_key") or os.environ.get("OPENAI_API_KEY") or conf().get("linkai_api_key") or os.environ.get("LINKAI_API_KEY") ) def execute(self, args: Dict[str, Any]) -> ToolResult: image = args.get("image", "").strip() question = args.get("question", "").strip() model = args.get("model", DEFAULT_MODEL).strip() or DEFAULT_MODEL if not image: return ToolResult.fail("Error: 'image' parameter is required") if not question: return ToolResult.fail("Error: 'question' parameter is required") api_key, api_base, extra_headers = self._resolve_provider() if not api_key: return ToolResult.fail( "Error: No API key configured for Vision.\n" "Please configure one of the following using env_config tool:\n" " 1. OPENAI_API_KEY (preferred): env_config(action=\"set\", key=\"OPENAI_API_KEY\", value=\"your-key\")\n" " 2. LINKAI_API_KEY (fallback): env_config(action=\"set\", key=\"LINKAI_API_KEY\", value=\"your-key\")\n\n" "Get your key at: https://platform.openai.com/api-keys or https://link-ai.tech" ) try: image_content = self._build_image_content(image) except Exception as e: return ToolResult.fail(f"Error: {e}") try: return self._call_api(api_key, api_base, model, question, image_content, extra_headers) except requests.Timeout: return ToolResult.fail(f"Error: Vision API request timed out after {DEFAULT_TIMEOUT}s") except requests.ConnectionError: return ToolResult.fail("Error: Failed to connect to Vision API") except Exception as e: logger.error(f"[Vision] Unexpected error: {e}", exc_info=True) return ToolResult.fail(f"Error: Vision API call failed - {e}") def _resolve_provider(self) -> Tuple[Optional[str], str, dict]: """Resolve API key, base URL and extra headers. Priority: conf() > env vars.""" api_key = conf().get("open_ai_api_key") or os.environ.get("OPENAI_API_KEY") if api_key: api_base = (conf().get("open_ai_api_base") or os.environ.get("OPENAI_API_BASE", "")).rstrip("/") \ or "https://api.openai.com/v1" return api_key, self._ensure_v1(api_base), {} api_key = conf().get("linkai_api_key") or os.environ.get("LINKAI_API_KEY") if api_key: api_base = (conf().get("linkai_api_base") or os.environ.get("LINKAI_API_BASE", "")).rstrip("/") \ or "https://api.link-ai.tech" logger.debug("[Vision] Using LinkAI API (OPENAI_API_KEY not set)") from common.utils import get_cloud_headers extra = get_cloud_headers(api_key) extra.pop("Authorization", None) extra.pop("Content-Type", None) return api_key, self._ensure_v1(api_base), extra return None, "", {} @staticmethod def _ensure_v1(api_base: str) -> str: """Append /v1 if the base URL doesn't already end with a versioned path.""" if not api_base: return api_base # Already has /v1 or similar version suffix if api_base.rstrip("/").split("/")[-1].startswith("v"): return api_base return api_base.rstrip("/") + "/v1" def _build_image_content(self, image: str) -> dict: """Build the image_url content block for the API request.""" if image.startswith(("http://", "https://")): return {"type": "image_url", "image_url": {"url": image}} if not os.path.isfile(image): raise FileNotFoundError(f"Image file not found: {image}") ext = image.rsplit(".", 1)[-1].lower() if "." in image else "" mime_type = SUPPORTED_EXTENSIONS.get(ext) if not mime_type: raise ValueError( f"Unsupported image format '.{ext}'. " f"Supported: {', '.join(SUPPORTED_EXTENSIONS.keys())}" ) file_path = self._maybe_compress(image) try: with open(file_path, "rb") as f: b64 = base64.b64encode(f.read()).decode("ascii") finally: if file_path != image and os.path.exists(file_path): os.remove(file_path) data_url = f"data:{mime_type};base64,{b64}" return {"type": "image_url", "image_url": {"url": data_url}} @staticmethod def _maybe_compress(path: str) -> str: """Compress image if larger than threshold; return path to use.""" file_size = os.path.getsize(path) if file_size <= COMPRESS_THRESHOLD: return path tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) tmp.close() try: # macOS: use sips subprocess.run( ["sips", "-Z", "800", path, "--out", tmp.name], capture_output=True, check=True, ) logger.debug(f"[Vision] Compressed image ({file_size // 1024}KB -> {os.path.getsize(tmp.name) // 1024}KB)") return tmp.name except (FileNotFoundError, subprocess.CalledProcessError): pass try: # Linux: use ImageMagick convert subprocess.run( ["convert", path, "-resize", "800x800>", tmp.name], capture_output=True, check=True, ) logger.debug(f"[Vision] Compressed image ({file_size // 1024}KB -> {os.path.getsize(tmp.name) // 1024}KB)") return tmp.name except (FileNotFoundError, subprocess.CalledProcessError): pass os.remove(tmp.name) return path def _call_api(self, api_key: str, api_base: str, model: str, question: str, image_content: dict, extra_headers: dict = None) -> ToolResult: payload = { "model": model, "messages": [ { "role": "user", "content": [ {"type": "text", "text": question}, image_content, ], } ], "max_tokens": MAX_TOKENS, } headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", **(extra_headers or {}), } resp = requests.post( f"{api_base}/chat/completions", headers=headers, json=payload, timeout=DEFAULT_TIMEOUT, ) if resp.status_code == 401: return ToolResult.fail("Error: Invalid API key. Please check your configuration.") if resp.status_code == 429: return ToolResult.fail("Error: API rate limit reached. Please try again later.") if resp.status_code != 200: return ToolResult.fail(f"Error: Vision API returned HTTP {resp.status_code}: {resp.text[:200]}") data = resp.json() if "error" in data: msg = data["error"].get("message", "Unknown API error") return ToolResult.fail(f"Error: Vision API error - {msg}") content = "" choices = data.get("choices", []) if choices: content = choices[0].get("message", {}).get("content", "") usage = data.get("usage", {}) result = { "model": model, "content": content, "usage": { "prompt_tokens": usage.get("prompt_tokens", 0), "completion_tokens": usage.get("completion_tokens", 0), "total_tokens": usage.get("total_tokens", 0), }, } return ToolResult.success(result) ================================================ FILE: agent/tools/web_fetch/__init__.py ================================================ ================================================ FILE: agent/tools/web_fetch/web_fetch.py ================================================ """ Web Fetch tool - Fetch and extract readable content from web pages and remote files. Supports: - HTML web pages: extracts readable text content - Document files (PDF, Word, TXT, Markdown, etc.): downloads to workspace/tmp and parses content """ import os import re import uuid from typing import Dict, Any, Optional, Set from urllib.parse import urlparse, unquote import requests from agent.tools.base_tool import BaseTool, ToolResult from agent.tools.utils.truncate import truncate_head, format_size from common.log import logger DEFAULT_TIMEOUT = 30 MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB DEFAULT_HEADERS = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", "Accept": "*/*", } # Supported document file extensions PDF_SUFFIXES: Set[str] = {".pdf"} WORD_SUFFIXES: Set[str] = {".docx"} TEXT_SUFFIXES: Set[str] = {".txt", ".md", ".markdown", ".rst", ".csv", ".tsv", ".log"} SPREADSHEET_SUFFIXES: Set[str] = {".xls", ".xlsx"} PPT_SUFFIXES: Set[str] = {".ppt", ".pptx"} ALL_DOC_SUFFIXES = PDF_SUFFIXES | WORD_SUFFIXES | TEXT_SUFFIXES | SPREADSHEET_SUFFIXES | PPT_SUFFIXES _CHARSET_RE = re.compile(r'charset\s*=\s*["\']?\s*([\w\-]+)', re.IGNORECASE) _META_CHARSET_RE = re.compile(rb']+charset\s*=\s*["\']?\s*([\w\-]+)', re.IGNORECASE) _META_HTTP_EQUIV_RE = re.compile( rb']+http-equiv\s*=\s*["\']?Content-Type["\']?[^>]+content\s*=\s*["\'][^"\']*charset=([\w\-]+)', re.IGNORECASE, ) def _extract_charset_from_content_type(content_type: str) -> Optional[str]: """Extract charset from Content-Type header value.""" m = _CHARSET_RE.search(content_type) return m.group(1) if m else None def _extract_charset_from_html_meta(raw_bytes: bytes) -> Optional[str]: """Extract charset from HTML tags in the first few KB of raw bytes.""" m = _META_CHARSET_RE.search(raw_bytes) if m: return m.group(1).decode("ascii", errors="ignore") m = _META_HTTP_EQUIV_RE.search(raw_bytes) if m: return m.group(1).decode("ascii", errors="ignore") return None def _get_url_suffix(url: str) -> str: """Extract file extension from URL path, ignoring query params.""" path = urlparse(url).path return os.path.splitext(path)[-1].lower() def _is_document_url(url: str) -> bool: """Check if URL points to a downloadable document file.""" suffix = _get_url_suffix(url) return suffix in ALL_DOC_SUFFIXES class WebFetch(BaseTool): """Tool for fetching web pages and remote document files""" name: str = "web_fetch" description: str = ( "Fetch content from a http/https URL. For web pages, extracts readable text. " "For document files (PDF, Word, TXT, Markdown, Excel, PPT), downloads and parses the file content. " "Supported file types: .pdf, .docx, .txt, .md, .csv, .xls, .xlsx, .ppt, .pptx" ) params: dict = { "type": "object", "properties": { "url": { "type": "string", "description": "The HTTP/HTTPS URL to fetch (web page or document file link)" } }, "required": ["url"] } def __init__(self, config: dict = None): self.config = config or {} self.cwd = self.config.get("cwd", os.getcwd()) def execute(self, args: Dict[str, Any]) -> ToolResult: url = args.get("url", "").strip() if not url: return ToolResult.fail("Error: 'url' parameter is required") parsed = urlparse(url) if parsed.scheme not in ("http", "https"): return ToolResult.fail("Error: Invalid URL (must start with http:// or https://)") if _is_document_url(url): return self._fetch_document(url) return self._fetch_webpage(url) # ---- Web page fetching ---- def _fetch_webpage(self, url: str) -> ToolResult: """Fetch and extract readable text from an HTML web page.""" parsed = urlparse(url) try: response = requests.get( url, headers=DEFAULT_HEADERS, timeout=DEFAULT_TIMEOUT, allow_redirects=True, ) response.raise_for_status() except requests.Timeout: return ToolResult.fail(f"Error: Request timed out after {DEFAULT_TIMEOUT}s") except requests.ConnectionError: return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}") except requests.HTTPError as e: return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}") except Exception as e: return ToolResult.fail(f"Error: Failed to fetch URL: {e}") content_type = response.headers.get("Content-Type", "") if self._is_binary_content_type(content_type) and not _is_document_url(url): return self._handle_download_by_content_type(url, response, content_type) response.encoding = self._detect_encoding(response) html = response.text title = self._extract_title(html) text = self._extract_text(html) return ToolResult.success(f"Title: {title}\n\nContent:\n{text}") # ---- Document fetching ---- def _fetch_document(self, url: str) -> ToolResult: """Download a document file and extract its text content.""" suffix = _get_url_suffix(url) parsed = urlparse(url) filename = self._extract_filename(url) tmp_dir = self._ensure_tmp_dir() local_path = os.path.join(tmp_dir, filename) logger.info(f"[WebFetch] Downloading document: {url} -> {local_path}") try: response = requests.get( url, headers=DEFAULT_HEADERS, timeout=DEFAULT_TIMEOUT, stream=True, allow_redirects=True, ) response.raise_for_status() content_length = int(response.headers.get("Content-Length", 0)) if content_length > MAX_FILE_SIZE: return ToolResult.fail( f"Error: File too large ({format_size(content_length)} > {format_size(MAX_FILE_SIZE)})" ) downloaded = 0 with open(local_path, "wb") as f: for chunk in response.iter_content(chunk_size=8192): downloaded += len(chunk) if downloaded > MAX_FILE_SIZE: f.close() os.remove(local_path) return ToolResult.fail( f"Error: File too large (>{format_size(MAX_FILE_SIZE)}), download aborted" ) f.write(chunk) except requests.Timeout: return ToolResult.fail(f"Error: Download timed out after {DEFAULT_TIMEOUT}s") except requests.ConnectionError: return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}") except requests.HTTPError as e: return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}") except Exception as e: self._cleanup_file(local_path) return ToolResult.fail(f"Error: Failed to download file: {e}") try: text = self._parse_document(local_path, suffix) except Exception as e: self._cleanup_file(local_path) return ToolResult.fail(f"Error: Failed to parse document: {e}") if not text or not text.strip(): file_size = os.path.getsize(local_path) return ToolResult.success( f"File downloaded to: {local_path} ({format_size(file_size)})\n" f"No text content could be extracted. The file may contain only images or be encrypted." ) truncation = truncate_head(text) result_text = truncation.content file_size = os.path.getsize(local_path) header = f"[Document: {filename} | Size: {format_size(file_size)} | Saved to: {local_path}]\n\n" if truncation.truncated: header += f"[Content truncated: showing {truncation.output_lines} of {truncation.total_lines} lines]\n\n" return ToolResult.success(header + result_text) def _parse_document(self, file_path: str, suffix: str) -> str: """Parse document file and return extracted text.""" if suffix in PDF_SUFFIXES: return self._parse_pdf(file_path) elif suffix in WORD_SUFFIXES: return self._parse_word(file_path) elif suffix in TEXT_SUFFIXES: return self._parse_text(file_path) elif suffix in SPREADSHEET_SUFFIXES: return self._parse_spreadsheet(file_path) elif suffix in PPT_SUFFIXES: return self._parse_ppt(file_path) else: return self._parse_text(file_path) def _parse_pdf(self, file_path: str) -> str: """Extract text from PDF using pypdf.""" try: from pypdf import PdfReader except ImportError: raise ImportError("pypdf library is required for PDF parsing. Install with: pip install pypdf") reader = PdfReader(file_path) text_parts = [] for page_num, page in enumerate(reader.pages, 1): page_text = page.extract_text() if page_text and page_text.strip(): text_parts.append(f"--- Page {page_num}/{len(reader.pages)} ---\n{page_text}") return "\n\n".join(text_parts) def _parse_word(self, file_path: str) -> str: """Extract text from Word documents (.docx).""" try: from docx import Document except ImportError: raise ImportError( "python-docx library is required for .docx parsing. Install with: pip install python-docx" ) doc = Document(file_path) paragraphs = [p.text for p in doc.paragraphs if p.text.strip()] return "\n\n".join(paragraphs) def _parse_text(self, file_path: str) -> str: """Read plain text files (txt, md, csv, etc.).""" encodings = ["utf-8", "utf-8-sig", "gbk", "gb2312", "latin-1"] for enc in encodings: try: with open(file_path, "r", encoding=enc) as f: return f.read() except (UnicodeDecodeError, UnicodeError): continue raise ValueError(f"Unable to decode file with any supported encoding: {encodings}") def _parse_spreadsheet(self, file_path: str) -> str: """Extract text from Excel files (.xls/.xlsx).""" try: import openpyxl except ImportError: raise ImportError( "openpyxl library is required for .xlsx parsing. Install with: pip install openpyxl" ) wb = openpyxl.load_workbook(file_path, read_only=True, data_only=True) result_parts = [] for sheet_name in wb.sheetnames: ws = wb[sheet_name] rows = [] for row in ws.iter_rows(values_only=True): cells = [str(c) if c is not None else "" for c in row] if any(cells): rows.append(" | ".join(cells)) if rows: result_parts.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows)) wb.close() return "\n\n".join(result_parts) def _parse_ppt(self, file_path: str) -> str: """Extract text from PowerPoint files (.ppt/.pptx).""" try: from pptx import Presentation except ImportError: raise ImportError( "python-pptx library is required for .pptx parsing. Install with: pip install python-pptx" ) prs = Presentation(file_path) text_parts = [] for slide_num, slide in enumerate(prs.slides, 1): slide_texts = [] for shape in slide.shapes: if shape.has_text_frame: for paragraph in shape.text_frame.paragraphs: text = paragraph.text.strip() if text: slide_texts.append(text) if slide_texts: text_parts.append(f"--- Slide {slide_num}/{len(prs.slides)} ---\n" + "\n".join(slide_texts)) return "\n\n".join(text_parts) # ---- Encoding detection ---- @staticmethod def _detect_encoding(response: requests.Response) -> str: """Detect response encoding with priority: Content-Type header > HTML meta > chardet > utf-8.""" # 1. Check Content-Type header for explicit charset content_type = response.headers.get("Content-Type", "") charset = _extract_charset_from_content_type(content_type) if charset: return charset # 2. Scan raw bytes for HTML meta charset declaration raw = response.content[:4096] charset = _extract_charset_from_html_meta(raw) if charset: return charset # 3. Use apparent_encoding (chardet-based detection) if confident enough apparent = response.apparent_encoding if apparent: apparent_lower = apparent.lower() # Trust CJK / Windows encodings detected by chardet trusted_prefixes = ("utf", "gb", "big5", "euc", "shift_jis", "iso-2022", "windows", "ascii") if any(apparent_lower.startswith(p) for p in trusted_prefixes): return apparent # 4. Fallback return "utf-8" # ---- Helper methods ---- def _ensure_tmp_dir(self) -> str: """Ensure workspace/tmp directory exists and return its path.""" tmp_dir = os.path.join(self.cwd, "tmp") os.makedirs(tmp_dir, exist_ok=True) return tmp_dir def _extract_filename(self, url: str) -> str: """Extract a safe filename from URL, with a short UUID prefix to avoid collisions.""" path = urlparse(url).path basename = os.path.basename(unquote(path)) if not basename or basename == "/": basename = "downloaded_file" # Sanitize: keep only safe chars basename = re.sub(r'[^\w.\-]', '_', basename) short_id = uuid.uuid4().hex[:8] return f"{short_id}_{basename}" @staticmethod def _cleanup_file(path: str): """Remove a file if it exists, ignoring errors.""" try: if os.path.exists(path): os.remove(path) except Exception: pass @staticmethod def _is_binary_content_type(content_type: str) -> bool: """Check if Content-Type indicates a binary/document response.""" binary_types = [ "application/pdf", "application/vnd.openxmlformats", "application/vnd.ms-excel", "application/vnd.ms-powerpoint", "application/octet-stream", ] ct_lower = content_type.lower() return any(bt in ct_lower for bt in binary_types) def _handle_download_by_content_type(self, url: str, response: requests.Response, content_type: str) -> ToolResult: """Handle a URL that returned binary content instead of HTML.""" ct_lower = content_type.lower() suffix_map = { "application/pdf": ".pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml": ".docx", "application/vnd.ms-excel": ".xls", "application/vnd.openxmlformats-officedocument.spreadsheetml": ".xlsx", "application/vnd.ms-powerpoint": ".ppt", "application/vnd.openxmlformats-officedocument.presentationml": ".pptx", } detected_suffix = None for ct_prefix, ext in suffix_map.items(): if ct_prefix in ct_lower: detected_suffix = ext break if detected_suffix and detected_suffix in ALL_DOC_SUFFIXES: # Re-fetch as document return self._fetch_document(url if _get_url_suffix(url) in ALL_DOC_SUFFIXES else self._rewrite_url_with_suffix(url, detected_suffix)) return ToolResult.fail(f"Error: URL returned binary content ({content_type}), not a supported document type") @staticmethod def _rewrite_url_with_suffix(url: str, suffix: str) -> str: """Append a suffix to the URL path so _get_url_suffix works correctly.""" parsed = urlparse(url) new_path = parsed.path.rstrip("/") + suffix return parsed._replace(path=new_path).geturl() # ---- HTML extraction (unchanged) ---- @staticmethod def _extract_title(html: str) -> str: match = re.search(r"]*>(.*?)", html, re.IGNORECASE | re.DOTALL) return match.group(1).strip() if match else "Untitled" @staticmethod def _extract_text(html: str) -> str: text = re.sub(r"]*>.*?", "", html, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r"]*>.*?", "", text, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r"<[^>]+>", "", text) text = text.replace("&", "&").replace("<", "<").replace(">", ">") text = text.replace(""", '"').replace("'", "'").replace(" ", " ") text = re.sub(r"[^\S\n]+", " ", text) text = re.sub(r"\n{3,}", "\n\n", text) lines = [line.strip() for line in text.splitlines()] text = "\n".join(lines) return text.strip() ================================================ FILE: agent/tools/web_search/__init__.py ================================================ from agent.tools.web_search.web_search import WebSearch __all__ = ["WebSearch"] ================================================ FILE: agent/tools/web_search/web_search.py ================================================ """ Web Search tool - Search the web using Bocha or LinkAI search API. Supports two backends with unified response format: 1. Bocha Search (primary, requires BOCHA_API_KEY) 2. LinkAI Search (fallback, requires LINKAI_API_KEY) """ import os import json from typing import Dict, Any, Optional import requests from agent.tools.base_tool import BaseTool, ToolResult from common.log import logger from config import conf # Default timeout for API requests (seconds) DEFAULT_TIMEOUT = 30 class WebSearch(BaseTool): """Tool for searching the web using Bocha or LinkAI search API""" name: str = "web_search" description: str = "Search the web for real-time information. Returns titles, URLs, and snippets." params: dict = { "type": "object", "properties": { "query": { "type": "string", "description": "Search query string" }, "count": { "type": "integer", "description": "Number of results to return (1-50, default: 10)" }, "freshness": { "type": "string", "description": ( "Time range filter. Options: " "'noLimit' (default), 'oneDay', 'oneWeek', 'oneMonth', 'oneYear', " "or date range like '2025-01-01..2025-02-01'" ) }, "summary": { "type": "boolean", "description": "Whether to include text summary for each result (default: false)" } }, "required": ["query"] } def __init__(self, config: dict = None): self.config = config or {} self._backend = None # Will be resolved on first execute @staticmethod def is_available() -> bool: """Check if web search is available (at least one API key is configured)""" return bool(os.environ.get("BOCHA_API_KEY") or os.environ.get("LINKAI_API_KEY")) def _resolve_backend(self) -> Optional[str]: """ Determine which search backend to use. Priority: Bocha > LinkAI :return: 'bocha', 'linkai', or None """ if os.environ.get("BOCHA_API_KEY"): return "bocha" if os.environ.get("LINKAI_API_KEY"): return "linkai" return None def execute(self, args: Dict[str, Any]) -> ToolResult: """ Execute web search :param args: Search parameters (query, count, freshness, summary) :return: Search results """ query = args.get("query", "").strip() if not query: return ToolResult.fail("Error: 'query' parameter is required") count = args.get("count", 10) freshness = args.get("freshness", "noLimit") summary = args.get("summary", False) # Validate count if not isinstance(count, int) or count < 1 or count > 50: count = 10 # Resolve backend backend = self._resolve_backend() if not backend: return ToolResult.fail( "Error: No search API key configured. " "Please set BOCHA_API_KEY or LINKAI_API_KEY using env_config tool.\n" " - Bocha Search: https://open.bocha.cn\n" " - LinkAI Search: https://link-ai.tech" ) try: if backend == "bocha": return self._search_bocha(query, count, freshness, summary) else: return self._search_linkai(query, count, freshness) except requests.Timeout: return ToolResult.fail(f"Error: Search request timed out after {DEFAULT_TIMEOUT}s") except requests.ConnectionError: return ToolResult.fail("Error: Failed to connect to search API") except Exception as e: logger.error(f"[WebSearch] Unexpected error: {e}", exc_info=True) return ToolResult.fail(f"Error: Search failed - {str(e)}") def _search_bocha(self, query: str, count: int, freshness: str, summary: bool) -> ToolResult: """ Search using Bocha API :param query: Search query :param count: Number of results :param freshness: Time range filter :param summary: Whether to include summary :return: Formatted search results """ api_key = os.environ.get("BOCHA_API_KEY", "") url = "https://api.bocha.cn/v1/web-search" headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "application/json" } payload = { "query": query, "count": count, "freshness": freshness, "summary": summary } logger.debug(f"[WebSearch] Bocha search: query='{query}', count={count}") response = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT) if response.status_code == 401: return ToolResult.fail("Error: Invalid BOCHA_API_KEY. Please check your API key.") if response.status_code == 403: return ToolResult.fail("Error: Bocha API - insufficient balance. Please top up at https://open.bocha.cn") if response.status_code == 429: return ToolResult.fail("Error: Bocha API rate limit reached. Please try again later.") if response.status_code != 200: return ToolResult.fail(f"Error: Bocha API returned HTTP {response.status_code}") data = response.json() # Check API-level error code api_code = data.get("code") if api_code is not None and api_code != 200: msg = data.get("msg") or "Unknown error" return ToolResult.fail(f"Error: Bocha API error (code={api_code}): {msg}") # Extract and format results return self._format_bocha_results(data, query) def _format_bocha_results(self, data: dict, query: str) -> ToolResult: """ Format Bocha API response into unified result structure :param data: Raw API response :param query: Original query :return: Formatted ToolResult """ search_data = data.get("data", {}) web_pages = search_data.get("webPages", {}) pages = web_pages.get("value", []) if not pages: return ToolResult.success({ "query": query, "backend": "bocha", "total": 0, "results": [], "message": "No results found" }) results = [] for page in pages: result = { "title": page.get("name", ""), "url": page.get("url", ""), "snippet": page.get("snippet", ""), "siteName": page.get("siteName", ""), "datePublished": page.get("datePublished") or page.get("dateLastCrawled", ""), } # Include summary only if present if page.get("summary"): result["summary"] = page["summary"] results.append(result) total = web_pages.get("totalEstimatedMatches", len(results)) return ToolResult.success({ "query": query, "backend": "bocha", "total": total, "count": len(results), "results": results }) def _search_linkai(self, query: str, count: int, freshness: str) -> ToolResult: """ Search using LinkAI plugin API :param query: Search query :param count: Number of results :param freshness: Time range filter :return: Formatted search results """ api_key = os.environ.get("LINKAI_API_KEY", "") api_base = conf().get("linkai_api_base", "https://api.link-ai.tech") url = f"{api_base.rstrip('/')}/v1/plugin/execute" from common.utils import get_cloud_headers headers = get_cloud_headers(api_key) payload = { "code": "web-search", "args": { "query": query, "count": count, "freshness": freshness } } logger.debug(f"[WebSearch] LinkAI search: query='{query}', count={count}") response = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT) if response.status_code == 401: return ToolResult.fail("Error: Invalid LINKAI_API_KEY. Please check your API key.") if response.status_code != 200: return ToolResult.fail(f"Error: LinkAI API returned HTTP {response.status_code}") data = response.json() if not data.get("success"): msg = data.get("message") or "Unknown error" return ToolResult.fail(f"Error: LinkAI search failed: {msg}") return self._format_linkai_results(data, query) def _format_linkai_results(self, data: dict, query: str) -> ToolResult: """ Format LinkAI API response into unified result structure. LinkAI returns the search data in data.data field, which follows the same Bing-compatible format as Bocha. :param data: Raw API response :param query: Original query :return: Formatted ToolResult """ raw_data = data.get("data", "") # LinkAI may return data as a JSON string if isinstance(raw_data, str): try: raw_data = json.loads(raw_data) except (json.JSONDecodeError, TypeError): # If data is plain text, return it as a single result return ToolResult.success({ "query": query, "backend": "linkai", "total": 1, "count": 1, "results": [{"content": raw_data}] }) # If the response follows Bing-compatible structure if isinstance(raw_data, dict): web_pages = raw_data.get("webPages", {}) pages = web_pages.get("value", []) if pages: results = [] for page in pages: result = { "title": page.get("name", ""), "url": page.get("url", ""), "snippet": page.get("snippet", ""), "siteName": page.get("siteName", ""), "datePublished": page.get("datePublished") or page.get("dateLastCrawled", ""), } if page.get("summary"): result["summary"] = page["summary"] results.append(result) total = web_pages.get("totalEstimatedMatches", len(results)) return ToolResult.success({ "query": query, "backend": "linkai", "total": total, "count": len(results), "results": results }) # Fallback: return raw data return ToolResult.success({ "query": query, "backend": "linkai", "total": 1, "count": 1, "results": [{"content": str(raw_data)}] }) ================================================ FILE: agent/tools/write/__init__.py ================================================ from .write import Write __all__ = ['Write'] ================================================ FILE: agent/tools/write/write.py ================================================ """ Write tool - Write file content Creates or overwrites files, automatically creates parent directories """ import os from typing import Dict, Any from pathlib import Path from agent.tools.base_tool import BaseTool, ToolResult from common.utils import expand_path class Write(BaseTool): """Tool for writing file content""" name: str = "write" description: str = "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories. IMPORTANT: Single write should not exceed 10KB. For large files, create a skeleton first, then use edit to add content in chunks." params: dict = { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the file to write (relative or absolute)" }, "content": { "type": "string", "description": "Content to write to the file" } }, "required": ["path", "content"] } def __init__(self, config: dict = None): self.config = config or {} self.cwd = self.config.get("cwd", os.getcwd()) self.memory_manager = self.config.get("memory_manager", None) def execute(self, args: Dict[str, Any]) -> ToolResult: """ Execute file write operation :param args: Contains file path and content :return: Operation result """ path = args.get("path", "").strip() content = args.get("content", "") if not path: return ToolResult.fail("Error: path parameter is required") # Resolve path absolute_path = self._resolve_path(path) try: # Create parent directory (if needed) parent_dir = os.path.dirname(absolute_path) if parent_dir: os.makedirs(parent_dir, exist_ok=True) # Write file with open(absolute_path, 'w', encoding='utf-8') as f: f.write(content) # Get bytes written bytes_written = len(content.encode('utf-8')) # Auto-sync to memory database if this is a memory file if self.memory_manager and 'memory/' in path: self.memory_manager.mark_dirty() result = { "message": f"Successfully wrote {bytes_written} bytes to {path}", "path": path, "bytes_written": bytes_written } return ToolResult.success(result) except PermissionError: return ToolResult.fail(f"Error: Permission denied writing to {path}") except Exception as e: return ToolResult.fail(f"Error writing file: {str(e)}") def _resolve_path(self, path: str) -> str: """ Resolve path to absolute path :param path: Relative or absolute path :return: Absolute path """ # Expand ~ to user home directory path = expand_path(path) if os.path.isabs(path): return path return os.path.abspath(os.path.join(self.cwd, path)) ================================================ FILE: app.py ================================================ # encoding:utf-8 import os import signal import sys import time from channel import channel_factory from common import const from common.log import logger from config import load_config, conf from plugins import * import threading _channel_mgr = None def get_channel_manager(): return _channel_mgr def _parse_channel_type(raw) -> list: """ Parse channel_type config value into a list of channel names. Supports: - single string: "feishu" - comma-separated string: "feishu, dingtalk" - list: ["feishu", "dingtalk"] """ if isinstance(raw, list): return [ch.strip() for ch in raw if ch.strip()] if isinstance(raw, str): return [ch.strip() for ch in raw.split(",") if ch.strip()] return [] class ChannelManager: """ Manage the lifecycle of multiple channels running concurrently. Each channel.startup() runs in its own daemon thread. The web channel is started as default console unless explicitly disabled. """ def __init__(self): self._channels = {} # channel_name -> channel instance self._threads = {} # channel_name -> thread self._primary_channel = None self._lock = threading.Lock() self.cloud_mode = False # set to True when cloud client is active @property def channel(self): """Return the primary (first non-web) channel for backward compatibility.""" return self._primary_channel def get_channel(self, channel_name: str): return self._channels.get(channel_name) def start(self, channel_names: list, first_start: bool = False): """ Create and start one or more channels in sub-threads. If first_start is True, plugins and linkai client will also be initialized. """ with self._lock: channels = [] for name in channel_names: ch = channel_factory.create_channel(name) ch.cloud_mode = self.cloud_mode self._channels[name] = ch channels.append((name, ch)) if self._primary_channel is None and name != "web": self._primary_channel = ch if self._primary_channel is None and channels: self._primary_channel = channels[0][1] if first_start: PluginManager().load_plugins() if conf().get("use_linkai"): try: from common import cloud_client threading.Thread( target=cloud_client.start, args=(self._primary_channel, self), daemon=True, ).start() except Exception: pass # Start web console first so its logs print cleanly, # then start remaining channels after a brief pause. web_entry = None other_entries = [] for entry in channels: if entry[0] == "web": web_entry = entry else: other_entries.append(entry) ordered = ([web_entry] if web_entry else []) + other_entries for i, (name, ch) in enumerate(ordered): if i > 0 and name != "web": time.sleep(0.1) t = threading.Thread(target=self._run_channel, args=(name, ch), daemon=True) self._threads[name] = t t.start() logger.debug(f"[ChannelManager] Channel '{name}' started in sub-thread") def _run_channel(self, name: str, channel): try: channel.startup() except Exception as e: logger.error(f"[ChannelManager] Channel '{name}' startup error: {e}") logger.exception(e) def stop(self, channel_name: str = None): """ Stop channel(s). If channel_name is given, stop only that channel; otherwise stop all channels. """ # Pop under lock, then stop outside lock to avoid deadlock with self._lock: names = [channel_name] if channel_name else list(self._channels.keys()) to_stop = [] for name in names: ch = self._channels.pop(name, None) th = self._threads.pop(name, None) to_stop.append((name, ch, th)) if channel_name and self._primary_channel is self._channels.get(channel_name): self._primary_channel = None for name, ch, th in to_stop: if ch is None: logger.warning(f"[ChannelManager] Channel '{name}' not found in managed channels") if th and th.is_alive(): self._interrupt_thread(th, name) continue logger.info(f"[ChannelManager] Stopping channel '{name}'...") graceful = False if hasattr(ch, 'stop'): try: ch.stop() graceful = True except Exception as e: logger.warning(f"[ChannelManager] Error during channel '{name}' stop: {e}") if th and th.is_alive(): th.join(timeout=5) if th.is_alive(): if graceful: logger.info(f"[ChannelManager] Channel '{name}' thread still alive after stop(), " "leaving daemon thread to finish on its own") else: logger.warning(f"[ChannelManager] Channel '{name}' thread did not exit in 5s, forcing interrupt") self._interrupt_thread(th, name) @staticmethod def _interrupt_thread(th: threading.Thread, name: str): """Raise SystemExit in target thread to break blocking loops like start_forever.""" import ctypes try: tid = th.ident if tid is None: return res = ctypes.pythonapi.PyThreadState_SetAsyncExc( ctypes.c_ulong(tid), ctypes.py_object(SystemExit) ) if res == 1: logger.info(f"[ChannelManager] Interrupted thread for channel '{name}'") elif res > 1: ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_ulong(tid), None) logger.warning(f"[ChannelManager] Failed to interrupt thread for channel '{name}'") except Exception as e: logger.warning(f"[ChannelManager] Thread interrupt error for '{name}': {e}") def restart(self, new_channel_name: str): """ Restart a single channel with a new channel type. Can be called from any thread (e.g. linkai config callback). """ logger.info(f"[ChannelManager] Restarting channel to '{new_channel_name}'...") self.stop(new_channel_name) _clear_singleton_cache(new_channel_name) time.sleep(1) self.start([new_channel_name], first_start=False) logger.info(f"[ChannelManager] Channel restarted to '{new_channel_name}' successfully") def add_channel(self, channel_name: str): """ Dynamically add and start a new channel. If the channel is already running, restart it instead. """ with self._lock: if channel_name in self._channels: logger.info(f"[ChannelManager] Channel '{channel_name}' already exists, restarting") if self._channels.get(channel_name): self.restart(channel_name) return logger.info(f"[ChannelManager] Adding channel '{channel_name}'...") _clear_singleton_cache(channel_name) self.start([channel_name], first_start=False) logger.info(f"[ChannelManager] Channel '{channel_name}' added successfully") def remove_channel(self, channel_name: str): """ Dynamically stop and remove a running channel. """ with self._lock: if channel_name not in self._channels: logger.warning(f"[ChannelManager] Channel '{channel_name}' not found, nothing to remove") return logger.info(f"[ChannelManager] Removing channel '{channel_name}'...") self.stop(channel_name) logger.info(f"[ChannelManager] Channel '{channel_name}' removed successfully") def _clear_singleton_cache(channel_name: str): """ Clear the singleton cache for the channel class so that a new instance can be created with updated config. """ cls_map = { "web": "channel.web.web_channel.WebChannel", "wechatmp": "channel.wechatmp.wechatmp_channel.WechatMPChannel", "wechatmp_service": "channel.wechatmp.wechatmp_channel.WechatMPChannel", "wechatcom_app": "channel.wechatcom.wechatcomapp_channel.WechatComAppChannel", const.FEISHU: "channel.feishu.feishu_channel.FeiShuChanel", const.DINGTALK: "channel.dingtalk.dingtalk_channel.DingTalkChanel", const.WECOM_BOT: "channel.wecom_bot.wecom_bot_channel.WecomBotChannel", const.QQ: "channel.qq.qq_channel.QQChannel", } module_path = cls_map.get(channel_name) if not module_path: return try: parts = module_path.rsplit(".", 1) module_name, class_name = parts[0], parts[1] import importlib module = importlib.import_module(module_name) wrapper = getattr(module, class_name, None) if wrapper and hasattr(wrapper, '__closure__') and wrapper.__closure__: for cell in wrapper.__closure__: try: cell_contents = cell.cell_contents if isinstance(cell_contents, dict): cell_contents.clear() logger.debug(f"[ChannelManager] Cleared singleton cache for {class_name}") break except ValueError: pass except Exception as e: logger.warning(f"[ChannelManager] Failed to clear singleton cache: {e}") def sigterm_handler_wrap(_signo): old_handler = signal.getsignal(_signo) def func(_signo, _stack_frame): logger.info("signal {} received, exiting...".format(_signo)) conf().save_user_datas() if callable(old_handler): # check old_handler return old_handler(_signo, _stack_frame) sys.exit(0) signal.signal(_signo, func) def run(): global _channel_mgr try: # load config load_config() # ctrl + c sigterm_handler_wrap(signal.SIGINT) # kill signal sigterm_handler_wrap(signal.SIGTERM) # Parse channel_type into a list raw_channel = conf().get("channel_type", "web") if "--cmd" in sys.argv: channel_names = ["terminal"] else: channel_names = _parse_channel_type(raw_channel) if not channel_names: channel_names = ["web"] # Auto-start web console unless explicitly disabled web_console_enabled = conf().get("web_console", True) if web_console_enabled and "web" not in channel_names: channel_names.append("web") logger.info(f"[App] Starting channels: {channel_names}") _channel_mgr = ChannelManager() _channel_mgr.start(channel_names, first_start=True) while True: time.sleep(1) except Exception as e: logger.error("App startup failed!") logger.exception(e) if __name__ == "__main__": run() ================================================ FILE: bridge/agent_bridge.py ================================================ """ Agent Bridge - Integrates Agent system with existing COW bridge """ import os from typing import Optional, List from agent.protocol import Agent, LLMModel, LLMRequest from bridge.agent_event_handler import AgentEventHandler from bridge.agent_initializer import AgentInitializer from bridge.bridge import Bridge from bridge.context import Context from bridge.reply import Reply, ReplyType from common import const from common.log import logger from common.utils import expand_path from models.openai_compatible_bot import OpenAICompatibleBot def add_openai_compatible_support(bot_instance): """ Dynamically add OpenAI-compatible tool calling support to a bot instance. This allows any bot to gain tool calling capability without modifying its code, as long as it uses OpenAI-compatible API format. Note: Some bots like ZHIPUAIBot have native tool calling support and don't need enhancement. """ if hasattr(bot_instance, 'call_with_tools'): # Bot already has tool calling support (e.g., ZHIPUAIBot) logger.debug(f"[AgentBridge] {type(bot_instance).__name__} already has native tool calling support") return bot_instance # Create a temporary mixin class that combines the bot with OpenAI compatibility class EnhancedBot(bot_instance.__class__, OpenAICompatibleBot): """Dynamically enhanced bot with OpenAI-compatible tool calling""" def get_api_config(self): """ Infer API config from common configuration patterns. Most OpenAI-compatible bots use similar configuration. """ from config import conf return { 'api_key': conf().get("open_ai_api_key"), 'api_base': conf().get("open_ai_api_base"), 'model': conf().get("model", "gpt-3.5-turbo"), 'default_temperature': conf().get("temperature", 0.9), 'default_top_p': conf().get("top_p", 1.0), 'default_frequency_penalty': conf().get("frequency_penalty", 0.0), 'default_presence_penalty': conf().get("presence_penalty", 0.0), } # Change the bot's class to the enhanced version bot_instance.__class__ = EnhancedBot logger.info( f"[AgentBridge] Enhanced {bot_instance.__class__.__bases__[0].__name__} with OpenAI-compatible tool calling") return bot_instance class AgentLLMModel(LLMModel): """ LLM Model adapter that uses COW's existing bot infrastructure """ _MODEL_BOT_TYPE_MAP = { "wenxin": const.BAIDU, "wenxin-4": const.BAIDU, "xunfei": const.XUNFEI, const.QWEN: const.QWEN, const.MODELSCOPE: const.MODELSCOPE, } _MODEL_PREFIX_MAP = [ ("qwen", const.QWEN_DASHSCOPE), ("qwq", const.QWEN_DASHSCOPE), ("qvq", const.QWEN_DASHSCOPE), ("gemini", const.GEMINI), ("glm", const.ZHIPU_AI), ("claude", const.CLAUDEAPI), ("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT), ("doubao", const.DOUBAO), ] def __init__(self, bridge: Bridge, bot_type: str = "chat"): from config import conf super().__init__(model=conf().get("model", const.GPT_41)) self.bridge = bridge self.bot_type = bot_type self._bot = None self._bot_model = None @property def model(self): from config import conf return conf().get("model", const.GPT_41) @model.setter def model(self, value): pass def _resolve_bot_type(self, model_name: str) -> str: """Resolve bot type from model name, matching Bridge.__init__ logic.""" from config import conf if conf().get("use_linkai", False) and conf().get("linkai_api_key"): return const.LINKAI # Support custom bot type configuration configured_bot_type = conf().get("bot_type") if configured_bot_type: return configured_bot_type if not model_name or not isinstance(model_name, str): return const.OPENAI if model_name in self._MODEL_BOT_TYPE_MAP: return self._MODEL_BOT_TYPE_MAP[model_name] if model_name.lower().startswith("minimax") or model_name in ["abab6.5-chat"]: return const.MiniMax if model_name in [const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]: return const.QWEN_DASHSCOPE if model_name in [const.MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]: return const.MOONSHOT if model_name in [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER]: return const.OPENAI for prefix, btype in self._MODEL_PREFIX_MAP: if model_name.startswith(prefix): return btype return const.OPENAI @property def bot(self): """Lazy load the bot, re-create when model changes""" from models.bot_factory import create_bot cur_model = self.model if self._bot is None or self._bot_model != cur_model: bot_type = self._resolve_bot_type(cur_model) self._bot = create_bot(bot_type) self._bot = add_openai_compatible_support(self._bot) self._bot_model = cur_model return self._bot def call(self, request: LLMRequest): """ Call the model using COW's bot infrastructure """ try: # For non-streaming calls, we'll use the existing reply method # This is a simplified implementation if hasattr(self.bot, 'call_with_tools'): # Use tool-enabled call if available kwargs = { 'messages': request.messages, 'tools': getattr(request, 'tools', None), 'stream': False, 'model': self.model # Pass model parameter } # Only pass max_tokens if it's explicitly set if request.max_tokens is not None: kwargs['max_tokens'] = request.max_tokens # Extract system prompt if present system_prompt = getattr(request, 'system', None) if system_prompt: kwargs['system'] = system_prompt # Pass context metadata to bot channel_type = getattr(self, 'channel_type', None) if channel_type: kwargs['channel_type'] = channel_type session_id = getattr(self, 'session_id', None) if session_id: kwargs['session_id'] = session_id response = self.bot.call_with_tools(**kwargs) return self._format_response(response) else: # Fallback to regular call # This would need to be implemented based on your specific needs raise NotImplementedError("Regular call not implemented yet") except Exception as e: logger.error(f"AgentLLMModel call error: {e}") raise def call_stream(self, request: LLMRequest): """ Call the model with streaming using COW's bot infrastructure """ try: if hasattr(self.bot, 'call_with_tools'): # Use tool-enabled streaming call if available # Extract system prompt if present system_prompt = getattr(request, 'system', None) # Build kwargs for call_with_tools kwargs = { 'messages': request.messages, 'tools': getattr(request, 'tools', None), 'stream': True, 'model': self.model # Pass model parameter } # Only pass max_tokens if explicitly set, let the bot use its default if request.max_tokens is not None: kwargs['max_tokens'] = request.max_tokens # Add system prompt if present if system_prompt: kwargs['system'] = system_prompt # Pass context metadata to bot channel_type = getattr(self, 'channel_type', None) if channel_type: kwargs['channel_type'] = channel_type session_id = getattr(self, 'session_id', None) if session_id: kwargs['session_id'] = session_id stream = self.bot.call_with_tools(**kwargs) # Convert stream format to our expected format for chunk in stream: yield self._format_stream_chunk(chunk) else: bot_type = type(self.bot).__name__ raise NotImplementedError(f"Bot {bot_type} does not support call_with_tools. Please add the method.") except Exception as e: logger.error(f"AgentLLMModel call_stream error: {e}", exc_info=True) raise def _format_response(self, response): """Format Claude response to our expected format""" # This would need to be implemented based on Claude's response format return response def _format_stream_chunk(self, chunk): """Format Claude stream chunk to our expected format""" # This would need to be implemented based on Claude's stream format return chunk class AgentBridge: """ Bridge class that integrates super Agent with COW Manages multiple agent instances per session for conversation isolation """ def __init__(self, bridge: Bridge): self.bridge = bridge self.agents = {} # session_id -> Agent instance mapping self.default_agent = None # For backward compatibility (no session_id) self.agent: Optional[Agent] = None self.scheduler_initialized = False # Create helper instances self.initializer = AgentInitializer(bridge, self) def create_agent(self, system_prompt: str, tools: List = None, **kwargs) -> Agent: """ Create the super agent with COW integration Args: system_prompt: System prompt tools: List of tools (optional) **kwargs: Additional agent parameters Returns: Agent instance """ # Create LLM model that uses COW's bot infrastructure model = AgentLLMModel(self.bridge) # Default tools if none provided if tools is None: # Use ToolManager to load all available tools from agent.tools import ToolManager tool_manager = ToolManager() tool_manager.load_tools() tools = [] for tool_name in tool_manager.tool_classes.keys(): try: tool = tool_manager.create_tool(tool_name) if tool: tools.append(tool) except Exception as e: logger.warning(f"[AgentBridge] Failed to load tool {tool_name}: {e}") # Create agent instance agent = Agent( system_prompt=system_prompt, description=kwargs.get("description", "AI Super Agent"), model=model, tools=tools, max_steps=kwargs.get("max_steps", 15), output_mode=kwargs.get("output_mode", "logger"), workspace_dir=kwargs.get("workspace_dir"), skill_manager=kwargs.get("skill_manager"), enable_skills=kwargs.get("enable_skills", True), memory_manager=kwargs.get("memory_manager"), max_context_tokens=kwargs.get("max_context_tokens"), context_reserve_tokens=kwargs.get("context_reserve_tokens"), runtime_info=kwargs.get("runtime_info"), ) # Log skill loading details if agent.skill_manager: logger.debug(f"[AgentBridge] SkillManager initialized with {len(agent.skill_manager.skills)} skills") return agent def get_agent(self, session_id: str = None) -> Optional[Agent]: """ Get agent instance for the given session Args: session_id: Session identifier (e.g., user_id). If None, returns default agent. Returns: Agent instance for this session """ # If no session_id, use default agent (backward compatibility) if session_id is None: if self.default_agent is None: self._init_default_agent() return self.default_agent # Check if agent exists for this session if session_id not in self.agents: self._init_agent_for_session(session_id) return self.agents[session_id] def _init_default_agent(self): """Initialize default super agent""" agent = self.initializer.initialize_agent(session_id=None) self.default_agent = agent def _init_agent_for_session(self, session_id: str): """Initialize agent for a specific session""" agent = self.initializer.initialize_agent(session_id=session_id) self.agents[session_id] = agent def agent_reply(self, query: str, context: Context = None, on_event=None, clear_history: bool = False) -> Reply: """ Use super agent to reply to a query Args: query: User query context: COW context (optional, contains session_id for user isolation) on_event: Event callback (optional) clear_history: Whether to clear conversation history Returns: Reply object """ session_id = None agent = None try: # Extract session_id from context for user isolation if context: session_id = context.kwargs.get("session_id") or context.get("session_id") # Get agent for this session (will auto-initialize if needed) agent = self.get_agent(session_id=session_id) if not agent: return Reply(ReplyType.ERROR, "Failed to initialize super agent") # Create event handler for logging and channel communication event_handler = AgentEventHandler(context=context, original_callback=on_event) # Filter tools based on context original_tools = agent.tools filtered_tools = original_tools # If this is a scheduled task execution, exclude scheduler tool to prevent recursion if context and context.get("is_scheduled_task"): filtered_tools = [tool for tool in agent.tools if tool.name != "scheduler"] agent.tools = filtered_tools logger.info(f"[AgentBridge] Scheduled task execution: excluded scheduler tool ({len(filtered_tools)}/{len(original_tools)} tools)") else: # Attach context to scheduler tool if present if context and agent.tools: for tool in agent.tools: if tool.name == "scheduler": try: from agent.tools.scheduler.integration import attach_scheduler_to_tool attach_scheduler_to_tool(tool, context) except Exception as e: logger.warning(f"[AgentBridge] Failed to attach context to scheduler: {e}") break # Pass context metadata to model for downstream API requests if context and hasattr(agent, 'model'): agent.model.channel_type = context.get("channel_type", "") agent.model.session_id = session_id or "" # Store session_id on agent so executor can clear DB on fatal errors agent._current_session_id = session_id try: # Use agent's run_stream method with event handler response = agent.run_stream( user_message=query, on_event=event_handler.handle_event, clear_history=clear_history ) finally: # Restore original tools if context and context.get("is_scheduled_task"): agent.tools = original_tools # Log execution summary event_handler.log_summary() # Persist new messages generated during this run if session_id: channel_type = (context.get("channel_type") or "") if context else "" new_messages = getattr(agent, '_last_run_new_messages', []) if new_messages: self._persist_messages(session_id, list(new_messages), channel_type) else: with agent.messages_lock: msg_count = len(agent.messages) if msg_count == 0: try: from agent.memory import get_conversation_store get_conversation_store().clear_session(session_id) logger.info(f"[AgentBridge] Cleared DB for recovered session: {session_id}") except Exception as e: logger.warning(f"[AgentBridge] Failed to clear DB after recovery: {e}") # Check if there are files to send (from read tool) if hasattr(agent, 'stream_executor') and hasattr(agent.stream_executor, 'files_to_send'): files_to_send = agent.stream_executor.files_to_send if files_to_send: # Send the first file (for now, handle one file at a time) file_info = files_to_send[0] logger.info(f"[AgentBridge] Sending file: {file_info.get('path')}") # Clear files_to_send for next request agent.stream_executor.files_to_send = [] # Return file reply based on file type return self._create_file_reply(file_info, response, context) return Reply(ReplyType.TEXT, response) except Exception as e: logger.error(f"Agent reply error: {e}") # If the agent cleared its messages due to format error / overflow, # also purge the DB so the next request starts clean. if session_id and agent: try: with agent.messages_lock: msg_count = len(agent.messages) if msg_count == 0: from agent.memory import get_conversation_store get_conversation_store().clear_session(session_id) logger.info(f"[AgentBridge] Cleared DB for session after error: {session_id}") except Exception as db_err: logger.warning(f"[AgentBridge] Failed to clear DB after error: {db_err}") return Reply(ReplyType.ERROR, f"Agent error: {str(e)}") def _create_file_reply(self, file_info: dict, text_response: str, context: Context = None) -> Reply: """ Create a reply for sending files Args: file_info: File metadata from read tool text_response: Text response from agent context: Context object Returns: Reply object for file sending """ file_type = file_info.get("file_type", "file") file_path = file_info.get("path") # For images, use IMAGE_URL type (channel will handle upload) if file_type == "image": # Convert local path to file:// URL for channel processing file_url = f"file://{file_path}" logger.info(f"[AgentBridge] Sending image: {file_url}") reply = Reply(ReplyType.IMAGE_URL, file_url) # Attach text message if present (for channels that support text+image) if text_response: reply.text_content = text_response # Store accompanying text return reply # For all file types (document, video, audio), use FILE type if file_type in ["document", "video", "audio"]: file_url = f"file://{file_path}" logger.info(f"[AgentBridge] Sending {file_type}: {file_url}") reply = Reply(ReplyType.FILE, file_url) reply.file_name = file_info.get("file_name", os.path.basename(file_path)) # Attach text message if present if text_response: reply.text_content = text_response return reply # For other unknown file types, return text with file info message = text_response or file_info.get("message", "文件已准备") message += f"\n\n[文件: {file_info.get('file_name', file_path)}]" return Reply(ReplyType.TEXT, message) def _migrate_config_to_env(self, workspace_root: str): """ Migrate API keys from config.json to .env file if not already set Args: workspace_root: Workspace directory path (not used, kept for compatibility) """ from config import conf import os # Mapping from config.json keys to environment variable names key_mapping = { "open_ai_api_key": "OPENAI_API_KEY", "open_ai_api_base": "OPENAI_API_BASE", "gemini_api_key": "GEMINI_API_KEY", "claude_api_key": "CLAUDE_API_KEY", "linkai_api_key": "LINKAI_API_KEY", } # Use fixed secure location for .env file env_file = expand_path("~/.cow/.env") # Read existing env vars from .env file existing_env_vars = {} if os.path.exists(env_file): try: with open(env_file, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if line and not line.startswith('#') and '=' in line: key, _ = line.split('=', 1) existing_env_vars[key.strip()] = True except Exception as e: logger.warning(f"[AgentBridge] Failed to read .env file: {e}") # Check which keys need to be migrated keys_to_migrate = {} for config_key, env_key in key_mapping.items(): # Skip if already in .env file if env_key in existing_env_vars: continue # Get value from config.json value = conf().get(config_key, "") if value and value.strip(): # Only migrate non-empty values keys_to_migrate[env_key] = value.strip() # Log summary if there are keys to skip if existing_env_vars: logger.debug(f"[AgentBridge] {len(existing_env_vars)} env vars already in .env") # Write new keys to .env file if keys_to_migrate: try: # Ensure ~/.cow directory and .env file exist env_dir = os.path.dirname(env_file) if not os.path.exists(env_dir): os.makedirs(env_dir, exist_ok=True) if not os.path.exists(env_file): open(env_file, 'a').close() # Append new keys with open(env_file, 'a', encoding='utf-8') as f: f.write('\n# Auto-migrated from config.json\n') for key, value in keys_to_migrate.items(): f.write(f'{key}={value}\n') # Also set in current process os.environ[key] = value logger.info(f"[AgentBridge] Migrated {len(keys_to_migrate)} API keys from config.json to .env: {list(keys_to_migrate.keys())}") except Exception as e: logger.warning(f"[AgentBridge] Failed to migrate API keys: {e}") def _persist_messages( self, session_id: str, new_messages: list, channel_type: str = "" ) -> None: """ Persist new messages to the conversation store after each agent run. Failures are logged but never propagate — they must not interrupt replies. """ if not new_messages: return try: from config import conf if not conf().get("conversation_persistence", True): return except Exception: pass try: from agent.memory import get_conversation_store get_conversation_store().append_messages( session_id, new_messages, channel_type=channel_type ) except Exception as e: logger.warning( f"[AgentBridge] Failed to persist messages for session={session_id}: {e}" ) def clear_session(self, session_id: str): """ Clear a specific session's agent and conversation history Args: session_id: Session identifier to clear """ if session_id in self.agents: logger.info(f"[AgentBridge] Clearing session: {session_id}") del self.agents[session_id] def clear_all_sessions(self): """Clear all agent sessions""" logger.info(f"[AgentBridge] Clearing all sessions ({len(self.agents)} total)") self.agents.clear() self.default_agent = None def refresh_all_skills(self) -> int: """ Refresh skills and conditional tools in all agent instances after environment variable changes. This allows hot-reload without restarting. Returns: Number of agent instances refreshed """ import os from dotenv import load_dotenv from config import conf # Reload environment variables from .env file workspace_root = expand_path(conf().get("agent_workspace", "~/cow")) env_file = os.path.join(workspace_root, '.env') if os.path.exists(env_file): load_dotenv(env_file, override=True) logger.info(f"[AgentBridge] Reloaded environment variables from {env_file}") refreshed_count = 0 # Collect all agent instances to refresh agents_to_refresh = [] if self.default_agent: agents_to_refresh.append(("default", self.default_agent)) for session_id, agent in self.agents.items(): agents_to_refresh.append((session_id, agent)) for label, agent in agents_to_refresh: # Refresh skills if hasattr(agent, 'skill_manager') and agent.skill_manager: agent.skill_manager.refresh_skills() # Refresh conditional tools (e.g. web_search depends on API keys) self._refresh_conditional_tools(agent) refreshed_count += 1 if refreshed_count > 0: logger.info(f"[AgentBridge] Refreshed skills & tools in {refreshed_count} agent instance(s)") return refreshed_count @staticmethod def _refresh_conditional_tools(agent): """ Add or remove conditional tools based on current environment variables. For example, web_search should only be present when BOCHA_API_KEY or LINKAI_API_KEY is set. """ try: from agent.tools.web_search.web_search import WebSearch has_tool = any(t.name == "web_search" for t in agent.tools) available = WebSearch.is_available() if available and not has_tool: # API key was added - inject the tool tool = WebSearch() tool.model = agent.model agent.tools.append(tool) logger.info("[AgentBridge] web_search tool added (API key now available)") elif not available and has_tool: # API key was removed - remove the tool agent.tools = [t for t in agent.tools if t.name != "web_search"] logger.info("[AgentBridge] web_search tool removed (API key no longer available)") except Exception as e: logger.debug(f"[AgentBridge] Failed to refresh conditional tools: {e}") ================================================ FILE: bridge/agent_event_handler.py ================================================ """ Agent Event Handler - Handles agent events and thinking process output """ from common.log import logger class AgentEventHandler: """ Handles agent events and optionally sends intermediate messages to channel """ def __init__(self, context=None, original_callback=None): """ Initialize event handler Args: context: COW context (for accessing channel) original_callback: Original event callback to chain """ self.context = context self.original_callback = original_callback # Get channel for sending intermediate messages self.channel = None if context: self.channel = context.kwargs.get("channel") if hasattr(context, "kwargs") else None # Track current thinking for channel output self.current_thinking = "" self.turn_number = 0 def handle_event(self, event): """ Main event handler Args: event: Event dict with type and data """ event_type = event.get("type") data = event.get("data", {}) # Dispatch to specific handlers if event_type == "turn_start": self._handle_turn_start(data) elif event_type == "message_update": self._handle_message_update(data) elif event_type == "message_end": self._handle_message_end(data) elif event_type == "tool_execution_start": self._handle_tool_execution_start(data) elif event_type == "tool_execution_end": self._handle_tool_execution_end(data) # Call original callback if provided if self.original_callback: self.original_callback(event) def _handle_turn_start(self, data): """Handle turn start event""" self.turn_number = data.get("turn", 0) self.has_tool_calls_in_turn = False self.current_thinking = "" def _handle_message_update(self, data): """Handle message update event (streaming text)""" delta = data.get("delta", "") self.current_thinking += delta def _handle_message_end(self, data): """Handle message end event""" tool_calls = data.get("tool_calls", []) # Only send thinking process if followed by tool calls if tool_calls: if self.current_thinking.strip(): logger.info(f"💭 {self.current_thinking.strip()[:200]}{'...' if len(self.current_thinking) > 200 else ''}") # Send thinking process to channel self._send_to_channel(f"{self.current_thinking.strip()}") else: # No tool calls = final response (logged at agent_stream level) if self.current_thinking.strip(): logger.debug(f"💬 {self.current_thinking.strip()[:200]}{'...' if len(self.current_thinking) > 200 else ''}") self.current_thinking = "" def _handle_tool_execution_start(self, data): """Handle tool execution start event - logged by agent_stream.py""" pass def _handle_tool_execution_end(self, data): """Handle tool execution end event - logged by agent_stream.py""" pass def _send_to_channel(self, message): """ Try to send intermediate message to channel. Skipped in SSE mode because thinking text is already streamed via on_event. """ if self.context and self.context.get("on_event"): return if self.channel: try: from bridge.reply import Reply, ReplyType reply = Reply(ReplyType.TEXT, message) self.channel._send(reply, self.context) except Exception as e: logger.debug(f"[AgentEventHandler] Failed to send to channel: {e}") def log_summary(self): """Log execution summary - simplified""" # Summary removed as per user request # Real-time logging during execution is sufficient pass ================================================ FILE: bridge/agent_initializer.py ================================================ """ Agent Initializer - Handles agent initialization logic """ import os import asyncio import datetime import time from typing import Optional, List from agent.protocol import Agent from agent.tools import ToolManager from common.log import logger from common.utils import expand_path class AgentInitializer: """ Handles agent initialization including: - Workspace setup - Memory system initialization - Tool loading - System prompt building """ def __init__(self, bridge, agent_bridge): """ Initialize agent initializer Args: bridge: COW bridge instance agent_bridge: AgentBridge instance (for create_agent method) """ self.bridge = bridge self.agent_bridge = agent_bridge def initialize_agent(self, session_id: Optional[str] = None) -> Agent: """ Initialize agent for a session Args: session_id: Session ID (None for default agent) Returns: Initialized agent instance """ from config import conf # Get workspace from config workspace_root = expand_path(conf().get("agent_workspace", "~/cow")) # Migrate API keys self._migrate_config_to_env(workspace_root) # Load environment variables self._load_env_file() # Initialize workspace from agent.prompt import ensure_workspace, load_context_files, PromptBuilder workspace_files = ensure_workspace(workspace_root, create_templates=True) if session_id is None: logger.info(f"[AgentInitializer] Workspace initialized at: {workspace_root}") # Setup memory system memory_manager, memory_tools = self._setup_memory_system(workspace_root, session_id) # Load tools tools = self._load_tools(workspace_root, memory_manager, memory_tools, session_id) # Initialize scheduler if needed self._initialize_scheduler(tools, session_id) # Load context files context_files = load_context_files(workspace_root) # Initialize skill manager skill_manager = self._initialize_skill_manager(workspace_root, session_id) # Build system prompt prompt_builder = PromptBuilder(workspace_dir=workspace_root, language="zh") runtime_info = self._get_runtime_info(workspace_root) system_prompt = prompt_builder.build( tools=tools, context_files=context_files, skill_manager=skill_manager, memory_manager=memory_manager, runtime_info=runtime_info, ) # Get cost control parameters from config import conf max_steps = conf().get("agent_max_steps", 20) max_context_tokens = conf().get("agent_max_context_tokens", 50000) # Create agent agent = self.agent_bridge.create_agent( system_prompt=system_prompt, tools=tools, max_steps=max_steps, output_mode="logger", workspace_dir=workspace_root, skill_manager=skill_manager, enable_skills=True, max_context_tokens=max_context_tokens, runtime_info=runtime_info # Pass runtime_info for dynamic time updates ) # Attach memory manager and share LLM model for summarization if memory_manager: agent.memory_manager = memory_manager if hasattr(agent, 'model') and agent.model: memory_manager.flush_manager.llm_model = agent.model # Restore persisted conversation history for this session if session_id: self._restore_conversation_history(agent, session_id) # Start daily memory flush timer (once, on first agent init regardless of session) self._start_daily_flush_timer() return agent def _restore_conversation_history(self, agent, session_id: str) -> None: """ Load persisted conversation messages from SQLite and inject them into the agent's in-memory message list. Only user text and assistant text are restored. Tool call chains (tool_use / tool_result) are stripped out because: 1. They are intermediate process, the value is already in the final assistant text reply. 2. They consume massive context tokens (often 80%+ of history). 3. Different models have incompatible tool message formats, so restoring tool chains across model switches causes 400 errors. 4. Eliminates the entire class of tool_use/tool_result pairing bugs. """ from config import conf if not conf().get("conversation_persistence", True): return try: from agent.memory import get_conversation_store store = get_conversation_store() max_turns = conf().get("agent_max_context_turns", 20) restore_turns = max(3, max_turns // 6) saved = store.load_messages(session_id, max_turns=restore_turns) if saved: filtered = self._filter_text_only_messages(saved) if filtered: with agent.messages_lock: agent.messages = filtered logger.debug( f"[AgentInitializer] Restored {len(filtered)} text messages " f"(from {len(saved)} total, {restore_turns} turns cap) " f"for session={session_id}" ) except Exception as e: logger.warning( f"[AgentInitializer] Failed to restore conversation history for " f"session={session_id}: {e}" ) @staticmethod def _filter_text_only_messages(messages: list) -> list: """ Extract clean user/assistant turn pairs from raw message history. Groups messages into turns (each starting with a real user query), then keeps only: - The first user text in each turn (the actual user input) - The last assistant text in each turn (the final answer) All tool_use, tool_result, intermediate assistant thoughts, and internal hint messages injected by the agent loop are discarded. """ def _extract_text(content) -> str: if isinstance(content, str): return content.strip() if isinstance(content, list): parts = [ b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text" ] return "\n".join(p for p in parts if p).strip() return "" def _is_real_user_msg(msg: dict) -> bool: """True for actual user input, False for tool_result or internal hints.""" if msg.get("role") != "user": return False content = msg.get("content") if isinstance(content, list): has_tool_result = any( isinstance(b, dict) and b.get("type") == "tool_result" for b in content ) if has_tool_result: return False text = _extract_text(content) return bool(text) # Group into turns: each turn starts with a real user message turns = [] current_turn = None for msg in messages: if _is_real_user_msg(msg): if current_turn is not None: turns.append(current_turn) current_turn = {"user": msg, "assistants": []} elif current_turn is not None and msg.get("role") == "assistant": text = _extract_text(msg.get("content")) if text: current_turn["assistants"].append(text) if current_turn is not None: turns.append(current_turn) # Build result: one user msg + one assistant msg per turn filtered = [] for turn in turns: user_text = _extract_text(turn["user"].get("content")) if not user_text: continue filtered.append({ "role": "user", "content": [{"type": "text", "text": user_text}] }) if turn["assistants"]: final_reply = turn["assistants"][-1] filtered.append({ "role": "assistant", "content": [{"type": "text", "text": final_reply}] }) return filtered def _load_env_file(self): """Load environment variables from .env file""" env_file = expand_path("~/.cow/.env") if os.path.exists(env_file): try: from dotenv import load_dotenv load_dotenv(env_file, override=True) except ImportError: logger.warning("[AgentInitializer] python-dotenv not installed") except Exception as e: logger.warning(f"[AgentInitializer] Failed to load .env file: {e}") def _setup_memory_system(self, workspace_root: str, session_id: Optional[str] = None): """ Setup memory system Returns: (memory_manager, memory_tools) tuple """ memory_manager = None memory_tools = [] try: from agent.memory import MemoryManager, MemoryConfig, create_embedding_provider from agent.tools import MemorySearchTool, MemoryGetTool from config import conf # Initialize embedding provider (prefer OpenAI, fallback to LinkAI) embedding_provider = None openai_api_key = conf().get("open_ai_api_key", "") openai_api_base = conf().get("open_ai_api_base", "") if openai_api_key and openai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]: try: embedding_provider = create_embedding_provider( provider="openai", model="text-embedding-3-small", api_key=openai_api_key, api_base=openai_api_base or "https://api.openai.com/v1" ) if session_id is None: logger.info("[AgentInitializer] OpenAI embedding initialized") except Exception as e: logger.warning(f"[AgentInitializer] OpenAI embedding failed: {e}") if embedding_provider is None: linkai_api_key = conf().get("linkai_api_key", "") or os.environ.get("LINKAI_API_KEY", "") linkai_api_base = conf().get("linkai_api_base", "https://api.link-ai.tech") if linkai_api_key and linkai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]: try: embedding_provider = create_embedding_provider( provider="linkai", model="text-embedding-3-small", api_key=linkai_api_key, api_base=f"{linkai_api_base}/v1" ) if session_id is None: logger.info("[AgentInitializer] LinkAI embedding initialized (fallback)") except Exception as e: logger.warning(f"[AgentInitializer] LinkAI embedding failed: {e}") # Create memory manager memory_config = MemoryConfig(workspace_root=workspace_root) memory_manager = MemoryManager(memory_config, embedding_provider=embedding_provider) # Sync memory self._sync_memory(memory_manager, session_id) # Create memory tools memory_tools = [ MemorySearchTool(memory_manager), MemoryGetTool(memory_manager) ] if session_id is None: logger.info("[AgentInitializer] Memory system initialized") except Exception as e: logger.warning(f"[AgentInitializer] Memory system not available: {e}") return memory_manager, memory_tools def _sync_memory(self, memory_manager, session_id: Optional[str] = None): """Sync memory database""" try: loop = asyncio.get_event_loop() if loop.is_closed(): raise RuntimeError("Event loop is closed") except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: if loop.is_running(): asyncio.create_task(memory_manager.sync()) else: loop.run_until_complete(memory_manager.sync()) except Exception as e: logger.warning(f"[AgentInitializer] Memory sync failed: {e}") def _load_tools(self, workspace_root: str, memory_manager, memory_tools: List, session_id: Optional[str] = None): """Load all tools""" tool_manager = ToolManager() tool_manager.load_tools() tools = [] file_config = { "cwd": workspace_root, "memory_manager": memory_manager } if memory_manager else {"cwd": workspace_root} for tool_name in tool_manager.tool_classes.keys(): try: # Skip web_search if no API key is available if tool_name == "web_search": from agent.tools.web_search.web_search import WebSearch if not WebSearch.is_available(): logger.debug("[AgentInitializer] WebSearch skipped - no BOCHA_API_KEY or LINKAI_API_KEY") continue # Special handling for EnvConfig tool if tool_name == "env_config": from agent.tools import EnvConfig tool = EnvConfig({"agent_bridge": self.agent_bridge}) else: tool = tool_manager.create_tool(tool_name) if tool: # Apply workspace config to file operation tools if tool_name in ['read', 'write', 'edit', 'bash', 'grep', 'find', 'ls', 'web_fetch']: tool.config = file_config tool.cwd = file_config.get("cwd", getattr(tool, 'cwd', None)) if 'memory_manager' in file_config: tool.memory_manager = file_config['memory_manager'] tools.append(tool) except Exception as e: logger.warning(f"[AgentInitializer] Failed to load tool {tool_name}: {e}") # Add memory tools if memory_tools: tools.extend(memory_tools) if session_id is None: logger.info(f"[AgentInitializer] Added {len(memory_tools)} memory tools") if session_id is None: logger.info(f"[AgentInitializer] Loaded {len(tools)} tools: {[t.name for t in tools]}") return tools def _initialize_scheduler(self, tools: List, session_id: Optional[str] = None): """Initialize scheduler service if needed""" if not self.agent_bridge.scheduler_initialized: try: from agent.tools.scheduler.integration import init_scheduler if init_scheduler(self.agent_bridge): self.agent_bridge.scheduler_initialized = True if session_id is None: logger.info("[AgentInitializer] Scheduler service initialized") except Exception as e: logger.warning(f"[AgentInitializer] Failed to initialize scheduler: {e}") # Inject scheduler dependencies if self.agent_bridge.scheduler_initialized: try: from agent.tools.scheduler.integration import get_task_store, get_scheduler_service from agent.tools import SchedulerTool from config import conf task_store = get_task_store() scheduler_service = get_scheduler_service() for tool in tools: if isinstance(tool, SchedulerTool): tool.task_store = task_store tool.scheduler_service = scheduler_service if not tool.config: tool.config = {} raw_ct = conf().get("channel_type", "unknown") if isinstance(raw_ct, list): ct = raw_ct[0] if raw_ct else "unknown" elif isinstance(raw_ct, str) and "," in raw_ct: ct = raw_ct.split(",")[0].strip() else: ct = raw_ct tool.config["channel_type"] = ct except Exception as e: logger.warning(f"[AgentInitializer] Failed to inject scheduler dependencies: {e}") def _initialize_skill_manager(self, workspace_root: str, session_id: Optional[str] = None): """Initialize skill manager""" try: from agent.skills import SkillManager skill_manager = SkillManager(custom_dir=os.path.join(workspace_root, "skills")) return skill_manager except Exception as e: logger.warning(f"[AgentInitializer] Failed to initialize SkillManager: {e}") return None def _get_runtime_info(self, workspace_root: str): """Get runtime information with dynamic time support""" from config import conf def get_current_time(): """Get current time dynamically - called each time system prompt is accessed""" now = datetime.datetime.now() # Get timezone info try: offset = -time.timezone if not time.daylight else -time.altzone hours = offset // 3600 minutes = (offset % 3600) // 60 timezone_name = f"UTC{hours:+03d}:{minutes:02d}" if minutes else f"UTC{hours:+03d}" except Exception: timezone_name = "UTC" # Chinese weekday mapping weekday_map = { 'Monday': '星期一', 'Tuesday': '星期二', 'Wednesday': '星期三', 'Thursday': '星期四', 'Friday': '星期五', 'Saturday': '星期六', 'Sunday': '星期日' } weekday_zh = weekday_map.get(now.strftime("%A"), now.strftime("%A")) return { 'time': now.strftime("%Y-%m-%d %H:%M:%S"), 'weekday': weekday_zh, 'timezone': timezone_name } return { "model": conf().get("model", "unknown"), "workspace": workspace_root, "channel": ", ".join(conf().get("channel_type")) if isinstance(conf().get("channel_type"), list) else conf().get("channel_type", "unknown"), "_get_current_time": get_current_time # Dynamic time function } def _migrate_config_to_env(self, workspace_root: str): """Migrate API keys from config.json to .env file""" from config import conf key_mapping = { "open_ai_api_key": "OPENAI_API_KEY", "open_ai_api_base": "OPENAI_API_BASE", "gemini_api_key": "GEMINI_API_KEY", "claude_api_key": "CLAUDE_API_KEY", "linkai_api_key": "LINKAI_API_KEY", } env_file = expand_path("~/.cow/.env") # Read existing env vars existing_env_vars = {} if os.path.exists(env_file): try: with open(env_file, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if line and not line.startswith('#') and '=' in line: key, _ = line.split('=', 1) existing_env_vars[key.strip()] = True except Exception as e: logger.warning(f"[AgentInitializer] Failed to read .env file: {e}") # Check which keys need migration keys_to_migrate = {} for config_key, env_key in key_mapping.items(): if env_key in existing_env_vars: continue value = conf().get(config_key, "") if value and value.strip(): keys_to_migrate[env_key] = value.strip() # Write new keys if keys_to_migrate: try: env_dir = os.path.dirname(env_file) if not os.path.exists(env_dir): os.makedirs(env_dir, exist_ok=True) if not os.path.exists(env_file): open(env_file, 'a').close() with open(env_file, 'a', encoding='utf-8') as f: f.write('\n# Auto-migrated from config.json\n') for key, value in keys_to_migrate.items(): f.write(f'{key}={value}\n') os.environ[key] = value logger.info(f"[AgentInitializer] Migrated {len(keys_to_migrate)} API keys to .env: {list(keys_to_migrate.keys())}") except Exception as e: logger.warning(f"[AgentInitializer] Failed to migrate API keys: {e}") def _start_daily_flush_timer(self): """Start a background thread that flushes all agents' memory daily at 23:55.""" if getattr(self.agent_bridge, '_daily_flush_started', False): return self.agent_bridge._daily_flush_started = True import threading def _daily_flush_loop(): while True: try: now = datetime.datetime.now() target = now.replace(hour=23, minute=55, second=0, microsecond=0) if target <= now: target += datetime.timedelta(days=1) wait_seconds = (target - now).total_seconds() logger.info(f"[DailyFlush] Next flush at {target.strftime('%Y-%m-%d %H:%M')} (in {wait_seconds/3600:.1f}h)") time.sleep(wait_seconds) self._flush_all_agents() except Exception as e: logger.warning(f"[DailyFlush] Error in daily flush loop: {e}") time.sleep(3600) t = threading.Thread(target=_daily_flush_loop, daemon=True) t.start() def _flush_all_agents(self): """Flush memory for all active agent sessions.""" agents = [] if self.agent_bridge.default_agent: agents.append(("default", self.agent_bridge.default_agent)) for sid, agent in self.agent_bridge.agents.items(): agents.append((sid, agent)) if not agents: return flushed = 0 for label, agent in agents: try: if not agent.memory_manager: continue with agent.messages_lock: messages = list(agent.messages) if not messages: continue result = agent.memory_manager.flush_manager.create_daily_summary(messages) if result: flushed += 1 except Exception as e: logger.warning(f"[DailyFlush] Failed for session {label}: {e}") if flushed: logger.info(f"[DailyFlush] Flushed {flushed}/{len(agents)} agent session(s)") ================================================ FILE: bridge/bridge.py ================================================ from models.bot_factory import create_bot from bridge.context import Context from bridge.reply import Reply from common import const from common.log import logger from common.singleton import singleton from config import conf from translate.factory import create_translator from voice.factory import create_voice @singleton class Bridge(object): def __init__(self): self.btype = { "chat": const.OPENAI, "voice_to_text": conf().get("voice_to_text", "openai"), "text_to_voice": conf().get("text_to_voice", "google"), "translate": conf().get("translate", "baidu"), } # 这边取配置的模型 bot_type = conf().get("bot_type") if bot_type: self.btype["chat"] = bot_type else: model_type = conf().get("model") or const.GPT_41_MINI # Ensure model_type is string to prevent AttributeError when using startswith() # This handles cases where numeric model names (e.g., "1") are parsed as integers from YAML if not isinstance(model_type, str): logger.warning(f"[Bridge] model_type is not a string: {model_type} (type: {type(model_type).__name__}), converting to string") model_type = str(model_type) if model_type in ["text-davinci-003"]: self.btype["chat"] = const.OPEN_AI if conf().get("use_azure_chatgpt", False): self.btype["chat"] = const.CHATGPTONAZURE if model_type in ["wenxin", "wenxin-4"]: self.btype["chat"] = const.BAIDU if model_type in ["xunfei"]: self.btype["chat"] = const.XUNFEI if model_type in [const.QWEN]: self.btype["chat"] = const.QWEN if model_type in [const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]: self.btype["chat"] = const.QWEN_DASHSCOPE # Support Qwen3 and other DashScope models if model_type and (model_type.startswith("qwen") or model_type.startswith("qwq") or model_type.startswith("qvq")): self.btype["chat"] = const.QWEN_DASHSCOPE if model_type and model_type.startswith("gemini"): self.btype["chat"] = const.GEMINI if model_type and model_type.startswith("glm"): self.btype["chat"] = const.ZHIPU_AI if model_type and model_type.startswith("claude"): self.btype["chat"] = const.CLAUDEAPI if model_type in [const.MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]: self.btype["chat"] = const.MOONSHOT if model_type and model_type.startswith("kimi"): self.btype["chat"] = const.MOONSHOT if model_type and model_type.startswith("doubao"): self.btype["chat"] = const.DOUBAO if model_type in [const.MODELSCOPE]: self.btype["chat"] = const.MODELSCOPE # MiniMax models if model_type and (model_type in ["abab6.5-chat", "abab6.5"] or model_type.lower().startswith("minimax")): self.btype["chat"] = const.MiniMax if conf().get("use_linkai") and conf().get("linkai_api_key"): self.btype["chat"] = const.LINKAI if not conf().get("voice_to_text") or conf().get("voice_to_text") in ["openai"]: self.btype["voice_to_text"] = const.LINKAI if not conf().get("text_to_voice") or conf().get("text_to_voice") in ["openai", const.TTS_1, const.TTS_1_HD]: self.btype["text_to_voice"] = const.LINKAI self.bots = {} self.chat_bots = {} self._agent_bridge = None # 模型对应的接口 def get_bot(self, typename): if self.bots.get(typename) is None: logger.info("create bot {} for {}".format(self.btype[typename], typename)) if typename == "text_to_voice": self.bots[typename] = create_voice(self.btype[typename]) elif typename == "voice_to_text": self.bots[typename] = create_voice(self.btype[typename]) elif typename == "chat": self.bots[typename] = create_bot(self.btype[typename]) elif typename == "translate": self.bots[typename] = create_translator(self.btype[typename]) return self.bots[typename] def get_bot_type(self, typename): return self.btype[typename] def fetch_reply_content(self, query, context: Context) -> Reply: return self.get_bot("chat").reply(query, context) def fetch_voice_to_text(self, voiceFile) -> Reply: return self.get_bot("voice_to_text").voiceToText(voiceFile) def fetch_text_to_voice(self, text) -> Reply: return self.get_bot("text_to_voice").textToVoice(text) def fetch_translate(self, text, from_lang="", to_lang="en") -> Reply: return self.get_bot("translate").translate(text, from_lang, to_lang) def find_chat_bot(self, bot_type: str): if self.chat_bots.get(bot_type) is None: self.chat_bots[bot_type] = create_bot(bot_type) return self.chat_bots.get(bot_type) def reset_bot(self): """ 重置bot路由 """ self.__init__() def get_agent_bridge(self): """ Get agent bridge for agent-based conversations """ if self._agent_bridge is None: from bridge.agent_bridge import AgentBridge self._agent_bridge = AgentBridge(self) return self._agent_bridge def fetch_agent_reply(self, query: str, context: Context = None, on_event=None, clear_history: bool = False) -> Reply: """ Use super agent to handle the query Args: query: User query context: Context object on_event: Event callback for streaming clear_history: Whether to clear conversation history Returns: Reply object """ agent_bridge = self.get_agent_bridge() return agent_bridge.agent_reply(query, context, on_event, clear_history) ================================================ FILE: bridge/context.py ================================================ # encoding:utf-8 from enum import Enum class ContextType(Enum): TEXT = 1 # 文本消息 VOICE = 2 # 音频消息 IMAGE = 3 # 图片消息 FILE = 4 # 文件信息 VIDEO = 5 # 视频信息 SHARING = 6 # 分享信息 IMAGE_CREATE = 10 # 创建图片命令 ACCEPT_FRIEND = 19 # 同意好友请求 JOIN_GROUP = 20 # 加入群聊 PATPAT = 21 # 拍了拍 FUNCTION = 22 # 函数调用 EXIT_GROUP = 23 #退出 def __str__(self): return self.name class Context: def __init__(self, type: ContextType = None, content=None, kwargs=dict()): self.type = type self.content = content self.kwargs = kwargs def __contains__(self, key): if key == "type": return self.type is not None elif key == "content": return self.content is not None else: return key in self.kwargs def __getitem__(self, key): if key == "type": return self.type elif key == "content": return self.content else: return self.kwargs[key] def get(self, key, default=None): try: return self[key] except KeyError: return default def __setitem__(self, key, value): if key == "type": self.type = value elif key == "content": self.content = value else: self.kwargs[key] = value def __delitem__(self, key): if key == "type": self.type = None elif key == "content": self.content = None else: del self.kwargs[key] def __str__(self): return "Context(type={}, content={}, kwargs={})".format(self.type, self.content, self.kwargs) ================================================ FILE: bridge/reply.py ================================================ # encoding:utf-8 from enum import Enum class ReplyType(Enum): TEXT = 1 # 文本 VOICE = 2 # 音频文件 IMAGE = 3 # 图片文件 IMAGE_URL = 4 # 图片URL VIDEO_URL = 5 # 视频URL FILE = 6 # 文件 CARD = 7 # 微信名片,仅支持ntchat INVITE_ROOM = 8 # 邀请好友进群 INFO = 9 ERROR = 10 TEXT_ = 11 # 强制文本 VIDEO = 12 MINIAPP = 13 # 小程序 def __str__(self): return self.name class Reply: def __init__(self, type: ReplyType = None, content=None): self.type = type self.content = content def __str__(self): return "Reply(type={}, content={})".format(self.type, self.content) ================================================ FILE: channel/channel.py ================================================ """ Message sending channel abstract class """ from bridge.bridge import Bridge from bridge.context import Context from bridge.reply import * from common.log import logger from config import conf class Channel(object): channel_type = "" NOT_SUPPORT_REPLYTYPE = [ReplyType.VOICE, ReplyType.IMAGE] def __init__(self): import threading self._startup_event = threading.Event() self._startup_error = None self.cloud_mode = False # set to True by ChannelManager when running with cloud client def startup(self): """ init channel """ raise NotImplementedError def report_startup_success(self): self._startup_error = None self._startup_event.set() def report_startup_error(self, error: str): self._startup_error = error self._startup_event.set() def wait_startup(self, timeout: float = 3) -> (bool, str): """ Wait for channel startup result. Returns (success: bool, error_msg: str). """ ready = self._startup_event.wait(timeout=timeout) if not ready: return True, "" if self._startup_error: return False, self._startup_error return True, "" def stop(self): """ stop channel gracefully, called before restart """ pass def handle_text(self, msg): """ process received msg :param msg: message object """ raise NotImplementedError # 统一的发送函数,每个Channel自行实现,根据reply的type字段发送不同类型的消息 def send(self, reply: Reply, context: Context): """ send message to user :param msg: message content :param receiver: receiver channel account :return: """ raise NotImplementedError def build_reply_content(self, query, context: Context = None) -> Reply: """ Build reply content, using agent if enabled in config """ # Check if agent mode is enabled use_agent = conf().get("agent", False) if use_agent: try: logger.info("[Channel] Using agent mode") # Add channel_type to context if not present if context and "channel_type" not in context: context["channel_type"] = self.channel_type # Read on_event callback injected by the channel (e.g. web SSE) on_event = context.get("on_event") if context else None # Use agent bridge to handle the query return Bridge().fetch_agent_reply( query=query, context=context, on_event=on_event, clear_history=False ) except Exception as e: logger.error(f"[Channel] Agent mode failed, fallback to normal mode: {e}") # Fallback to normal mode if agent fails return Bridge().fetch_reply_content(query, context) else: # Normal mode return Bridge().fetch_reply_content(query, context) def build_voice_to_text(self, voice_file) -> Reply: return Bridge().fetch_voice_to_text(voice_file) def build_text_to_voice(self, text) -> Reply: return Bridge().fetch_text_to_voice(text) ================================================ FILE: channel/channel_factory.py ================================================ """ channel factory """ from common import const from .channel import Channel def create_channel(channel_type) -> Channel: """ create a channel instance :param channel_type: channel type code :return: channel instance """ ch = Channel() if channel_type == "terminal": from channel.terminal.terminal_channel import TerminalChannel ch = TerminalChannel() elif channel_type == 'web': from channel.web.web_channel import WebChannel ch = WebChannel() elif channel_type == "wechatmp": from channel.wechatmp.wechatmp_channel import WechatMPChannel ch = WechatMPChannel(passive_reply=True) elif channel_type == "wechatmp_service": from channel.wechatmp.wechatmp_channel import WechatMPChannel ch = WechatMPChannel(passive_reply=False) elif channel_type == "wechatcom_app": from channel.wechatcom.wechatcomapp_channel import WechatComAppChannel ch = WechatComAppChannel() elif channel_type == const.FEISHU: from channel.feishu.feishu_channel import FeiShuChanel ch = FeiShuChanel() elif channel_type == const.DINGTALK: from channel.dingtalk.dingtalk_channel import DingTalkChanel ch = DingTalkChanel() elif channel_type == const.WECOM_BOT: from channel.wecom_bot.wecom_bot_channel import WecomBotChannel ch = WecomBotChannel() elif channel_type == const.QQ: from channel.qq.qq_channel import QQChannel ch = QQChannel() else: raise RuntimeError ch.channel_type = channel_type return ch ================================================ FILE: channel/chat_channel.py ================================================ import os import re import threading import time from asyncio import CancelledError from concurrent.futures import Future, ThreadPoolExecutor from bridge.context import * from bridge.reply import * from channel.channel import Channel from common.dequeue import Dequeue from common import memory from plugins import * try: from voice.audio_convert import any_to_wav except Exception as e: pass handler_pool = ThreadPoolExecutor(max_workers=8) # 处理消息的线程池 # 抽象类, 它包含了与消息通道无关的通用处理逻辑 class ChatChannel(Channel): name = None # 登录的用户名 user_id = None # 登录的用户id def __init__(self): super().__init__() # Instance-level attributes so each channel subclass has its own # independent session queue and lock. Previously these were class-level, # which caused contexts from one channel (e.g. Feishu) to be consumed # by another channel's consume() thread (e.g. Web), leading to errors # like "No request_id found in context". self.futures = {} self.sessions = {} self.lock = threading.Lock() _thread = threading.Thread(target=self.consume) _thread.setDaemon(True) _thread.start() # 根据消息构造context,消息内容相关的触发项写在这里 def _compose_context(self, ctype: ContextType, content, **kwargs): context = Context(ctype, content) context.kwargs = kwargs if "channel_type" not in context: context["channel_type"] = self.channel_type if "origin_ctype" not in context: context["origin_ctype"] = ctype # context首次传入时,receiver是None,根据类型设置receiver first_in = "receiver" not in context # 群名匹配过程,设置session_id和receiver if first_in: # context首次传入时,receiver是None,根据类型设置receiver config = conf() cmsg = context["msg"] user_data = conf().get_user_data(cmsg.from_user_id) context["openai_api_key"] = user_data.get("openai_api_key") context["gpt_model"] = user_data.get("gpt_model") if context.get("isgroup", False): group_name = cmsg.other_user_nickname group_id = cmsg.other_user_id group_name_white_list = config.get("group_name_white_list", []) group_name_keyword_white_list = config.get("group_name_keyword_white_list", []) if any( [ group_name in group_name_white_list, "ALL_GROUP" in group_name_white_list, check_contain(group_name, group_name_keyword_white_list), ] ): # Check global group_shared_session config first group_shared_session = conf().get("group_shared_session", True) if group_shared_session: # All users in the group share the same session session_id = group_id else: # Check group-specific whitelist (legacy behavior) group_chat_in_one_session = conf().get("group_chat_in_one_session", []) session_id = cmsg.actual_user_id if any( [ group_name in group_chat_in_one_session, "ALL_GROUP" in group_chat_in_one_session, ] ): session_id = group_id else: logger.debug(f"No need reply, groupName not in whitelist, group_name={group_name}") return None context["session_id"] = session_id context["receiver"] = group_id else: context["session_id"] = cmsg.other_user_id context["receiver"] = cmsg.other_user_id e_context = PluginManager().emit_event(EventContext(Event.ON_RECEIVE_MESSAGE, {"channel": self, "context": context})) context = e_context["context"] if e_context.is_pass() or context is None: return context if cmsg.from_user_id == self.user_id and not config.get("trigger_by_self", True): logger.debug("[chat_channel]self message skipped") return None # 消息内容匹配过程,并处理content if ctype == ContextType.TEXT: if first_in and "」\n- - - - - - -" in content: # 初次匹配 过滤引用消息 logger.debug(content) logger.debug("[chat_channel]reference query skipped") return None nick_name_black_list = conf().get("nick_name_black_list", []) if context.get("isgroup", False): # 群聊 # 校验关键字 match_prefix = check_prefix(content, conf().get("group_chat_prefix")) match_contain = check_contain(content, conf().get("group_chat_keyword")) flag = False if context["msg"].to_user_id != context["msg"].actual_user_id: if match_prefix is not None or match_contain is not None: flag = True if match_prefix: content = content.replace(match_prefix, "", 1).strip() if context["msg"].is_at: nick_name = context["msg"].actual_user_nickname if nick_name and nick_name in nick_name_black_list: # 黑名单过滤 logger.warning(f"[chat_channel] Nickname {nick_name} in In BlackList, ignore") return None logger.info("[chat_channel]receive group at") if not conf().get("group_at_off", False): flag = True self.name = self.name if self.name is not None else "" # 部分渠道self.name可能没有赋值 pattern = f"@{re.escape(self.name)}(\u2005|\u0020)" subtract_res = re.sub(pattern, r"", content) if isinstance(context["msg"].at_list, list): for at in context["msg"].at_list: pattern = f"@{re.escape(at)}(\u2005|\u0020)" subtract_res = re.sub(pattern, r"", subtract_res) if subtract_res == content and context["msg"].self_display_name: # 前缀移除后没有变化,使用群昵称再次移除 pattern = f"@{re.escape(context['msg'].self_display_name)}(\u2005|\u0020)" subtract_res = re.sub(pattern, r"", content) content = subtract_res if not flag: if context["origin_ctype"] == ContextType.VOICE: logger.info("[chat_channel]receive group voice, but checkprefix didn't match") return None else: # 单聊 nick_name = context["msg"].from_user_nickname if nick_name and nick_name in nick_name_black_list: # 黑名单过滤 logger.warning(f"[chat_channel] Nickname '{nick_name}' in In BlackList, ignore") return None match_prefix = check_prefix(content, conf().get("single_chat_prefix", [""])) if match_prefix is not None: # 判断如果匹配到自定义前缀,则返回过滤掉前缀+空格后的内容 content = content.replace(match_prefix, "", 1).strip() elif context["origin_ctype"] == ContextType.VOICE: # 如果源消息是私聊的语音消息,允许不匹配前缀,放宽条件 pass else: logger.info("[chat_channel]receive single chat msg, but checkprefix didn't match") return None content = content.strip() img_match_prefix = check_prefix(content, conf().get("image_create_prefix",[""])) if img_match_prefix: content = content.replace(img_match_prefix, "", 1) context.type = ContextType.IMAGE_CREATE else: context.type = ContextType.TEXT context.content = content.strip() if "desire_rtype" not in context and conf().get("always_reply_voice") and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE: context["desire_rtype"] = ReplyType.VOICE elif context.type == ContextType.VOICE: if "desire_rtype" not in context and conf().get("voice_reply_voice") and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE: context["desire_rtype"] = ReplyType.VOICE return context def _handle(self, context: Context): if context is None or not context.content: return logger.debug("[chat_channel] handling context: {}".format(context)) # reply的构建步骤 reply = self._generate_reply(context) logger.debug("[chat_channel] decorating reply: {}".format(reply)) # reply的包装步骤 if reply and reply.content: reply = self._decorate_reply(context, reply) # reply的发送步骤 self._send_reply(context, reply) def _generate_reply(self, context: Context, reply: Reply = Reply()) -> Reply: e_context = PluginManager().emit_event( EventContext( Event.ON_HANDLE_CONTEXT, {"channel": self, "context": context, "reply": reply}, ) ) reply = e_context["reply"] if not e_context.is_pass(): logger.debug("[chat_channel] type={}, content={}".format(context.type, context.content)) if context.type == ContextType.TEXT or context.type == ContextType.IMAGE_CREATE: # 文字和图片消息 context["channel"] = e_context["channel"] reply = super().build_reply_content(context.content, context) elif context.type == ContextType.VOICE: # 语音消息 cmsg = context["msg"] cmsg.prepare() file_path = context.content wav_path = os.path.splitext(file_path)[0] + ".wav" try: any_to_wav(file_path, wav_path) except Exception as e: # 转换失败,直接使用mp3,对于某些api,mp3也可以识别 logger.warning("[chat_channel]any to wav error, use raw path. " + str(e)) wav_path = file_path # 语音识别 reply = super().build_voice_to_text(wav_path) # 删除临时文件 try: os.remove(file_path) if wav_path != file_path: os.remove(wav_path) except Exception as e: pass # logger.warning("[chat_channel]delete temp file error: " + str(e)) if reply.type == ReplyType.TEXT: new_context = self._compose_context(ContextType.TEXT, reply.content, **context.kwargs) if new_context: reply = self._generate_reply(new_context) else: return elif context.type == ContextType.IMAGE: # 图片消息,当前仅做下载保存到本地的逻辑 memory.USER_IMAGE_CACHE[context["session_id"]] = { "path": context.content, "msg": context.get("msg") } elif context.type == ContextType.SHARING: # 分享信息,当前无默认逻辑 pass elif context.type == ContextType.FUNCTION or context.type == ContextType.FILE: # 文件消息及函数调用等,当前无默认逻辑 pass else: logger.warning("[chat_channel] unknown context type: {}".format(context.type)) return return reply def _decorate_reply(self, context: Context, reply: Reply) -> Reply: if reply and reply.type: e_context = PluginManager().emit_event( EventContext( Event.ON_DECORATE_REPLY, {"channel": self, "context": context, "reply": reply}, ) ) reply = e_context["reply"] desire_rtype = context.get("desire_rtype") if not e_context.is_pass() and reply and reply.type: if reply.type in self.NOT_SUPPORT_REPLYTYPE: logger.error("[chat_channel]reply type not support: " + str(reply.type)) reply.type = ReplyType.ERROR reply.content = "不支持发送的消息类型: " + str(reply.type) if reply.type == ReplyType.TEXT: reply_text = reply.content if desire_rtype == ReplyType.VOICE and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE: reply = super().build_text_to_voice(reply.content) return self._decorate_reply(context, reply) if context.get("isgroup", False): if not context.get("no_need_at", False): reply_text = "@" + context["msg"].actual_user_nickname + "\n" + reply_text.strip() reply_text = conf().get("group_chat_reply_prefix", "") + reply_text + conf().get("group_chat_reply_suffix", "") else: reply_text = conf().get("single_chat_reply_prefix", "") + reply_text + conf().get("single_chat_reply_suffix", "") reply.content = reply_text elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO: reply.content = "[" + str(reply.type) + "]\n" + reply.content elif reply.type == ReplyType.IMAGE_URL or reply.type == ReplyType.VOICE or reply.type == ReplyType.IMAGE or reply.type == ReplyType.FILE or reply.type == ReplyType.VIDEO or reply.type == ReplyType.VIDEO_URL: pass else: logger.error("[chat_channel] unknown reply type: {}".format(reply.type)) return if desire_rtype and desire_rtype != reply.type and reply.type not in [ReplyType.ERROR, ReplyType.INFO]: logger.warning("[chat_channel] desire_rtype: {}, but reply type: {}".format(context.get("desire_rtype"), reply.type)) return reply def _send_reply(self, context: Context, reply: Reply): if reply and reply.type: e_context = PluginManager().emit_event( EventContext( Event.ON_SEND_REPLY, {"channel": self, "context": context, "reply": reply}, ) ) reply = e_context["reply"] if not e_context.is_pass() and reply and reply.type: logger.debug("[chat_channel] sending reply: {}, context: {}".format(reply, context)) # 如果是文本回复,尝试提取并发送图片 if reply.type == ReplyType.TEXT: self._extract_and_send_images(reply, context) # 如果是图片回复但带有文本内容,先发文本再发图片 elif reply.type == ReplyType.IMAGE_URL and hasattr(reply, 'text_content') and reply.text_content: # 先发送文本 text_reply = Reply(ReplyType.TEXT, reply.text_content) self._send(text_reply, context) # 短暂延迟后发送图片 time.sleep(0.3) self._send(reply, context) else: self._send(reply, context) def _extract_and_send_images(self, reply: Reply, context: Context): """ 从文本回复中提取图片/视频URL并单独发送 支持格式:[图片: /path/to/image.png], [视频: /path/to/video.mp4], ![](url), 最多发送5个媒体文件 """ content = reply.content media_items = [] # [(url, type), ...] # 正则提取各种格式的媒体URL patterns = [ (r'\[图片:\s*([^\]]+)\]', 'image'), # [图片: /path/to/image.png] (r'\[视频:\s*([^\]]+)\]', 'video'), # [视频: /path/to/video.mp4] (r'!\[.*?\]\(([^\)]+)\)', 'image'), # ![alt](url) - 默认图片 (r']+src=["\']([^"\']+)["\']', 'image'), # (r']+src=["\']([^"\']+)["\']', 'video'), #