mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-08-04 01:19:36 +00:00
Compare commits
331 Commits
v0.2.9
...
52320f4875
| Author | SHA1 | Date | |
|---|---|---|---|
| 52320f4875 | |||
| c15aac21fe | |||
| 7ee4f41b7e | |||
| d65d592a40 | |||
| 06830c9b19 | |||
| 9bc8d15685 | |||
| 979f440af7 | |||
| 377c25e7c5 | |||
| 2cdfadaa8b | |||
| 0eb721fff7 | |||
| 14c9a1f1f8 | |||
| 500ce72436 | |||
| 583d9e1e14 | |||
| 1bd2e84527 | |||
| 36d72a0112 | |||
| 1260fd8e2f | |||
| 54f1ab75e2 | |||
| 7f90ce7a98 | |||
| 70b2364f39 | |||
| 124c529bf1 | |||
| 5feeb91c60 | |||
| 9287df415b | |||
| ae81ae93cf | |||
| e4bda8c4a1 | |||
| 3400221527 | |||
| 7ee4ee3b64 | |||
| 62a2b0015a | |||
| d6371fcb68 | |||
| dff16dbb91 | |||
| 0a47fc8eb1 | |||
| f53e6e68e2 | |||
| 288013ca0f | |||
| 5e3fd4cd0f | |||
| 540f81ff9e | |||
| a75b3d15bb | |||
| eb8b6ca7c8 | |||
| ec9c6cde8e | |||
| 646c2db1e9 | |||
| dc46c05aac | |||
| 43c671abdc | |||
| a63b44ac74 | |||
| 287853ab58 | |||
| 0d1ed99007 | |||
| 70afab59e6 | |||
| a3be5a6b25 | |||
| 62206befe1 | |||
| 38a1d823c3 | |||
| 35db389025 | |||
| aeab7b15a4 | |||
| 6d0ca80f21 | |||
| 4514d54ff5 | |||
| d4adbf9418 | |||
| 99291182e7 | |||
| 6c31f9ffdd | |||
| e3464f4388 | |||
| 08192ddf1b | |||
| 7b5fe01187 | |||
| 1420875921 | |||
| 234bd03018 | |||
| b2fbe83565 | |||
| a16a1e1535 | |||
| 910bfbe6a4 | |||
| 03b022c2ac | |||
| cc4055b6d4 | |||
| 86221d470b | |||
| e720be0dd7 | |||
| c9f5872e02 | |||
| 0c1e6b5279 | |||
| 981ab3affe | |||
| 3056a540bf | |||
| 5b41f18c3d | |||
| 7019143c8e | |||
| 083e68b49a | |||
| fcc2882ffa | |||
| b292defd95 | |||
| ed55715b6b | |||
| 2f8c462366 | |||
| d2156a982c | |||
| c1ff5aa6f4 | |||
| dc746fbe66 | |||
| 890f4d6bcb | |||
| 42ece2aceb | |||
| 13a38bd1c4 | |||
| 7b2e0ccfc7 | |||
| 5e415ed210 | |||
| 647543bd26 | |||
| 73738f1cd3 | |||
| 8290c72d44 | |||
| e567f1faa1 | |||
| df753aaebf | |||
| 7d71a4d93f | |||
| 97039b259e | |||
| 9eadffda1e | |||
| e55ddf2e24 | |||
| cf67dd3851 | |||
| f091c04ca9 | |||
| 0bfb13c0df | |||
| 15196b9cc9 | |||
| d44fa890e4 | |||
| f087d8358b | |||
| c180590799 | |||
| f3d4555bc9 | |||
| adb89a16b9 | |||
| 58926f76b9 | |||
| 9fd0dbda96 | |||
| c362114cf1 | |||
| d8c0b783a8 | |||
| 7de3d1f7e3 | |||
| 7338df2cfb | |||
| 1719067aca | |||
| d26e4eca7d | |||
| bbdf746bcb | |||
| 409cc051e7 | |||
| 413d37494b | |||
| d153952c15 | |||
| 091c374348 | |||
| 0994d48e77 | |||
| 729935ef1a | |||
| 293fb69439 | |||
| c5a98a5f47 | |||
| f30c611327 | |||
| 23b52ebef5 | |||
| 0d4b06f088 | |||
| fa6ed714c4 | |||
| f8472d6f27 | |||
| 44fdf9a20b | |||
| 17e4720203 | |||
| ef002d9a5d | |||
| 017601354b | |||
| 52ab6c4694 | |||
| f8462855d8 | |||
| 2861fd90ab | |||
| 40fe1b0a2d | |||
| a7208983d0 | |||
| 9955155389 | |||
| d955d5bbf3 | |||
| 2efbe5d560 | |||
| bc6179917c | |||
| 351ecf0182 | |||
| f5add27d39 | |||
| 8a2c67fe70 | |||
| 355e83e07f | |||
| b9a8fad6fa | |||
| 0ab6924978 | |||
| 2ecdb893d5 | |||
| 921d753cc0 | |||
| 0bbd8f081e | |||
| 6b004f6a5f | |||
| 3daeec96ab | |||
| fc90a5af23 | |||
| e2112e627c | |||
| 46b29a0ae9 | |||
| 13bf650807 | |||
| 0f86d9aacb | |||
| c215a4caaf | |||
| 5b9f9c85a9 | |||
| b40a1d92cc | |||
| fac5603daf | |||
| a4e8fe953e | |||
| 77017eb57d | |||
| 92a647bfcf | |||
| 8a246c2282 | |||
| 3bba6338ca | |||
| 12c36572a5 | |||
| 890780b924 | |||
| 1ab442b12c | |||
| 3f435c5e56 | |||
| 875cf4a2d4 | |||
| 5e7b84f429 | |||
| 1b3e887fc6 | |||
| d627dc8b57 | |||
| 0a3a7881c6 | |||
| 639f700c15 | |||
| cbb684be01 | |||
| 67eaa984c7 | |||
| ebb04abb38 | |||
| a011df1ddc | |||
| f037a112b2 | |||
| 10115f941c | |||
| db13367404 | |||
| 7c18fe8421 | |||
| 007b2ae8bd | |||
| 2d1fb953fc | |||
| b1d727ebaf | |||
| f7be21bb11 | |||
| 7d2b0c2a4d | |||
| c19e4e8db1 | |||
| ebf17aa152 | |||
| 4290aa8b5b | |||
| 5f0d368995 | |||
| ddabaa69a4 | |||
| ff7c92deee | |||
| 4752a67a7c | |||
| 89ee8f1b39 | |||
| b10f9cdf18 | |||
| 0b7aaac2b2 | |||
| 8e7e910f67 | |||
| 71524183b6 | |||
| 6c882ec5e7 | |||
| 9f246a6482 | |||
| 7a7e205cc8 | |||
| 1f2736915e | |||
| 12ca46b1ab | |||
| cc712a1adb | |||
| 52e3ea72ba | |||
| f0f809db35 | |||
| e5c7772d3c | |||
| 32ea611f0c | |||
| b6030f054d | |||
| 296a8ae287 | |||
| a6735517d2 | |||
| 5224b9a4bc | |||
| 976ecc68b7 | |||
| dbd76fe541 | |||
| 49e3a03def | |||
| d5bd06dc0d | |||
| d009ba32b7 | |||
| 9b0ab22b3d | |||
| 3e6abba803 | |||
| 79aefc5062 | |||
| 9da23e7804 | |||
| a90d8d35ee | |||
| b86ab71836 | |||
| 0ce6e20e08 | |||
| 36ca85ad09 | |||
| 734f53fb37 | |||
| 6e9b5071b0 | |||
| aa49d066b0 | |||
| 5f826f4448 | |||
| 04664ab514 | |||
| 9c71a44421 | |||
| e1d9a62e0e | |||
| 709c8b2b52 | |||
| 5d4840c979 | |||
| a502aa7f83 | |||
| e74ac70cf9 | |||
| 8dffd6ff03 | |||
| 1903a18235 | |||
| 004f9346c1 | |||
| 827cd32ffc | |||
| 379ab9af2f | |||
| e70a9fca7c | |||
| 99a7179e76 | |||
| 7b47872334 | |||
| 5927ecc394 | |||
| bb57e0498c | |||
| e42006c10d | |||
| 426046fca0 | |||
| 28eafaeef2 | |||
| 1cfa781925 | |||
| 5a997a86f0 | |||
| 672f86c670 | |||
| 4e3e90df26 | |||
| be13201f02 | |||
| 6e0e2906aa | |||
| c0f2714b66 | |||
| ba8065923b | |||
| 13e1833c81 | |||
| 1d8ef7dcfb | |||
| 93391223ea | |||
| 41a108c9af | |||
| 1ce353ba28 | |||
| 4b8761ce6d | |||
| 63ba146015 | |||
| 16c26338b6 | |||
| 2391f32fc1 | |||
| 46e5b59d5f | |||
| 995005a0ba | |||
| 1edb873ace | |||
| 2ff8b01cc6 | |||
| e1bada5b94 | |||
| e81d37108b | |||
| 4e280c5f5e | |||
| 6247f47628 | |||
| 32282beef8 | |||
| f9f53e30ee | |||
| a34669a2d8 | |||
| f797172a86 | |||
| 8e0964be24 | |||
| 85751492c6 | |||
| 0b7e18cd9e | |||
| 89e7a61a69 | |||
| e9e653fb13 | |||
| 5755b5b323 | |||
| 65c09d4270 | |||
| 28ec5793a8 | |||
| c5a016ccc6 | |||
| 9825b4782f | |||
| f5f6fdc1f9 | |||
| cfbddcd117 | |||
| 7be20bf70a | |||
| ab6d3946a5 | |||
| 7af40d49eb | |||
| 239a98e18b | |||
| d499cbece4 | |||
| d48fa2e2fd | |||
| e95bcaf3e3 | |||
| fbea699936 | |||
| 23e1485a98 | |||
| edcae17b41 | |||
| d609e83313 | |||
| 96b4c543f4 | |||
| 477028f8f2 | |||
| 9bb44b0a80 | |||
| 6a97b1b087 | |||
| 020bef2759 | |||
| 848bf77381 | |||
| 3a454593ca | |||
| ceebda35ee | |||
| 1bf0d898de | |||
| c05e5e29c6 | |||
| 987f117f31 | |||
| 5a4e42d1b6 | |||
| f09a7d67f7 | |||
| 2cce7b8abe | |||
| 044a9d1df6 | |||
| d3ac0a74c4 | |||
| 24e8285e73 | |||
| 33e5503e26 | |||
| fd08ebd3db | |||
| 34e73f6b1a | |||
| 3e30e8abc6 | |||
| 81bbef62b1 | |||
| 76175b4bcf | |||
| 0dfdb54198 | |||
| 1bc7abfb50 | |||
| 3a68d26837 | |||
| 17cf91771c | |||
| f0dcba8c5a | |||
| fe7ded5c13 | |||
| 1502636bf0 |
@@ -1,3 +1,4 @@
|
|||||||
# Ensure shell scripts always use LF line endings regardless of OS.
|
# Ensure shell scripts always use LF line endings regardless of OS.
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
docker/entrypoint.sh text eol=lf
|
docker/entrypoint.sh text eol=lf
|
||||||
|
.gitignore text eol=lf
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: [sipeed]
|
||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Run Docker-backed integration suites
|
- name: Run Docker-backed integration suites
|
||||||
run: bash ./scripts/run-integration-tests.sh
|
run: bash ./scripts/run-integration-tests.sh
|
||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: main
|
ref: main
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# ── Checkout ──────────────────────────────
|
# ── Checkout ──────────────────────────────
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.tag }}
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
GOFLAGS: -tags=goolm,stdjson
|
GOFLAGS: -tags=goolm,stdjson
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Run Docker-backed integration suites
|
- name: Run Docker-backed integration suites
|
||||||
run: bash ./scripts/run-integration-tests.sh
|
run: bash ./scripts/run-integration-tests.sh
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Checkout tag
|
- name: Checkout tag
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ inputs.tag }}
|
ref: ${{ inputs.tag }}
|
||||||
|
|||||||
@@ -73,3 +73,4 @@ web/backend/dist/*
|
|||||||
docker/data
|
docker/data
|
||||||
|
|
||||||
.omc/
|
.omc/
|
||||||
|
.worktrees/
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ endif
|
|||||||
VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev)
|
VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev)
|
||||||
GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev)
|
GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev)
|
||||||
BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev)
|
BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev)
|
||||||
GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown)
|
GO_VERSION=$(if $(GO_VERSION_RAW),$(firstword $(GO_VERSION_RAW)),unknown)
|
||||||
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||||
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
|
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,17 @@
|
|||||||
|
|
||||||
[中文](docs/project/README.zh.md) | [日本語](docs/project/README.ja.md) | [한국어](docs/project/README.ko.md) | [Português](docs/project/README.pt-br.md) | [Tiếng Việt](docs/project/README.vi.md) | [Français](docs/project/README.fr.md) | [Italiano](docs/project/README.it.md) | [Bahasa Indonesia](docs/project/README.id.md) | [Malay](docs/project/README.ms.md) | **English**
|
[中文](docs/project/README.zh.md) | [日本語](docs/project/README.ja.md) | [한국어](docs/project/README.ko.md) | [Português](docs/project/README.pt-br.md) | [Tiếng Việt](docs/project/README.vi.md) | [Français](docs/project/README.fr.md) | [Italiano](docs/project/README.it.md) | [Bahasa Indonesia](docs/project/README.id.md) | [Malay](docs/project/README.ms.md) | **English**
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://picopaw.ai">
|
||||||
|
<img src="assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
||||||
|
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -49,13 +60,15 @@
|
|||||||
> **Security Notice**
|
> **Security Notice**
|
||||||
>
|
>
|
||||||
> * **NO CRYPTO:** PicoClaw has **not** issued any official tokens or cryptocurrency. All claims on `pump.fun` or other trading platforms are **scams**.
|
> * **NO CRYPTO:** PicoClaw has **not** issued any official tokens or cryptocurrency. All claims on `pump.fun` or other trading platforms are **scams**.
|
||||||
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
|
> * **OFFICIAL DOMAIN:** The **ONLY** official PicoClaw website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
|
||||||
> * **BEWARE:** Many `.ai/.org/.com/.net/...` domains have been registered by third parties. Do not trust them.
|
> * **BEWARE:** Many lookalike `.ai/.org/.com/.net/...` domains have been registered by third parties. Only trust domains explicitly linked from this README.
|
||||||
> * **NOTE:** PicoClaw is in early rapid development. There may be unresolved security issues. Do not deploy to production before v1.0.
|
> * **NOTE:** PicoClaw is in early rapid development. There may be unresolved security issues. Do not deploy to production before v1.0.
|
||||||
> * **NOTE:** PicoClaw has recently merged many PRs. Recent builds may use 10-20MB RAM. Resource optimization is planned after feature stabilization.
|
> * **NOTE:** PicoClaw has recently merged many PRs. Recent builds may use 10-20MB RAM. Resource optimization is planned after feature stabilization.
|
||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
|
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw on AliExpress!** You can now purchase LicheeRV-Claw from [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), making it easier to try PicoClaw on compact RISC-V hardware.
|
2026-05-11 🛒 **LicheeRV-Claw on AliExpress!** You can now purchase LicheeRV-Claw from [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), making it easier to try PicoClaw on compact RISC-V hardware.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -64,6 +77,16 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
2026-05-28 🚀 **v0.2.9 Released!** MCP server management in Web UI, configurable Sogou-backed web search, tool feedback animation in channels, `pretty_print` and `disable_escape_html` defaults, and numerous bug fixes across providers and channels.
|
||||||
|
|
||||||
|
2026-05-14 🚀 **v0.2.8 Released!** MCP CLI commands (`show`, `add`, `list`, `remove`, `test`, `edit`), empty object instead of null for MCP tool parameters, and build fixes.
|
||||||
|
|
||||||
|
2026-05-07 🚀 **v0.2.7 Released!** Configurable Sogou-backed web search, channel tool feedback animation, linter fixes.
|
||||||
|
|
||||||
|
2026-04-23 🚀 **v0.2.6 Released!** Hooks with respond action and comprehensive documentation, isolation support, help banner fix.
|
||||||
|
|
||||||
|
2026-04-11 🚀 **v0.2.5 Released!** Zoneinfo from TZ/ZONEINFO env, Matrix CommonMark rendering alignment, `read_file` by lines.
|
||||||
|
|
||||||
2026-03-31 📱 **Android Support!** PicoClaw now runs on Android! Download the APK at [picoclaw.io](https://picoclaw.io/download)
|
2026-03-31 📱 **Android Support!** PicoClaw now runs on Android! Download the APK at [picoclaw.io](https://picoclaw.io/download)
|
||||||
|
|
||||||
2026-03-25 🚀 **v0.2.4 Released!** Agent architecture overhaul (SubTurn, Hooks, Steering, EventBus), WeChat/WeCom integration, security hardening (.security.yml, sensitive data filtering), new providers (AWS Bedrock, Azure, Xiaomi MiMo), and 35 bug fixes. PicoClaw has reached **26K Stars**!
|
2026-03-25 🚀 **v0.2.4 Released!** Agent architecture overhaul (SubTurn, Hooks, Steering, EventBus), WeChat/WeCom integration, security hardening (.security.yml, sensitive data filtering), new providers (AWS Bedrock, Azure, Xiaomi MiMo), and 35 bug fixes. PicoClaw has reached **26K Stars**!
|
||||||
@@ -321,6 +344,8 @@ Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install d
|
|||||||
|
|
||||||
**Option 2: Termux**
|
**Option 2: Termux**
|
||||||
|
|
||||||
|
For a full command-line setup checklist, see the [Android Termux Guide](docs/guides/android-termux.md).
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Terminal Launcher (for resource-constrained environments)</b></summary>
|
<summary><b>Terminal Launcher (for resource-constrained environments)</b></summary>
|
||||||
|
|
||||||
@@ -408,17 +433,20 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use
|
|||||||
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Required | Mistral Large, Codestral |
|
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Required | Mistral Large, Codestral |
|
||||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Required | NVIDIA hosted models |
|
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Required | NVIDIA hosted models |
|
||||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Required | Fast inference |
|
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Required | Fast inference |
|
||||||
|
| [NEAR AI Cloud](https://near.ai/) | `nearai/` | Required | TEE inference, OpenAI-compatible |
|
||||||
| [Novita AI](https://novita.ai/) | `novita/` | Required | Various open models |
|
| [Novita AI](https://novita.ai/) | `novita/` | Required | Various open models |
|
||||||
| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Required | MiMo models |
|
| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Required | MiMo models |
|
||||||
| [Ollama](https://ollama.com/) | `ollama/` | Not needed | Local models, self-hosted |
|
| [Ollama](https://ollama.com/) | `ollama/` | Not needed | Local models, self-hosted |
|
||||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Not needed | Local deployment, OpenAI-compatible |
|
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Not needed | Local deployment, OpenAI-compatible |
|
||||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Varies | Proxy for 100+ providers |
|
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Varies | Proxy for 100+ providers |
|
||||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Required | Enterprise Azure deployment |
|
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | API key or Entra ID** | Enterprise Azure deployment |
|
||||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Device code login |
|
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Device code login |
|
||||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||||
| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | AWS credentials | Claude, Llama, Mistral on AWS |
|
| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | AWS credentials | Claude, Llama, Mistral on AWS |
|
||||||
|
|
||||||
> \* AWS Bedrock requires build tag: `go build -tags bedrock`. Set `api_base` to a region name (e.g., `us-east-1`) for automatic endpoint resolution across all AWS partitions (aws, aws-cn, aws-us-gov). When using a full endpoint URL instead, you must also configure `AWS_REGION` via environment variable or AWS config/profile.
|
> \* AWS Bedrock requires build tag: `go build -tags bedrock`. Set `api_base` to a region name (e.g., `us-east-1`) for automatic endpoint resolution across all AWS partitions (aws, aws-cn, aws-us-gov). When using a full endpoint URL instead, you must also configure `AWS_REGION` via environment variable or AWS config/profile.
|
||||||
|
>
|
||||||
|
> \*\* Azure OpenAI uses `api_key` when set. If `api_key` is omitted, the provider falls back to Microsoft Entra ID via `DefaultAzureCredential` (env vars, workload identity, managed identity, Azure CLI, etc.). The Entra ID path requires build tag: `go build -tags azidentity`.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Local deployment (Ollama, vLLM, etc.)</b></summary>
|
<summary><b>Local deployment (Ollama, vLLM, etc.)</b></summary>
|
||||||
@@ -497,6 +525,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too
|
|||||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1500/month (daily allocation) | AI-powered, China-optimized |
|
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1500/month (daily allocation) | AI-powered, China-optimized |
|
||||||
| [Tavily](https://tavily.com) | Required | 1000 queries/month | Optimized for AI Agents |
|
| [Tavily](https://tavily.com) | Required | 1000 queries/month | Optimized for AI Agents |
|
||||||
| [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private |
|
| [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private |
|
||||||
|
| [Kagi Search](https://help.kagi.com/kagi/api/search.html) | Required | Paid/limited by API setup | Premium search results |
|
||||||
| [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search |
|
| [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search |
|
||||||
| [SearXNG](https://github.com/searxng/searxng) | Not needed | Self-hosted | Free metasearch engine |
|
| [SearXNG](https://github.com/searxng/searxng) | Not needed | Self-hosted | Free metasearch engine |
|
||||||
| [GLM Search](https://open.bigmodel.cn/) | Required | Varies | Zhipu web search |
|
| [GLM Search](https://open.bigmodel.cn/) | Required | Varies | Zhipu web search |
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 327 KiB After Width: | Height: | Size: 261 KiB |
@@ -155,7 +155,7 @@ func (c *LLMClient) Complete(ctx context.Context, systemPrompt, userPrompt strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
respBody, lastErr = io.ReadAll(resp.Body)
|
respBody, lastErr = io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
if lastErr != nil {
|
if lastErr != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,12 +56,23 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
|
|||||||
|
|
||||||
// Print agent startup info (only for interactive mode)
|
// Print agent startup info (only for interactive mode)
|
||||||
startupInfo := agentLoop.GetStartupInfo()
|
startupInfo := agentLoop.GetStartupInfo()
|
||||||
logger.InfoCF("agent", "Agent initialized",
|
toolsInfo, ok := startupInfo["tools"].(map[string]any)
|
||||||
map[string]any{
|
if !ok {
|
||||||
"tools_count": startupInfo["tools"].(map[string]any)["count"],
|
toolsInfo = nil
|
||||||
"skills_total": startupInfo["skills"].(map[string]any)["total"],
|
}
|
||||||
"skills_available": startupInfo["skills"].(map[string]any)["available"],
|
skillsInfo, ok := startupInfo["skills"].(map[string]any)
|
||||||
})
|
if !ok {
|
||||||
|
skillsInfo = nil
|
||||||
|
}
|
||||||
|
logFields := map[string]any{}
|
||||||
|
if toolsInfo != nil {
|
||||||
|
logFields["tools_count"] = toolsInfo["count"]
|
||||||
|
}
|
||||||
|
if skillsInfo != nil {
|
||||||
|
logFields["skills_total"] = skillsInfo["total"]
|
||||||
|
logFields["skills_available"] = skillsInfo["available"]
|
||||||
|
}
|
||||||
|
logger.InfoCF("agent", "Agent initialized", logFields)
|
||||||
|
|
||||||
if message != "" {
|
if message != "" {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -68,6 +69,21 @@ func TestFetchOpenAIModels_HTTPError(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "HTTP 401")
|
assert.Contains(t, err.Error(), "HTTP 401")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFetchOpenAIModels_HTTPErrorReadFailure(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
body := "short"
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(body)+1))
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := fetchOpenAIModels(srv.URL, "bad")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "read error response")
|
||||||
|
assert.Contains(t, err.Error(), "unexpected EOF")
|
||||||
|
}
|
||||||
|
|
||||||
func TestFetchOpenAIModels_EmptyDataEnvelope(t *testing.T) {
|
func TestFetchOpenAIModels_EmptyDataEnvelope(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = w.Write([]byte(`{"data":[]}`))
|
_, _ = w.Write([]byte(`{"data":[]}`))
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ func fetchOpenAIModels(baseURL, apiKey string) ([]modelEntry, error) {
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, fmt.Errorf("read error response: %w", readErr)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
package onboard
|
package onboard
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
picoclaw "github.com/sipeed/picoclaw"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:generate go run ../../../../scripts/copydir.go ../../../../workspace ./workspace
|
var embeddedFiles = picoclaw.OnboardWorkspace
|
||||||
//go:embed workspace
|
|
||||||
var embeddedFiles embed.FS
|
|
||||||
|
|
||||||
func NewOnboardCommand() *cobra.Command {
|
func NewOnboardCommand() *cobra.Command {
|
||||||
var encrypt bool
|
var encrypt bool
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, target string)
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
|
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
|
||||||
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
|
return fmt.Errorf("\u2717 failed to create skills directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := registry.DownloadAndInstall(ctx, target, "", targetDir)
|
result, err := registry.DownloadAndInstall(ctx, target, "", targetDir)
|
||||||
@@ -302,6 +302,11 @@ func skillsSearchCmd(query string) {
|
|||||||
if result.Version != "" {
|
if result.Version != "" {
|
||||||
fmt.Printf(" Version: %s\n", result.Version)
|
fmt.Printf(" Version: %s\n", result.Version)
|
||||||
}
|
}
|
||||||
|
if result.RegistryName == "github" {
|
||||||
|
fmt.Printf(" Install: picoclaw skills install %s\n", result.Slug)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" Install: picoclaw skills install --registry=%s %s\n", result.RegistryName, result.Slug)
|
||||||
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,9 +350,11 @@ func copyDirectory(src, dst string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer dstFile.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(dstFile, srcFile)
|
_, copyErr := io.Copy(dstFile, srcFile)
|
||||||
return err
|
if closeErr := dstFile.Close(); closeErr != nil && copyErr == nil {
|
||||||
|
return fmt.Errorf("close destination file %s: %w", dstPath, closeErr)
|
||||||
|
}
|
||||||
|
return copyErr
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -33,6 +35,49 @@ import (
|
|||||||
|
|
||||||
var rootNoColor bool
|
var rootNoColor bool
|
||||||
|
|
||||||
|
// initTermuxSSL detects Termux environment and sets SSL_CERT_FILE if not already set.
|
||||||
|
// This fixes X509 certificate errors when running PicoClaw inside Termux or termux-chroot.
|
||||||
|
// See: https://github.com/sipeed/picoclaw/issues/2944
|
||||||
|
func initTermuxSSL() {
|
||||||
|
// Only applicable on Linux/Android
|
||||||
|
if runtime.GOOS != "linux" && runtime.GOOS != "android" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if already set
|
||||||
|
if os.Getenv("SSL_CERT_FILE") != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Termux prefix in PATH or HOME
|
||||||
|
home := os.Getenv("HOME")
|
||||||
|
path := os.Getenv("PATH")
|
||||||
|
|
||||||
|
isTermux := strings.Contains(home, "com.termux") ||
|
||||||
|
strings.Contains(path, "com.termux") ||
|
||||||
|
strings.Contains(home, "/data/data/com.termux")
|
||||||
|
|
||||||
|
if !isTermux {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check common CA bundle locations in Termux
|
||||||
|
caPaths := []string{
|
||||||
|
"$PREFIX/etc/tls/cert.pem",
|
||||||
|
os.Getenv("PREFIX") + "/etc/tls/cert.pem",
|
||||||
|
"/data/data/com.termux/files/usr/etc/tls/cert.pem",
|
||||||
|
"/usr/etc/tls/cert.pem",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, caPath := range caPaths {
|
||||||
|
expanded := os.ExpandEnv(caPath)
|
||||||
|
if _, err := os.Stat(expanded); err == nil {
|
||||||
|
os.Setenv("SSL_CERT_FILE", expanded)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func syncCliUIColor(root *cobra.Command) {
|
func syncCliUIColor(root *cobra.Command) {
|
||||||
no, _ := root.PersistentFlags().GetBool("no-color")
|
no, _ := root.PersistentFlags().GetBool("no-color")
|
||||||
cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb")
|
cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb")
|
||||||
@@ -123,6 +168,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Initialize Termux SSL certificate detection before anything else
|
||||||
|
initTermuxSSL()
|
||||||
|
|
||||||
cliui.Init(earlyColorDisabled())
|
cliui.Init(earlyColorDisabled())
|
||||||
|
|
||||||
if earlyColorDisabled() {
|
if earlyColorDisabled() {
|
||||||
|
|||||||
@@ -67,6 +67,11 @@
|
|||||||
"model": "venice/venice-uncensored",
|
"model": "venice/venice-uncensored",
|
||||||
"api_keys": ["your-venice-api-key"]
|
"api_keys": ["your-venice-api-key"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"model_name": "nearai-glm",
|
||||||
|
"model": "nearai/zai-org/GLM-5.1-FP8",
|
||||||
|
"api_keys": ["your-nearai-api-key"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"model_name": "lmstudio-local",
|
"model_name": "lmstudio-local",
|
||||||
"model": "lmstudio/openai/gpt-oss-20b"
|
"model": "lmstudio/openai/gpt-oss-20b"
|
||||||
@@ -329,6 +334,13 @@
|
|||||||
"base_url": "",
|
"base_url": "",
|
||||||
"max_results": 0
|
"max_results": 0
|
||||||
},
|
},
|
||||||
|
"kagi": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "",
|
||||||
|
"api_keys": ["YOUR_KAGI_API_KEY"],
|
||||||
|
"base_url": "https://kagi.com/api/v1/search",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
"provider": "auto",
|
"provider": "auto",
|
||||||
"sogou": {
|
"sogou": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -373,7 +385,9 @@
|
|||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"exec_timeout_minutes": 5
|
"exec_timeout_minutes": 5,
|
||||||
|
"allow_command": true,
|
||||||
|
"command_allowed_remotes": []
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Task-oriented guides for setup, configuration, and common PicoClaw workflows.
|
Task-oriented guides for setup, configuration, and common PicoClaw workflows.
|
||||||
|
|
||||||
- [Docker & Quick Start Guide](docker.md): install and run PicoClaw with Docker or the launcher.
|
- [Docker & Quick Start Guide](docker.md): install and run PicoClaw with Docker or the launcher.
|
||||||
|
- [Android Termux Guide](android-termux.md): run the PicoClaw terminal binary on an ARM64 Android phone.
|
||||||
- [Configuration Guide](configuration.md): environment variables, workspace layout, routing, and sandbox settings.
|
- [Configuration Guide](configuration.md): environment variables, workspace layout, routing, and sandbox settings.
|
||||||
- [Session Guide](session-guide.md): how session scope affects memory sharing, summaries, and isolation.
|
- [Session Guide](session-guide.md): how session scope affects memory sharing, summaries, and isolation.
|
||||||
- [Routing Guide](routing-guide.md): agent dispatch, session overrides, and light-model routing.
|
- [Routing Guide](routing-guide.md): agent dispatch, session overrides, and light-model routing.
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Android Termux Guide
|
||||||
|
|
||||||
|
> Back to [Guides](README.md)
|
||||||
|
|
||||||
|
This guide covers running the PicoClaw terminal binary on an ARM64 Android phone with Termux. Use the APK from [picoclaw.io](https://picoclaw.io/download/) if you want the Android app experience; use Termux when you want a lightweight command-line install on an older or resource-constrained device.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- ARM64 Android device. Run `uname -m` in Termux and use this guide when it prints `aarch64`.
|
||||||
|
- Termux installed from [Termux GitHub Releases](https://github.com/termux/termux-app/releases) or F-Droid.
|
||||||
|
- Network access for downloading the release and calling your LLM provider.
|
||||||
|
- An API key for at least one configured model provider.
|
||||||
|
|
||||||
|
## Install PicoClaw
|
||||||
|
|
||||||
|
Open Termux and install the packages used by the release archive and chroot wrapper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkg update
|
||||||
|
pkg install -y wget tar proot
|
||||||
|
```
|
||||||
|
|
||||||
|
Download and unpack the ARM64 Linux release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/picoclaw
|
||||||
|
cd ~/picoclaw
|
||||||
|
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
|
||||||
|
tar xzf picoclaw_Linux_arm64.tar.gz
|
||||||
|
chmod +x ./picoclaw
|
||||||
|
```
|
||||||
|
|
||||||
|
Start first-run setup through `termux-chroot`, which gives the Linux binary a more standard filesystem layout than a raw Android userspace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
termux-chroot ./picoclaw onboard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configure
|
||||||
|
|
||||||
|
Edit the generated config and add at least one model provider API key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vim ~/.picoclaw/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The default workspace is `~/.picoclaw/workspace`. If you want PicoClaw to read or write Android shared storage, run `termux-setup-storage` first and then point the workspace or any file paths at the mounted storage directory.
|
||||||
|
|
||||||
|
See [Configuration Guide](configuration.md) and [Providers & Model Configuration](providers.md) for the available config fields and provider examples.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
Use one-shot agent mode to confirm the installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
termux-chroot ./picoclaw agent -m "Hello from Termux"
|
||||||
|
```
|
||||||
|
|
||||||
|
For long-running use, start the gateway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
termux-chroot ./picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the Termux session open while PicoClaw is running. Android battery optimization can stop background work, so disable battery optimization for Termux if you expect PicoClaw to keep running after the screen locks.
|
||||||
|
|
||||||
|
## Update
|
||||||
|
|
||||||
|
Your config and workspace live under `~/.picoclaw`, so updating the binary does not remove them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/picoclaw
|
||||||
|
rm -f picoclaw_Linux_arm64.tar.gz
|
||||||
|
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
|
||||||
|
tar xzf picoclaw_Linux_arm64.tar.gz
|
||||||
|
chmod +x ./picoclaw
|
||||||
|
termux-chroot ./picoclaw version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Check |
|
||||||
|
|---------|-------|
|
||||||
|
| `permission denied` | Run `chmod +x ./picoclaw` after unpacking the archive. |
|
||||||
|
| `not found` after running `./picoclaw` | Confirm `uname -m` prints `aarch64` and that you downloaded `picoclaw_Linux_arm64.tar.gz`. |
|
||||||
|
| Files or paths behave differently than Linux | Run PicoClaw through `termux-chroot` instead of calling the binary directly. |
|
||||||
|
| Provider requests fail | Check the API key and network access in `~/.picoclaw/config.json`. |
|
||||||
|
| PicoClaw stops when the phone sleeps | Disable Android battery optimization for Termux and keep a foreground Termux session active. |
|
||||||
@@ -400,6 +400,7 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous
|
|||||||
|------------|------|---------|-------------|
|
|------------|------|---------|-------------|
|
||||||
| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace |
|
| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace |
|
||||||
| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace |
|
| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace |
|
||||||
|
| `tools.message.media_enabled` | bool | `false` | Allows the `message` tool to attach local media files by path. This is separate from `tools.send_file.enabled`; enable it only when unified text/media/caption delivery is intended. |
|
||||||
|
|
||||||
### Read File Mode
|
### Read File Mode
|
||||||
|
|
||||||
@@ -454,7 +455,7 @@ Use `mode = lines` when:
|
|||||||
"tools": {
|
"tools": {
|
||||||
"read_file": {
|
"read_file": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"mode": "lines",
|
"mode": "lines",
|
||||||
"max_read_file_size": 65536
|
"max_read_file_size": 65536
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -837,6 +838,9 @@ Legacy Telegram environment variables remain compatible: `PICOCLAW_CHANNELS_TELE
|
|||||||
|
|
||||||
Failure behavior is intentionally conservative: if streaming fails before any visible chunk is sent, PicoClaw retries once through the normal `Chat()` path. If a chunk has already been shown to the user, PicoClaw does not send a second non-streaming answer, because that would duplicate visible output.
|
Failure behavior is intentionally conservative: if streaming fails before any visible chunk is sent, PicoClaw retries once through the normal `Chat()` path. If a chunk has already been shown to the user, PicoClaw does not send a second non-streaming answer, because that would duplicate visible output.
|
||||||
|
|
||||||
|
For model-specific TTS request fields such as custom speech `voice` names or
|
||||||
|
`response_format: "mp3"`, use `model_list[].extra_body`.
|
||||||
|
|
||||||
#### Vendor-Specific Examples
|
#### Vendor-Specific Examples
|
||||||
|
|
||||||
> **Tip**: You can omit `api_key` fields and store them in `.security.yml` for better security. See [Security Configuration](#-security-configuration-recommended).
|
> **Tip**: You can omit `api_key` fields and store them in `.security.yml` for better security. See [Security Configuration](#-security-configuration-recommended).
|
||||||
@@ -855,6 +859,35 @@ Failure behavior is intentionally conservative: if streaming fails before any vi
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>OpenRouter TTS (MAI Voice 2)</b></summary>
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "mai-voice-2",
|
||||||
|
"provider": "openrouter",
|
||||||
|
"model": "microsoft/mai-voice-2",
|
||||||
|
"api_base": "https://openrouter.ai/api/v1",
|
||||||
|
"extra_body": {
|
||||||
|
"voice": "en-US-Harper:MAI-Voice-2",
|
||||||
|
"response_format": "mp3"
|
||||||
|
}
|
||||||
|
// api_key: set in .security.yml
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pair this with:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"voice": {
|
||||||
|
"tts_model_name": "mai-voice-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>VolcEngine (Doubao)</b></summary>
|
<summary><b>VolcEngine (Doubao)</b></summary>
|
||||||
|
|
||||||
@@ -1121,7 +1154,9 @@ PicoClaw supports cron-style scheduled tasks via the `cron` tool. The agent can
|
|||||||
"tools": {
|
"tools": {
|
||||||
"cron": {
|
"cron": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"exec_timeout_minutes": 5
|
"exec_timeout_minutes": 5,
|
||||||
|
"allow_command": true,
|
||||||
|
"command_allowed_remotes": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1129,6 +1164,8 @@ PicoClaw supports cron-style scheduled tasks via the `cron` tool. The agent can
|
|||||||
|
|
||||||
Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace/cron/`.
|
Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace/cron/`.
|
||||||
|
|
||||||
|
Command cron jobs can execute shell commands. By default, remote channels cannot schedule command jobs. To allow specific remote channels, set `command_allowed_remotes` to entries such as `"telegram"` or `"telegram:1234567890"`; use `"*"` only if every non-empty channel should be allowed. The `"*"` wildcard is potentially dangerous because any remote channel that can talk to PicoClaw can schedule shell commands. This does not bypass `allow_command`, `command_confirm`, or exec safety checks.
|
||||||
|
|
||||||
### Advanced Topics
|
### Advanced Topics
|
||||||
|
|
||||||
| Topic | Description |
|
| Topic | Description |
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ Consumer products, routers, and industrial devices that have been tested with Pi
|
|||||||
|
|
||||||
Any ARM64 Android phone (2015+) with 1GB+ RAM. Install [Termux](https://github.com/termux/termux-app), use `proot` to run PicoClaw.
|
Any ARM64 Android phone (2015+) with 1GB+ RAM. Install [Termux](https://github.com/termux/termux-app), use `proot` to run PicoClaw.
|
||||||
|
|
||||||
> See [README: Run on old Android Phones](../../README.md#-run-on-old-android-phones) for setup instructions.
|
> See the [Android Termux Guide](android-termux.md) for setup instructions.
|
||||||
|
|
||||||
### Desktop / Server / Cloud
|
### Desktop / Server / Cloud
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||||
| `venice` | LLM (Venice AI direct) | [venice.ai](https://venice.ai) |
|
| `venice` | LLM (Venice AI direct) | [venice.ai](https://venice.ai) |
|
||||||
|
| `nearai` | LLM (NEAR AI Cloud TEE inference) | [near.ai](https://near.ai) |
|
||||||
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||||
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||||
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||||
@@ -50,6 +51,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
|
| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
|
||||||
| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||||
| **Venice AI** | `venice` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) |
|
| **Venice AI** | `venice` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) |
|
||||||
|
| **NEAR AI Cloud** | `nearai` | `https://cloud-api.near.ai/v1` | OpenAI | [Get Key](https://near.ai) |
|
||||||
| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||||
| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
| **Z.AI Coding Plan** | `openai` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) |
|
| **Z.AI Coding Plan** | `openai` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) |
|
||||||
@@ -138,6 +140,9 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
|
|
||||||
When streaming is disabled, omit the `streaming` block. Writing `"streaming": {"enabled": false}` is optional and not needed in generated or hand-written config.
|
When streaming is disabled, omit the `streaming` block. Writing `"streaming": {"enabled": false}` is optional and not needed in generated or hand-written config.
|
||||||
|
|
||||||
|
`extra_body` is especially useful for model-specific TTS fields on OpenAI-compatible
|
||||||
|
speech routes, for example custom `voice` names or `response_format: "mp3"`.
|
||||||
|
|
||||||
#### Tool Schema Compatibility
|
#### Tool Schema Compatibility
|
||||||
|
|
||||||
By default, PicoClaw now forwards tool JSON Schemas unchanged.
|
By default, PicoClaw now forwards tool JSON Schemas unchanged.
|
||||||
@@ -205,6 +210,41 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Voice Synthesis
|
||||||
|
|
||||||
|
You can configure a dedicated text-to-speech model with `voice.tts_model_name`.
|
||||||
|
When the provider needs model-specific TTS request fields, put them in
|
||||||
|
`model_list[].extra_body`.
|
||||||
|
|
||||||
|
Example with OpenRouter `microsoft/mai-voice-2`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "mai-voice-2",
|
||||||
|
"provider": "openrouter",
|
||||||
|
"model": "microsoft/mai-voice-2",
|
||||||
|
"api_base": "https://openrouter.ai/api/v1",
|
||||||
|
"extra_body": {
|
||||||
|
"voice": "en-US-Harper:MAI-Voice-2",
|
||||||
|
"response_format": "mp3"
|
||||||
|
},
|
||||||
|
"api_keys": ["sk-or-your-openrouter-key"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"voice": {
|
||||||
|
"tts_model_name": "mai-voice-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes that matter:
|
||||||
|
|
||||||
|
- PicoClaw still uses the OpenAI-compatible `/audio/speech` route for this setup.
|
||||||
|
- The default TTS request uses `voice: alloy` and `response_format: opus`.
|
||||||
|
- Override those defaults with `extra_body` when your selected TTS model requires different values.
|
||||||
|
|
||||||
#### Vendor-Specific Examples
|
#### Vendor-Specific Examples
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
@@ -218,6 +258,17 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**NEAR AI Cloud**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "nearai-glm",
|
||||||
|
"provider": "nearai",
|
||||||
|
"model": "zai-org/GLM-5.1-FP8",
|
||||||
|
"api_keys": ["your-nearai-api-key"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**VolcEngine (Doubao)**
|
**VolcEngine (Doubao)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -609,7 +660,9 @@ picoclaw agent -m "Hello"
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
"exec_timeout_minutes": 5
|
"exec_timeout_minutes": 5,
|
||||||
|
"allow_command": true,
|
||||||
|
"command_allowed_remotes": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"heartbeat": {
|
"heartbeat": {
|
||||||
|
|||||||
@@ -20,6 +20,17 @@
|
|||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://picopaw.ai">
|
||||||
|
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
||||||
|
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -57,6 +68,8 @@
|
|||||||
|
|
||||||
## 📢 Actualités
|
## 📢 Actualités
|
||||||
|
|
||||||
|
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw disponible sur AliExpress !** Vous pouvez désormais acheter le LicheeRV-Claw sur [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), ce qui facilite l'essai de PicoClaw sur du matériel RISC-V compact.
|
2026-05-11 🛒 **LicheeRV-Claw disponible sur AliExpress !** Vous pouvez désormais acheter le LicheeRV-Claw sur [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), ce qui facilite l'essai de PicoClaw sur du matériel RISC-V compact.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -20,6 +20,17 @@
|
|||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **Bahasa Indonesia** | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **Bahasa Indonesia** | [Malay](README.ms.md) | [English](../../README.md)
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://picopaw.ai">
|
||||||
|
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
||||||
|
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -56,6 +67,8 @@
|
|||||||
|
|
||||||
## 📢 Berita
|
## 📢 Berita
|
||||||
|
|
||||||
|
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Kini Anda dapat membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), sehingga lebih mudah mencoba PicoClaw di hardware RISC-V ringkas.
|
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Kini Anda dapat membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), sehingga lebih mudah mencoba PicoClaw di hardware RISC-V ringkas.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -20,6 +20,17 @@
|
|||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://picopaw.ai">
|
||||||
|
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
||||||
|
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -56,6 +67,8 @@
|
|||||||
|
|
||||||
## 📢 Novità
|
## 📢 Novità
|
||||||
|
|
||||||
|
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw disponibile su AliExpress!** Ora puoi acquistare LicheeRV-Claw su [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), rendendo più semplice provare PicoClaw su hardware RISC-V compatto.
|
2026-05-11 🛒 **LicheeRV-Claw disponibile su AliExpress!** Ora puoi acquistare LicheeRV-Claw su [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), rendendo più semplice provare PicoClaw su hardware RISC-V compatto.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -20,6 +20,17 @@
|
|||||||
|
|
||||||
[中文](README.zh.md) | **日本語** | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | **日本語** | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://picopaw.ai">
|
||||||
|
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
||||||
|
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -56,6 +67,8 @@
|
|||||||
|
|
||||||
## 📢 ニュース
|
## 📢 ニュース
|
||||||
|
|
||||||
|
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw が AliExpress で購入可能に!** [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) から LicheeRV-Claw を購入できるようになり、コンパクトな RISC-V ハードウェアで PicoClaw を試しやすくなりました。
|
2026-05-11 🛒 **LicheeRV-Claw が AliExpress で購入可能に!** [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) から LicheeRV-Claw を購入できるようになり、コンパクトな RISC-V ハードウェアで PicoClaw を試しやすくなりました。
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -20,6 +20,17 @@
|
|||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | **한국어** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | **한국어** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://picopaw.ai">
|
||||||
|
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
||||||
|
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -56,6 +67,8 @@
|
|||||||
|
|
||||||
## 📢 뉴스
|
## 📢 뉴스
|
||||||
|
|
||||||
|
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw를 AliExpress에서 구매할 수 있습니다!** 이제 [AliExpress](https://www.aliexpress.com/item/1005006519668532.html)에서 LicheeRV-Claw를 구매해 소형 RISC-V 하드웨어에서 PicoClaw를 더 쉽게 사용해 볼 수 있습니다.
|
2026-05-11 🛒 **LicheeRV-Claw를 AliExpress에서 구매할 수 있습니다!** 이제 [AliExpress](https://www.aliexpress.com/item/1005006519668532.html)에서 LicheeRV-Claw를 구매해 소형 RISC-V 하드웨어에서 PicoClaw를 더 쉽게 사용해 볼 수 있습니다.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -20,6 +20,17 @@
|
|||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](../../README.md)
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://picopaw.ai">
|
||||||
|
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
||||||
|
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -56,6 +67,8 @@
|
|||||||
|
|
||||||
## 📢 Berita
|
## 📢 Berita
|
||||||
|
|
||||||
|
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Anda kini boleh membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), menjadikannya lebih mudah untuk mencuba PicoClaw pada perkakasan RISC-V yang kompak.
|
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Anda kini boleh membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), menjadikannya lebih mudah untuk mencuba PicoClaw pada perkakasan RISC-V yang kompak.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -20,6 +20,17 @@
|
|||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://picopaw.ai">
|
||||||
|
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
||||||
|
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -56,6 +67,8 @@
|
|||||||
|
|
||||||
## 📢 Novidades
|
## 📢 Novidades
|
||||||
|
|
||||||
|
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw no AliExpress!** Agora você pode comprar o LicheeRV-Claw no [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), facilitando testar o PicoClaw em hardware RISC-V compacto.
|
2026-05-11 🛒 **LicheeRV-Claw no AliExpress!** Agora você pode comprar o LicheeRV-Claw no [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), facilitando testar o PicoClaw em hardware RISC-V compacto.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -20,6 +20,17 @@
|
|||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://picopaw.ai">
|
||||||
|
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
||||||
|
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -56,6 +67,8 @@
|
|||||||
|
|
||||||
## 📢 Tin tức
|
## 📢 Tin tức
|
||||||
|
|
||||||
|
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw đã có trên AliExpress!** Bạn hiện có thể mua LicheeRV-Claw trên [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), giúp việc thử PicoClaw trên phần cứng RISC-V nhỏ gọn dễ dàng hơn.
|
2026-05-11 🛒 **LicheeRV-Claw đã có trên AliExpress!** Bạn hiện có thể mua LicheeRV-Claw trên [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), giúp việc thử PicoClaw trên phần cứng RISC-V nhỏ gọn dễ dàng hơn.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -20,6 +20,17 @@
|
|||||||
|
|
||||||
**中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
**中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://picopaw.ai/zh">
|
||||||
|
<img src="../../assets/picopaw-banner-zh.webp" alt="PicoPaw AI:你的 AI 桌面伙伴" width="100%">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>PicoPaw AI 已在 <a href="https://picopaw.ai/zh">picopaw.ai/zh</a> 上线。</strong><br>
|
||||||
|
创建、预览并分享面向 PicoClaw 生态的 AI 桌面伙伴。
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -49,13 +60,15 @@
|
|||||||
> **🚨 安全声明**
|
> **🚨 安全声明**
|
||||||
>
|
>
|
||||||
> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
|
> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
|
||||||
> - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
|
> - **官方域名:** 唯一的 PicoClaw 官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
|
||||||
> - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
|
> - **警惕:** 许多相似的 `.ai/.org/.com/.net/...` 后缀域名被第三方抢注。请只信任本 README 明确链接的域名。
|
||||||
> - **注意:** PicoClaw 正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在 1.0 正式版发布前,请不要将其部署到生产环境中。
|
> - **注意:** PicoClaw 正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在 1.0 正式版发布前,请不要将其部署到生产环境中。
|
||||||
> - **注意:** PicoClaw 最近合并了大量 PR,近期版本可能内存占用较大 (10~20MB),我们将在功能较为收敛后进行资源占用优化。
|
> - **注意:** PicoClaw 最近合并了大量 PR,近期版本可能内存占用较大 (10~20MB),我们将在功能较为收敛后进行资源占用优化。
|
||||||
|
|
||||||
## 📢 新闻
|
## 📢 新闻
|
||||||
|
|
||||||
|
2026-06-11 🐾 **PicoPaw AI 上线!** 访问 [picopaw.ai/zh](https://picopaw.ai/zh),体验全新的 PicoPaw 桌面伙伴,预览会动的 AI 宠物,并关注 PicoClaw 生态更新。
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw 已上架淘宝!** 现在可以在 [淘宝](https://item.taobao.com/item.htm?abbucket=20&id=764939520376) 购买 LicheeRV-Claw,更方便地在小型 RISC-V 硬件上体验 PicoClaw。
|
2026-05-11 🛒 **LicheeRV-Claw 已上架淘宝!** 现在可以在 [淘宝](https://item.taobao.com/item.htm?abbucket=20&id=764939520376) 购买 LicheeRV-Claw,更方便地在小型 RISC-V 硬件上体验 PicoClaw。
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
+58
-3
@@ -26,6 +26,42 @@ picoclaw cron add --name "Daily summary" --message "Summarize today's logs" --cr
|
|||||||
picoclaw cron add --name "Ping" --message "heartbeat" --every 300 --deliver
|
picoclaw cron add --name "Ping" --message "heartbeat" --every 300 --deliver
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Agent Tool Actions
|
||||||
|
|
||||||
|
The agent-facing `cron` tool supports these actions:
|
||||||
|
|
||||||
|
- `add`: create a new job.
|
||||||
|
- `list`: show accessible job names, ids, and schedules.
|
||||||
|
- `get`: fetch one accessible persisted job by `job_id`, including its saved payload.
|
||||||
|
- `update`: partially update one accessible job by `job_id`; omitted fields are preserved.
|
||||||
|
- `remove`, `enable`, `disable`: existing management actions.
|
||||||
|
|
||||||
|
When rescheduling an existing task, use `list -> get -> update`. Do not use
|
||||||
|
`remove -> add` just to change the schedule, because recreating a job can drop
|
||||||
|
the original prompt, delivery target, or command payload.
|
||||||
|
|
||||||
|
Remote channel access is scoped to the current `channel/chat_id`: remote callers
|
||||||
|
can only list, get, or update jobs whose saved `payload.channel` and `payload.to`
|
||||||
|
match the current conversation. Command jobs include a shell command payload, so
|
||||||
|
they can only be listed, inspected, or updated from internal channels or remote
|
||||||
|
channels allowed by `tools.cron.command_allowed_remotes`.
|
||||||
|
|
||||||
|
Example tool calls:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action":"get","job_id":"79095b2f5685a0f2"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action":"update","job_id":"79095b2f5685a0f2","cron_expr":"30 10 * * *"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`update` accepts `name`, `message`, `command`, and exactly one schedule field
|
||||||
|
(`at_seconds`, `every_seconds`, or `cron_expr`).
|
||||||
|
Omit `command` to preserve it, set `command` to a non-empty string to replace
|
||||||
|
it, or set `command` to `""` to clear it. Command updates require the same
|
||||||
|
channel allowlist and confirmation gates as command creation.
|
||||||
|
|
||||||
## Execution Modes
|
## Execution Modes
|
||||||
|
|
||||||
Jobs are stored with a message payload and can execute in three stable user-facing modes:
|
Jobs are stored with a message payload and can execute in three stable user-facing modes:
|
||||||
@@ -69,7 +105,7 @@ If `tools.exec.enabled` is `false`:
|
|||||||
- new command jobs are rejected by the cron tool
|
- new command jobs are rejected by the cron tool
|
||||||
- existing command jobs publish a `command execution is disabled` error when they fire
|
- existing command jobs publish a `command execution is disabled` error when they fire
|
||||||
|
|
||||||
`tools.exec.allow_remote` is still enforced by the exec tool, but cron command scheduling already requires an internal channel when the job is created. In practice, reminder jobs can be scheduled from remote channels, while scheduled command jobs are limited to internal channels.
|
`tools.exec.allow_remote` is still enforced by the exec tool, but cron command scheduling has its own channel gate when the job is created. In practice, reminder jobs can be scheduled from remote channels, while scheduled command jobs are limited to internal channels and configured remote channels.
|
||||||
|
|
||||||
### `allow_command`
|
### `allow_command`
|
||||||
|
|
||||||
@@ -77,7 +113,23 @@ If `tools.exec.enabled` is `false`:
|
|||||||
|
|
||||||
This is not a hard disable switch. If you set `allow_command` to `false`, PicoClaw still allows a command job when the caller explicitly passes `command_confirm: true`.
|
This is not a hard disable switch. If you set `allow_command` to `false`, PicoClaw still allows a command job when the caller explicitly passes `command_confirm: true`.
|
||||||
|
|
||||||
Command jobs also require an internal channel. Non-command reminders do not have that restriction.
|
Command jobs also require either an internal channel or a remote channel allowed by `tools.cron.command_allowed_remotes`. Non-command reminders do not have that restriction.
|
||||||
|
|
||||||
|
### `command_allowed_remotes`
|
||||||
|
|
||||||
|
`tools.cron.command_allowed_remotes` defaults to an empty list. With the default empty list, remote channels cannot schedule command jobs.
|
||||||
|
|
||||||
|
Entries can be either a channel name or a channel plus chat id:
|
||||||
|
|
||||||
|
- `telegram` allows command jobs from any Telegram chat.
|
||||||
|
- `telegram:1234567890` allows command jobs only from that exact Telegram chat id.
|
||||||
|
- `*` allows command jobs from every non-empty channel.
|
||||||
|
|
||||||
|
Warning: `*` is potentially dangerous because any remote channel that can talk
|
||||||
|
to PicoClaw can schedule shell commands. Use it only when every enabled remote
|
||||||
|
channel and chat is trusted to request command execution.
|
||||||
|
|
||||||
|
This setting only controls the remote-channel gate. It does not bypass `tools.cron.allow_command`, `command_confirm`, `tools.exec.enabled`, or the exec tool's command safety checks.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -87,7 +139,10 @@ Example:
|
|||||||
"cron": {
|
"cron": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"exec_timeout_minutes": 5,
|
"exec_timeout_minutes": 5,
|
||||||
"allow_command": true
|
"allow_command": true,
|
||||||
|
"command_allowed_remotes": [
|
||||||
|
"telegram:1234567890"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"exec": {
|
"exec": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
|||||||
@@ -126,6 +126,44 @@ Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfa
|
|||||||
| `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) |
|
| `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
||||||
|
### Kagi Search
|
||||||
|
|
||||||
|
Kagi Search uses the official Kagi OpenAPI client for `POST /search` and returns normal web results from `data.search`.
|
||||||
|
|
||||||
|
| Config | Type | Default | Description |
|
||||||
|
|---------------|----------|---------------------------------------|------------------------------------------------|
|
||||||
|
| `enabled` | bool | false | Enable Kagi Search |
|
||||||
|
| `api_key` | string | - | Kagi API key |
|
||||||
|
| `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) |
|
||||||
|
| `base_url` | string | `https://kagi.com/api/v1/search` | Kagi Search API endpoint |
|
||||||
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"provider": "kagi",
|
||||||
|
"kagi": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_results": 5,
|
||||||
|
"base_url": "https://kagi.com/api/v1/search"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Store Kagi API keys in `.security.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
web:
|
||||||
|
kagi:
|
||||||
|
api_keys:
|
||||||
|
- "YOUR_KAGI_API_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
Kagi API usage may be billed or limited separately from a normal Kagi subscription, depending on your account and API setup.
|
||||||
|
|
||||||
### Tavily
|
### Tavily
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
@@ -171,6 +209,7 @@ At runtime, the `web_search` tool accepts the following parameters:
|
|||||||
| `range` | string | no | Optional time filter: `d` (day), `w` (week), `m` (month), `y` (year) |
|
| `range` | string | no | Optional time filter: `d` (day), `w` (week), `m` (month), `y` (year) |
|
||||||
|
|
||||||
If `range` is omitted, PicoClaw performs an unrestricted search.
|
If `range` is omitted, PicoClaw performs an unrestricted search.
|
||||||
|
For Kagi, `d`, `w`, and `m` map to Kagi lens `time_relative`; `y` maps to a lens `time_after` date one year before the current day.
|
||||||
|
|
||||||
### Example `web_search` Call
|
### Example `web_search` Call
|
||||||
|
|
||||||
@@ -272,11 +311,12 @@ as containers, VMs, or an approval flow around build-and-run commands.
|
|||||||
|
|
||||||
The cron tool is used for scheduling periodic tasks.
|
The cron tool is used for scheduling periodic tasks.
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|------------------------|------|---------|------------------------------------------------|
|
|---------------------------|----------|---------|----------------------------------------------------------------|
|
||||||
| `enabled` | bool | true | Register the agent-facing cron tool |
|
| `enabled` | bool | true | Register the agent-facing cron tool |
|
||||||
| `allow_command` | bool | true | Allow command jobs without extra confirmation |
|
| `allow_command` | bool | true | Allow command jobs without extra confirmation |
|
||||||
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
| `command_allowed_remotes` | string[] | [] | Remote channels or `channel:chat_id` values allowed for command jobs; `*` allows every channel and is dangerous unless all remote channels are trusted |
|
||||||
|
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
||||||
|
|
||||||
For schedule types, execution modes (`deliver`, agent turn, and command jobs), persistence, and the current command-security gates, see [Scheduled Tasks and Cron Jobs](cron.md).
|
For schedule types, execution modes (`deliver`, agent turn, and command jobs), persistence, and the current command-security gates, see [Scheduled Tasks and Cron Jobs](cron.md).
|
||||||
|
|
||||||
|
|||||||
@@ -248,13 +248,16 @@ channel_list:
|
|||||||
|
|
||||||
### Web Tools
|
### Web Tools
|
||||||
|
|
||||||
**Brave, Tavily, Perplexity:**
|
**Brave, Tavily, Perplexity, Kagi:**
|
||||||
```yaml
|
```yaml
|
||||||
web:
|
web:
|
||||||
brave:
|
brave:
|
||||||
api_keys:
|
api_keys:
|
||||||
- "key-1"
|
- "key-1"
|
||||||
- "key-2"
|
- "key-2"
|
||||||
|
kagi:
|
||||||
|
api_keys:
|
||||||
|
- "your-kagi-api-key"
|
||||||
```
|
```
|
||||||
- Use `api_keys` (plural) array format
|
- Use `api_keys` (plural) array format
|
||||||
|
|
||||||
@@ -315,16 +318,19 @@ model_list:
|
|||||||
- **Rate limit management**: Distribute usage across multiple keys
|
- **Rate limit management**: Distribute usage across multiple keys
|
||||||
- **High availability**: Reduce downtime during API provider issues
|
- **High availability**: Reduce downtime during API provider issues
|
||||||
|
|
||||||
### Web Tools (Brave/Tavily/Perplexity) - Single key
|
### Web Tools (Brave/Tavily/Perplexity/Kagi) - Single key
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
web:
|
web:
|
||||||
brave:
|
brave:
|
||||||
api_keys:
|
api_keys:
|
||||||
- "BSA-your-key"
|
- "BSA-your-key"
|
||||||
|
kagi:
|
||||||
|
api_keys:
|
||||||
|
- "your-kagi-api-key"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Web Tools (Brave/Tavily/Perplexity) - Multiple keys
|
### Web Tools (Brave/Tavily/Perplexity/Kagi) - Multiple keys
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
web:
|
web:
|
||||||
@@ -332,6 +338,10 @@ web:
|
|||||||
api_keys:
|
api_keys:
|
||||||
- "BSA-key-1"
|
- "BSA-key-1"
|
||||||
- "BSA-key-2"
|
- "BSA-key-2"
|
||||||
|
kagi:
|
||||||
|
api_keys:
|
||||||
|
- "kagi-key-1"
|
||||||
|
- "kagi-key-2"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Web Tool (GLMSearch/BaiduSearch) - Single key only
|
### Web Tool (GLMSearch/BaiduSearch) - Single key only
|
||||||
@@ -558,7 +568,7 @@ go test ./pkg/config -run TestSecurityConfig
|
|||||||
|
|
||||||
- Ensure you're using `api_keys` (plural) in `.security.yml` for models and web tools (except GLMSearch/BaiduSearch)
|
- Ensure you're using `api_keys` (plural) in `.security.yml` for models and web tools (except GLMSearch/BaiduSearch)
|
||||||
- Check that the array format is correct in YAML (proper indentation with dashes)
|
- Check that the array format is correct in YAML (proper indentation with dashes)
|
||||||
- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format)
|
- Remember: Models, Brave, Tavily, Perplexity, Kagi MUST use `api_keys` (array format)
|
||||||
- GLMSearch and BaiduSearch MUST use `api_key` (single string format)
|
- GLMSearch and BaiduSearch MUST use `api_key` (single string format)
|
||||||
|
|
||||||
### Load Balancing/Failover Issues
|
### Load Balancing/Failover Issues
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
module github.com/sipeed/picoclaw
|
module github.com/sipeed/picoclaw
|
||||||
|
|
||||||
go 1.25.10
|
go 1.25.11
|
||||||
|
|
||||||
require (
|
require (
|
||||||
fyne.io/systray v1.12.1
|
fyne.io/systray v1.12.2
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0
|
||||||
github.com/SevereCloud/vksdk/v3 v3.3.1
|
github.com/SevereCloud/vksdk/v3 v3.3.1
|
||||||
github.com/adhocore/gronx v1.19.7
|
github.com/adhocore/gronx v1.20.0
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
github.com/anthropics/anthropic-sdk-go v1.50.2
|
||||||
github.com/atc0005/go-teams-notify/v2 v2.14.0
|
github.com/atc0005/go-teams-notify/v2 v2.14.0
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.7
|
github.com/aws/aws-sdk-go-v2 v1.42.0
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.17
|
github.com/aws/aws-sdk-go-v2/config v1.32.25
|
||||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6
|
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.53.3
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.4.0
|
github.com/caarlos0/env/v11 v11.4.1
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||||
@@ -22,16 +24,17 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/h2non/filetype v1.1.3
|
github.com/h2non/filetype v1.1.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.5
|
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62
|
||||||
github.com/line/line-bot-sdk-go/v8 v8.19.0
|
github.com/larksuite/oapi-sdk-go/v3 v3.9.4
|
||||||
|
github.com/line/line-bot-sdk-go/v8 v8.20.1
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/minio/selfupdate v0.6.0
|
github.com/minio/selfupdate v0.6.0
|
||||||
github.com/modelcontextprotocol/go-sdk v1.5.0
|
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||||
github.com/muesli/termenv v0.16.0
|
github.com/muesli/termenv v0.16.0
|
||||||
github.com/mymmrac/telego v1.9.0
|
github.com/mymmrac/telego v1.10.0
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||||
github.com/openai/openai-go/v3 v3.22.0
|
github.com/openai/openai-go/v3 v3.22.0
|
||||||
github.com/pion/rtp v1.10.1
|
github.com/pion/rtp v1.10.2
|
||||||
github.com/pion/webrtc/v3 v3.3.6
|
github.com/pion/webrtc/v3 v3.3.6
|
||||||
github.com/pmezard/go-difflib v1.0.0
|
github.com/pmezard/go-difflib v1.0.0
|
||||||
github.com/rs/zerolog v1.35.1
|
github.com/rs/zerolog v1.35.1
|
||||||
@@ -43,33 +46,37 @@ require (
|
|||||||
go.mau.fi/util v0.9.8
|
go.mau.fi/util v0.9.8
|
||||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/term v0.43.0
|
golang.org/x/term v0.44.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
maunium.net/go/mautrix v0.27.0
|
maunium.net/go/mautrix v0.27.0
|
||||||
modernc.org/sqlite v1.50.1
|
modernc.org/sqlite v1.53.0
|
||||||
rsc.io/qr v0.2.0
|
rsc.io/qr v0.2.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
aead.dev/minisign v0.2.0 // indirect
|
aead.dev/minisign v0.2.0 // indirect
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
|
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
|
||||||
github.com/aws/smithy-go v1.25.1 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
|
||||||
|
github.com/aws/smithy-go v1.27.1 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
github.com/beeper/argo-go v1.1.2 // indirect
|
github.com/beeper/argo-go v1.1.2 // indirect
|
||||||
|
github.com/buger/jsonparser v1.1.2 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
@@ -82,19 +89,25 @@ require (
|
|||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/invopop/jsonschema v0.14.0 // indirect
|
||||||
|
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.42 // indirect
|
github.com/mattn/go-sqlite3 v1.14.42 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
|
||||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||||
github.com/pion/randutil v0.1.0 // indirect
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/segmentio/asm v1.1.3 // indirect
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
github.com/segmentio/encoding v0.5.4 // indirect
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
|
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||||
@@ -104,9 +117,10 @@ require (
|
|||||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||||
|
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
modernc.org/libc v1.72.3 // indirect
|
modernc.org/libc v1.73.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
@@ -114,7 +128,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.1 // indirect
|
github.com/bytedance/sonic v1.15.2 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/github/copilot-sdk/go v0.2.0
|
github.com/github/copilot-sdk/go v0.2.0
|
||||||
@@ -135,9 +149,9 @@ require (
|
|||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
golang.org/x/arch v0.24.0 // indirect
|
golang.org/x/arch v0.24.0 // indirect
|
||||||
golang.org/x/crypto v0.51.0
|
golang.org/x/crypto v0.51.0
|
||||||
golang.org/x/net v0.54.0
|
golang.org/x/net v0.55.0
|
||||||
golang.org/x/sync v0.20.0
|
golang.org/x/sync v0.21.0
|
||||||
golang.org/x/sys v0.44.0
|
golang.org/x/sys v0.46.0
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532
|
replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532
|
||||||
|
|||||||
@@ -3,68 +3,84 @@ aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
|
|||||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
|
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
|
||||||
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
|
||||||
|
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
|
||||||
|
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
|
||||||
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU=
|
||||||
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg=
|
github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg=
|
||||||
github.com/SevereCloud/vksdk/v3 v3.3.1/go.mod h1:c6WaA5aocUYsXfkcUbg2qy45V9M1VDcqHHmHIN14NAw=
|
github.com/SevereCloud/vksdk/v3 v3.3.1/go.mod h1:c6WaA5aocUYsXfkcUbg2qy45V9M1VDcqHHmHIN14NAw=
|
||||||
github.com/adhocore/gronx v1.19.7 h1:7hhFwChgDw9eHC3+TQ+OKKBqJnP44oWkDCnnW9nrsuA=
|
github.com/adhocore/gronx v1.20.0 h1:PD13Mo0wekkZ7ZZR9yb1TqeqTfybs7/K3ez9DmjQwEs=
|
||||||
github.com/adhocore/gronx v1.19.7/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
github.com/adhocore/gronx v1.20.0/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
||||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
github.com/anthropics/anthropic-sdk-go v1.50.2 h1:K+YJWWzeN2h5MAbh9xeUWY8yAB2oOMp2xLLAODrVBXA=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
github.com/anthropics/anthropic-sdk-go v1.50.2/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
|
||||||
github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
|
github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
|
||||||
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q=
|
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 h1:oRtsqWgxbpeXrOlxOoQStx2M9WNbIkPq4C4Xn1or6bc=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12/go.mod h1:Zg0Oe9qT+9wcezlm1a64wGJp2qZdRElVxo/seJf7jYU=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
|
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
|
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6 h1:Wbo1WlWyGaAXlr6C7OGXq9avbdJhIV9cQ4M6E34b5x8=
|
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.53.3 h1:HTzzFDJiFSNkZX1Al72+insR4dre/vUeT3YZ4b9h0MA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6/go.mod h1:uY1fJe6m3I3w/m8UAkQ89Cm/ZAt/um6LW+AOZU33LDI=
|
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.53.3/go.mod h1:dFhfMfXoFrnX6XK/gXDh+4azdybtKll2QnP239wm2O8=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
|
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
|
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
|
||||||
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
|
||||||
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||||
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
||||||
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
||||||
|
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
|
||||||
|
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
|
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
|
||||||
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
|
github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw=
|
||||||
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
@@ -164,6 +180,12 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf
|
|||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
|
||||||
|
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
|
||||||
|
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62 h1:nyUi7Wel3KlVSa5ArgX/snlizqfaxU48qtvXS/JK5GE=
|
||||||
|
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62/go.mod h1:vONkS+clG730HSKOw3nZVa22TjB21r6csKYzYt0a9zI=
|
||||||
|
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
|
||||||
|
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||||
@@ -179,10 +201,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.5 h1:dimv+ZAGia01f4xCDGvCiBHKWMf4K1AB7fGsM+lv5Jw=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.5/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/line/line-bot-sdk-go/v8 v8.19.0 h1:5FD/1SprRZ8Y0FiUI6syYiBewOs0ak2tuUBMYN0wzE4=
|
github.com/larksuite/oapi-sdk-go/v3 v3.9.4 h1:oMgcY7NBjJv1QXJqFAfcoN/TbScCkCuRZfbb1mCwZmI=
|
||||||
github.com/line/line-bot-sdk-go/v8 v8.19.0/go.mod h1:AeSRUuu7WGgveGDJb6DyKyFUOst2UB2aF6LO2cQeuXs=
|
github.com/larksuite/oapi-sdk-go/v3 v3.9.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||||
|
github.com/line/line-bot-sdk-go/v8 v8.20.1 h1:OE7qwJbuZPz9MGOHHNI0E6rQNtIU7ENHyFXpvbVLwII=
|
||||||
|
github.com/line/line-bot-sdk-go/v8 v8.20.1/go.mod h1:QMXJwPka2ysSeVQKWXkBp8DzBFs+CFAXFNo75KJtWho=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
@@ -197,12 +221,12 @@ github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFe
|
|||||||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||||
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
||||||
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
|
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
|
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/mymmrac/telego v1.9.0 h1:ZUJxZaPx/1IgRvVb5lXnUB8FgW5rNYfRe6Q2EJ4OJ+Y=
|
github.com/mymmrac/telego v1.10.0 h1:Upe0TqYyiK+yE5RFXXuQWVHGfLZnqvUfj4KZVjTcgWE=
|
||||||
github.com/mymmrac/telego v1.9.0/go.mod h1:tVEB7OqiOPx8elRk9+ETkwiDQrUhWSB2XmAKIY9KmWY=
|
github.com/mymmrac/telego v1.10.0/go.mod h1:LsQKDA6EwssPP9XkORPXwwOFUGIRf/Wf2Wb8y3YyJdE=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
@@ -217,14 +241,18 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv
|
|||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||||
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
||||||
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||||
|
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
|
||||||
|
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
|
||||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||||
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
|
github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
|
||||||
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
|
||||||
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
|
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
|
||||||
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
|
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
@@ -253,6 +281,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
|
|||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI=
|
||||||
|
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -321,6 +351,8 @@ go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J
|
|||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
|
||||||
|
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
@@ -338,8 +370,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
@@ -354,8 +386,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
|||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
@@ -365,8 +397,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -384,20 +416,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -416,8 +449,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
|
|||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@@ -449,20 +482,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
maunium.net/go/mautrix v0.27.0 h1:yfEYwoIluVWkofUgbZl9gP4i5nQTF+QNsxtb+r5bKlM=
|
maunium.net/go/mautrix v0.27.0 h1:yfEYwoIluVWkofUgbZl9gP4i5nQTF+QNsxtb+r5bKlM=
|
||||||
maunium.net/go/mautrix v0.27.0/go.mod h1:7QpEQiTy6p4LHkXXaZI+N46tGYy8HMhD0JjzZAFoFWs=
|
maunium.net/go/mautrix v0.27.0/go.mod h1:7QpEQiTy6p4LHkXXaZI+N46tGYy8HMhD0JjzZAFoFWs=
|
||||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
@@ -471,8 +504,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
|||||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package picoclaw
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// OnboardWorkspace embeds the default onboarding workspace template.
|
||||||
|
//
|
||||||
|
// Keeping this embed at the module root lets us source files directly from the
|
||||||
|
// tracked `workspace/` tree, instead of relying on a generated copy inside
|
||||||
|
// `cmd/...` that may be absent in clean checkouts and CI lint runs.
|
||||||
|
//
|
||||||
|
//go:embed workspace
|
||||||
|
var OnboardWorkspace embed.FS
|
||||||
+34
-38
@@ -69,8 +69,14 @@ type AgentLoop struct {
|
|||||||
activeTurnStates sync.Map
|
activeTurnStates sync.Map
|
||||||
subTurnCounter atomic.Int64
|
subTurnCounter atomic.Int64
|
||||||
|
|
||||||
turnSeq atomic.Uint64
|
turnSeq atomic.Uint64
|
||||||
activeRequests sync.WaitGroup
|
|
||||||
|
// activeReqMu/activeReqCond/activeReqCount replace sync.WaitGroup to
|
||||||
|
// avoid the "WaitGroup is reused before previous Wait has returned" panic
|
||||||
|
// that occurs when Add(1) races with a goroutine-launched Wait().
|
||||||
|
activeReqMu sync.Mutex
|
||||||
|
activeReqCond *sync.Cond
|
||||||
|
activeReqCount int
|
||||||
|
|
||||||
reloadFunc func() error
|
reloadFunc func() error
|
||||||
|
|
||||||
@@ -118,6 +124,7 @@ const (
|
|||||||
handledToolResponseSummary = "Requested output delivered via tool attachment."
|
handledToolResponseSummary = "Requested output delivered via tool attachment."
|
||||||
sessionKeyAgentPrefix = "agent:"
|
sessionKeyAgentPrefix = "agent:"
|
||||||
pendingTurnPrefix = "pending-"
|
pendingTurnPrefix = "pending-"
|
||||||
|
providerReloadGracePeriod = 30 * time.Second
|
||||||
metadataKeyMessageKind = "message_kind"
|
metadataKeyMessageKind = "message_kind"
|
||||||
metadataKeyToolCalls = "tool_calls"
|
metadataKeyToolCalls = "tool_calls"
|
||||||
metadataKeyOutboundKind = "outbound_kind"
|
metadataKeyOutboundKind = "outbound_kind"
|
||||||
@@ -208,7 +215,8 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
|||||||
// Session claimed — spawn a worker goroutine that acquires a semaphore
|
// Session claimed — spawn a worker goroutine that acquires a semaphore
|
||||||
// slot. The goroutine is spawned immediately so the main loop keeps
|
// slot. The goroutine is spawned immediately so the main loop keeps
|
||||||
// draining the inbound channel. The goroutine blocks on the semaphore.
|
// draining the inbound channel. The goroutine blocks on the semaphore.
|
||||||
go func(m bus.InboundMessage) {
|
go func(m bus.InboundMessage, ph *turnState) {
|
||||||
|
var releaseSession bool
|
||||||
// Acquire semaphore slot (blocks if at capacity)
|
// Acquire semaphore slot (blocks if at capacity)
|
||||||
select {
|
select {
|
||||||
case al.workerSem <- struct{}{}:
|
case al.workerSem <- struct{}{}:
|
||||||
@@ -216,7 +224,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// Context canceled while waiting for a slot — clean up the
|
// Context canceled while waiting for a slot — clean up the
|
||||||
// placeholder to prevent session-level deadlock.
|
// placeholder to prevent session-level deadlock.
|
||||||
al.activeTurnStates.Delete(sessionKey)
|
al.releaseSessionTurnState(sessionKey, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,16 +233,28 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
|||||||
// completes normally, clearActiveTurn deletes the real turnState and
|
// completes normally, clearActiveTurn deletes the real turnState and
|
||||||
// this becomes a no-op (the key is already gone).
|
// this becomes a no-op (the key is already gone).
|
||||||
defer func() {
|
defer func() {
|
||||||
|
if releaseSession {
|
||||||
|
// Conditional delete: only remove the entry if it still points
|
||||||
|
// to our placeholder. A new message may have claimed the slot
|
||||||
|
// between the panic and this defer.
|
||||||
|
if actual, ok := al.activeTurnStates.Load(sessionKey); ok {
|
||||||
|
if ts, ok := actual.(*turnState); ok && ts == ph {
|
||||||
|
al.releaseSessionTurnState(sessionKey, ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if actual, ok := al.activeTurnStates.Load(sessionKey); ok {
|
if actual, ok := al.activeTurnStates.Load(sessionKey); ok {
|
||||||
if ts, ok := actual.(*turnState); ok && strings.HasPrefix(ts.turnID, pendingTurnPrefix) {
|
if ts, ok := actual.(*turnState); ok && strings.HasPrefix(ts.turnID, pendingTurnPrefix) {
|
||||||
// Placeholder still present — runTurn never replaced it.
|
// Placeholder still present — runTurn never replaced it.
|
||||||
al.activeTurnStates.Delete(sessionKey)
|
al.releaseSessionTurnState(sessionKey, ts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
|
releaseSession = true
|
||||||
logger.RecoverPanicNoExit(r)
|
logger.RecoverPanicNoExit(r)
|
||||||
logger.ErrorCF("agent", "Worker goroutine panicked",
|
logger.ErrorCF("agent", "Worker goroutine panicked",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
@@ -252,7 +272,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if al.takePendingStop(sessionKey) {
|
if al.takePendingStop(sessionKey) {
|
||||||
al.activeTurnStates.Delete(sessionKey)
|
al.releaseSessionTurnState(sessionKey, nil)
|
||||||
target := &continuationTarget{
|
target := &continuationTarget{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Channel: m.Channel,
|
Channel: m.Channel,
|
||||||
@@ -270,7 +290,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
al.runTurnWithSteering(ctx, m)
|
al.runTurnWithSteering(ctx, m)
|
||||||
}(msg)
|
}(msg, placeholder)
|
||||||
|
|
||||||
// TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.
|
// TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.
|
||||||
// Currently disabled because files are deleted before the LLM can access their content.
|
// Currently disabled because files are deleted before the LLM can access their content.
|
||||||
@@ -366,37 +386,23 @@ func (al *AgentLoop) ReloadProviderAndConfig(
|
|||||||
return fmt.Errorf("config cannot be nil")
|
return fmt.Errorf("config cannot be nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new registry with updated config and provider
|
|
||||||
// Wrap in defer/recover to handle any panics gracefully
|
|
||||||
var registry *AgentRegistry
|
var registry *AgentRegistry
|
||||||
var panicErr error
|
func() {
|
||||||
done := make(chan struct{}, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
logger.RecoverPanicNoExit(r)
|
logger.RecoverPanicNoExit(r)
|
||||||
panicErr = fmt.Errorf("panic during registry creation: %v", r)
|
|
||||||
logger.ErrorCF("agent", "Panic during registry creation",
|
logger.ErrorCF("agent", "Panic during registry creation",
|
||||||
map[string]any{"panic": r})
|
map[string]any{"panic": r})
|
||||||
|
registry = nil
|
||||||
}
|
}
|
||||||
close(done)
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
registry = NewAgentRegistry(cfg, provider)
|
registry = NewAgentRegistry(cfg, provider)
|
||||||
}()
|
}()
|
||||||
|
if registry == nil {
|
||||||
// Wait for completion or context cancellation
|
if err := ctx.Err(); err != nil {
|
||||||
select {
|
return fmt.Errorf("context canceled during registry creation: %w", err)
|
||||||
case <-done:
|
|
||||||
if registry == nil {
|
|
||||||
if panicErr != nil {
|
|
||||||
return fmt.Errorf("registry creation failed: %w", panicErr)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("registry creation failed (nil result)")
|
|
||||||
}
|
}
|
||||||
case <-ctx.Done():
|
return fmt.Errorf("registry creation failed")
|
||||||
return fmt.Errorf("context canceled during registry creation: %w", ctx.Err())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check context again before proceeding
|
// Check context again before proceeding
|
||||||
@@ -472,17 +478,7 @@ func (al *AgentLoop) ReloadProviderAndConfig(
|
|||||||
// This prevents blocking readers while closing
|
// This prevents blocking readers while closing
|
||||||
if oldProvider, ok := extractProvider(oldRegistry); ok {
|
if oldProvider, ok := extractProvider(oldRegistry); ok {
|
||||||
if stateful, ok := oldProvider.(providers.StatefulProvider); ok {
|
if stateful, ok := oldProvider.(providers.StatefulProvider); ok {
|
||||||
// Give in-flight requests a moment to complete
|
al.closeReloadedProvider(ctx, stateful)
|
||||||
// Use a reasonable timeout that balances cleanup vs resource usage
|
|
||||||
select {
|
|
||||||
case <-time.After(100 * time.Millisecond):
|
|
||||||
stateful.Close()
|
|
||||||
case <-ctx.Done():
|
|
||||||
// Context canceled, close immediately but log warning
|
|
||||||
logger.WarnCF("agent", "Context canceled during provider cleanup, forcing close",
|
|
||||||
map[string]any{"error": ctx.Err()})
|
|
||||||
stateful.Close()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -350,11 +350,13 @@ func (al *AgentLoop) buildCommandsRuntime(
|
|||||||
}
|
}
|
||||||
history := agent.Sessions.GetHistory(opts.SessionKey)
|
history := agent.Sessions.GetHistory(opts.SessionKey)
|
||||||
return &commands.ContextStats{
|
return &commands.ContextStats{
|
||||||
UsedTokens: usage.UsedTokens,
|
UsedTokens: usage.UsedTokens,
|
||||||
TotalTokens: usage.TotalTokens,
|
TotalTokens: usage.TotalTokens,
|
||||||
CompressAtTokens: usage.CompressAtTokens,
|
HistoryTokens: usage.HistoryTokens,
|
||||||
UsedPercent: usage.UsedPercent,
|
CompressAtTokens: usage.CompressAtTokens,
|
||||||
MessageCount: len(history),
|
SummarizeAtTokens: usage.SummarizeAtTokens,
|
||||||
|
UsedPercent: usage.UsedPercent,
|
||||||
|
MessageCount: len(history),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-5
@@ -5,6 +5,7 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/agent/interfaces"
|
"github.com/sipeed/picoclaw/pkg/agent/interfaces"
|
||||||
@@ -91,6 +92,7 @@ func NewAgentLoop(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
al.activeReqCond = sync.NewCond(&al.activeReqMu)
|
||||||
al.refreshRuntimeEventLogger(cfg)
|
al.refreshRuntimeEventLogger(cfg)
|
||||||
al.providerFactory = providers.CreateProviderFromConfig
|
al.providerFactory = providers.CreateProviderFromConfig
|
||||||
al.hooks = NewHookManager(al.runtimeEvents.Channel())
|
al.hooks = NewHookManager(al.runtimeEvents.Channel())
|
||||||
@@ -161,26 +163,58 @@ func registerSharedTools(
|
|||||||
// Message tool
|
// Message tool
|
||||||
if cfg.Tools.IsToolEnabled("message") {
|
if cfg.Tools.IsToolEnabled("message") {
|
||||||
messageTool := tools.NewMessageTool()
|
messageTool := tools.NewMessageTool()
|
||||||
|
if cfg.Tools.Message.MediaEnabled {
|
||||||
|
messageTool.ConfigureLocalMedia(
|
||||||
|
agent.Workspace,
|
||||||
|
cfg.Agents.Defaults.RestrictToWorkspace,
|
||||||
|
cfg.Agents.Defaults.GetMaxMediaSize(),
|
||||||
|
allowReadPaths,
|
||||||
|
)
|
||||||
|
}
|
||||||
messageTool.SetSendCallback(func(
|
messageTool.SetSendCallback(func(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
channel, chatID, content, replyToMessageID string,
|
channel, chatID, content, replyToMessageID string,
|
||||||
|
mediaParts []bus.MediaPart,
|
||||||
) error {
|
) error {
|
||||||
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer pubCancel()
|
|
||||||
outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID)
|
outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID)
|
||||||
outboundAgentID, outboundSessionKey, outboundScope := outboundTurnMetadata(
|
outboundAgentID, outboundSessionKey, outboundScope := outboundTurnMetadata(
|
||||||
tools.ToolAgentID(ctx),
|
tools.ToolAgentID(ctx),
|
||||||
tools.ToolSessionKey(ctx),
|
tools.ToolSessionKey(ctx),
|
||||||
tools.ToolSessionScope(ctx),
|
tools.ToolSessionScope(ctx),
|
||||||
)
|
)
|
||||||
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
if len(mediaParts) > 0 {
|
||||||
|
outboundMedia := bus.OutboundMediaMessage{
|
||||||
|
Channel: channel,
|
||||||
|
ChatID: chatID,
|
||||||
|
Context: outboundCtx,
|
||||||
|
AgentID: outboundAgentID,
|
||||||
|
SessionKey: outboundSessionKey,
|
||||||
|
Scope: outboundScope,
|
||||||
|
Parts: mediaParts,
|
||||||
|
}
|
||||||
|
if al.channelManager != nil && channel != "" {
|
||||||
|
return al.channelManager.SendMedia(ctx, outboundMedia)
|
||||||
|
}
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
return msgBus.PublishOutboundMedia(pubCtx, outboundMedia)
|
||||||
|
}
|
||||||
|
outboundMessage := bus.OutboundMessage{
|
||||||
|
Channel: channel,
|
||||||
|
ChatID: chatID,
|
||||||
Context: outboundCtx,
|
Context: outboundCtx,
|
||||||
AgentID: outboundAgentID,
|
AgentID: outboundAgentID,
|
||||||
SessionKey: outboundSessionKey,
|
SessionKey: outboundSessionKey,
|
||||||
Scope: outboundScope,
|
Scope: outboundScope,
|
||||||
Content: content,
|
Content: content,
|
||||||
ReplyToMessageID: replyToMessageID,
|
ReplyToMessageID: replyToMessageID,
|
||||||
})
|
}
|
||||||
|
if al.channelManager != nil && channel != "" {
|
||||||
|
return al.channelManager.SendMessage(ctx, outboundMessage)
|
||||||
|
}
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
return msgBus.PublishOutbound(pubCtx, outboundMessage)
|
||||||
})
|
})
|
||||||
agent.Tools.Register(messageTool)
|
agent.Tools.Register(messageTool)
|
||||||
}
|
}
|
||||||
@@ -264,7 +298,7 @@ func registerSharedTools(
|
|||||||
// This keeps subagent vision support working even when the optimized
|
// This keeps subagent vision support working even when the optimized
|
||||||
// sub-turn spawner path is unavailable.
|
// sub-turn spawner path is unavailable.
|
||||||
subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message {
|
subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message {
|
||||||
return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize())
|
return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize(), 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set the spawner that links into AgentLoop's turnState
|
// Set the spawner that links into AgentLoop's turnState
|
||||||
|
|||||||
+39
-20
@@ -31,6 +31,21 @@ var (
|
|||||||
filePlaceholderRegex = regexp.MustCompile(`\[file(:\s+[^\]]*)?\]`)
|
filePlaceholderRegex = regexp.MustCompile(`\[file(:\s+[^\]]*)?\]`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func normalizeCurrentTurnStart(messages []providers.Message, currentTurnStart int) int {
|
||||||
|
if currentTurnStart < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if currentTurnStart > len(messages) {
|
||||||
|
return len(messages)
|
||||||
|
}
|
||||||
|
return currentTurnStart
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentTurnMessages(messages []providers.Message, currentTurnStart int) []providers.Message {
|
||||||
|
currentTurnStart = normalizeCurrentTurnStart(messages, currentTurnStart)
|
||||||
|
return messages[currentTurnStart:]
|
||||||
|
}
|
||||||
|
|
||||||
// resolveMediaRefs resolves media:// refs in messages.
|
// resolveMediaRefs resolves media:// refs in messages.
|
||||||
// For user messages: images get path tags only ([image:/path]) so the LLM
|
// For user messages: images get path tags only ([image:/path]) so the LLM
|
||||||
// can decide whether to view them via load_image or operate on the file.
|
// can decide whether to view them via load_image or operate on the file.
|
||||||
@@ -38,12 +53,20 @@ var (
|
|||||||
// user message only after the contiguous tool-message block ends, so we don't
|
// user message only after the contiguous tool-message block ends, so we don't
|
||||||
// break the tool-results-must-immediately-follow-assistant constraint that
|
// break the tool-results-must-immediately-follow-assistant constraint that
|
||||||
// LLM APIs enforce.
|
// LLM APIs enforce.
|
||||||
|
// Only tool messages from the current turn may emit the synthetic user
|
||||||
|
// follow-up; historical tool results stay as plain path-tagged history.
|
||||||
// Non-image files always get path tags regardless of role.
|
// Non-image files always get path tags regardless of role.
|
||||||
// Returns a new slice; original messages are not mutated.
|
// Returns a new slice; original messages are not mutated.
|
||||||
func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message {
|
func resolveMediaRefs(
|
||||||
|
messages []providers.Message,
|
||||||
|
store media.MediaStore,
|
||||||
|
maxSize int,
|
||||||
|
currentTurnStart int,
|
||||||
|
) []providers.Message {
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
currentTurnStart = normalizeCurrentTurnStart(messages, currentTurnStart)
|
||||||
|
|
||||||
result := make([]providers.Message, 0, len(messages))
|
result := make([]providers.Message, 0, len(messages))
|
||||||
var pendingToolImages []string
|
var pendingToolImages []string
|
||||||
@@ -52,22 +75,14 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
|
|||||||
// When leaving a tool-message block, flush any accumulated images
|
// When leaving a tool-message block, flush any accumulated images
|
||||||
// as a synthetic user message.
|
// as a synthetic user message.
|
||||||
if m.Role != "tool" && len(pendingToolImages) > 0 {
|
if m.Role != "tool" && len(pendingToolImages) > 0 {
|
||||||
result = append(result, providers.Message{
|
result = append(result, toolImageFollowUpPromptMessage(pendingToolImages))
|
||||||
Role: "user",
|
|
||||||
Content: "[Loaded image from tool result above]",
|
|
||||||
Media: pendingToolImages,
|
|
||||||
})
|
|
||||||
pendingToolImages = nil
|
pendingToolImages = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(m.Media) == 0 {
|
if len(m.Media) == 0 {
|
||||||
result = append(result, m)
|
result = append(result, m)
|
||||||
if idx == len(messages)-1 && len(pendingToolImages) > 0 {
|
if idx == len(messages)-1 && len(pendingToolImages) > 0 {
|
||||||
result = append(result, providers.Message{
|
result = append(result, toolImageFollowUpPromptMessage(pendingToolImages))
|
||||||
Role: "user",
|
|
||||||
Content: "[Loaded image from tool result above]",
|
|
||||||
Media: pendingToolImages,
|
|
||||||
})
|
|
||||||
pendingToolImages = nil
|
pendingToolImages = nil
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -104,7 +119,7 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
|
|||||||
mime := detectMIME(localPath, meta)
|
mime := detectMIME(localPath, meta)
|
||||||
pathTags = append(pathTags, buildPathTag(mime, localPath))
|
pathTags = append(pathTags, buildPathTag(mime, localPath))
|
||||||
|
|
||||||
if m.Role == "tool" && strings.HasPrefix(mime, "image/") {
|
if m.Role == "tool" && idx >= currentTurnStart && strings.HasPrefix(mime, "image/") {
|
||||||
dataURL := encodeImageToDataURL(localPath, mime, info, maxSize)
|
dataURL := encodeImageToDataURL(localPath, mime, info, maxSize)
|
||||||
if dataURL != "" {
|
if dataURL != "" {
|
||||||
pendingToolImages = append(pendingToolImages, dataURL)
|
pendingToolImages = append(pendingToolImages, dataURL)
|
||||||
@@ -120,11 +135,7 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
|
|||||||
|
|
||||||
// If this is the last message and we have pending images, flush them.
|
// If this is the last message and we have pending images, flush them.
|
||||||
if idx == len(messages)-1 && len(pendingToolImages) > 0 {
|
if idx == len(messages)-1 && len(pendingToolImages) > 0 {
|
||||||
result = append(result, providers.Message{
|
result = append(result, toolImageFollowUpPromptMessage(pendingToolImages))
|
||||||
Role: "user",
|
|
||||||
Content: "[Loaded image from tool result above]",
|
|
||||||
Media: pendingToolImages,
|
|
||||||
})
|
|
||||||
pendingToolImages = nil
|
pendingToolImages = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,14 +172,22 @@ func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int)
|
|||||||
buf.WriteString(prefix)
|
buf.WriteString(prefix)
|
||||||
|
|
||||||
encoder := base64.NewEncoder(base64.StdEncoding, &buf)
|
encoder := base64.NewEncoder(base64.StdEncoding, &buf)
|
||||||
if _, err := io.Copy(encoder, f); err != nil {
|
_, copyErr := io.Copy(encoder, f)
|
||||||
|
closeErr := encoder.Close()
|
||||||
|
if copyErr != nil {
|
||||||
logger.WarnCF("agent", "Failed to encode media file", map[string]any{
|
logger.WarnCF("agent", "Failed to encode media file", map[string]any{
|
||||||
"path": localPath,
|
"path": localPath,
|
||||||
"error": err.Error(),
|
"error": copyErr.Error(),
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to close base64 encoder", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"error": closeErr.Error(),
|
||||||
})
|
})
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
encoder.Close()
|
|
||||||
|
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|||||||
+1061
-40
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/commands"
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/session"
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
@@ -591,6 +592,77 @@ func closeProviderIfStateful(provider providers.LLMProvider) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// activeRequestsInc atomically increments the active request count.
|
||||||
|
func (al *AgentLoop) activeRequestsInc() {
|
||||||
|
al.activeReqMu.Lock()
|
||||||
|
al.activeReqCount++
|
||||||
|
al.activeReqMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeRequestsDec atomically decrements the active request count
|
||||||
|
// and wakes any goroutine blocked in waitForActiveRequests when the
|
||||||
|
// count reaches zero.
|
||||||
|
func (al *AgentLoop) activeRequestsDec() {
|
||||||
|
al.activeReqMu.Lock()
|
||||||
|
al.activeReqCount--
|
||||||
|
if al.activeReqCount == 0 {
|
||||||
|
al.activeReqCond.Broadcast()
|
||||||
|
}
|
||||||
|
al.activeReqMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) waitForActiveRequests(ctx context.Context, timeout time.Duration) bool {
|
||||||
|
al.activeReqMu.Lock()
|
||||||
|
if al.activeReqCount == 0 {
|
||||||
|
al.activeReqMu.Unlock()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wake blocked Wait() callers on timeout or context cancellation.
|
||||||
|
var timedOut bool
|
||||||
|
if timeout > 0 {
|
||||||
|
time.AfterFunc(timeout, func() {
|
||||||
|
al.activeReqMu.Lock()
|
||||||
|
timedOut = true
|
||||||
|
al.activeReqCond.Broadcast()
|
||||||
|
al.activeReqMu.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
al.activeReqMu.Lock()
|
||||||
|
al.activeReqCond.Broadcast()
|
||||||
|
al.activeReqMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for al.activeReqCount > 0 && !timedOut && ctx.Err() == nil {
|
||||||
|
al.activeReqCond.Wait()
|
||||||
|
}
|
||||||
|
result := al.activeReqCount == 0
|
||||||
|
al.activeReqMu.Unlock()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) closeReloadedProvider(ctx context.Context, provider providers.StatefulProvider) {
|
||||||
|
waitCtx := ctx
|
||||||
|
if waitCtx == nil {
|
||||||
|
waitCtx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
drained := al.waitForActiveRequests(waitCtx, providerReloadGracePeriod)
|
||||||
|
if !drained {
|
||||||
|
fields := map[string]any{"grace_period": providerReloadGracePeriod.String()}
|
||||||
|
if err := waitCtx.Err(); err != nil {
|
||||||
|
fields["error"] = err.Error()
|
||||||
|
logger.WarnCF("agent", "Provider reload interrupted while waiting for in-flight requests", fields)
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "Provider reload grace period expired with in-flight requests still running", fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func makePendingTurnID(sessionKey string, seq uint64) string {
|
func makePendingTurnID(sessionKey string, seq uint64) string {
|
||||||
return pendingTurnPrefix + sessionKey + "-" + fmt.Sprintf("%d", seq)
|
return pendingTurnPrefix + sessionKey + "-" + fmt.Sprintf("%d", seq)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,13 @@ func NewContextBuilder(workspace string) *ContextBuilder {
|
|||||||
// Use the skills/ directory under the current working directory
|
// Use the skills/ directory under the current working directory
|
||||||
builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))
|
builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))
|
||||||
if builtinSkillsDir == "" {
|
if builtinSkillsDir == "" {
|
||||||
wd, _ := os.Getwd()
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
// os.Getwd failure is extremely rare; fall back to empty
|
||||||
|
// string so that filepath.Join produces a relative "skills"
|
||||||
|
// path, preserving the original lookup behavior.
|
||||||
|
wd = ""
|
||||||
|
}
|
||||||
builtinSkillsDir = filepath.Join(wd, "skills")
|
builtinSkillsDir = filepath.Join(wd, "skills")
|
||||||
}
|
}
|
||||||
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
|
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
|
||||||
|
|||||||
@@ -115,3 +115,55 @@ func isOverContextBudget(
|
|||||||
|
|
||||||
return total > contextWindow
|
return total > contextWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trimHistoryToFitContextWindow rebuilds the prompt from progressively newer
|
||||||
|
// history slices until it fits within the context window. Oldest complete turns
|
||||||
|
// are dropped first so tool-call sequences remain intact.
|
||||||
|
func trimHistoryToFitContextWindow(
|
||||||
|
history []providers.Message,
|
||||||
|
build func([]providers.Message) []providers.Message,
|
||||||
|
contextWindow int,
|
||||||
|
toolDefs []providers.ToolDefinition,
|
||||||
|
maxTokens int,
|
||||||
|
) ([]providers.Message, []providers.Message, bool) {
|
||||||
|
messages := build(history)
|
||||||
|
if !isOverContextBudget(contextWindow, messages, toolDefs, maxTokens) {
|
||||||
|
return history, messages, true
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmedHistory := append([]providers.Message(nil), history...)
|
||||||
|
for len(trimmedHistory) > 0 {
|
||||||
|
dropUntil := nextHistoryTrimStart(trimmedHistory)
|
||||||
|
if dropUntil <= 0 || dropUntil >= len(trimmedHistory) {
|
||||||
|
trimmedHistory = nil
|
||||||
|
} else {
|
||||||
|
trimmedHistory = append([]providers.Message(nil), trimmedHistory[dropUntil:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = build(trimmedHistory)
|
||||||
|
if !isOverContextBudget(contextWindow, messages, toolDefs, maxTokens) {
|
||||||
|
return trimmedHistory, messages, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, messages, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextHistoryTrimStart(history []providers.Message) int {
|
||||||
|
if len(history) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
turns := parseTurnBoundaries(history)
|
||||||
|
if len(turns) >= 2 {
|
||||||
|
return turns[1]
|
||||||
|
}
|
||||||
|
if len(turns) == 1 {
|
||||||
|
if turns[0] > 0 {
|
||||||
|
return turns[0]
|
||||||
|
}
|
||||||
|
return len(history)
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(history)
|
||||||
|
}
|
||||||
|
|||||||
@@ -844,3 +844,64 @@ func TestIsOverContextBudget_RealisticSession(t *testing.T) {
|
|||||||
t.Error("realistic session should exceed 500 context window")
|
t.Error("realistic session should exceed 500 context window")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTrimHistoryToFitContextWindow_DropsOldestTurns(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msgUser(strings.Repeat("u1 ", 120)),
|
||||||
|
msgAssistant(strings.Repeat("a1 ", 120)),
|
||||||
|
msgUser(strings.Repeat("u2 ", 120)),
|
||||||
|
msgAssistant(strings.Repeat("a2 ", 120)),
|
||||||
|
msgUser(strings.Repeat("u3 ", 120)),
|
||||||
|
msgAssistant(strings.Repeat("a3 ", 120)),
|
||||||
|
}
|
||||||
|
|
||||||
|
build := func(history []providers.Message) []providers.Message {
|
||||||
|
return append([]providers.Message(nil), history...)
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmedHistory, messages, fit := trimHistoryToFitContextWindow(
|
||||||
|
history,
|
||||||
|
build,
|
||||||
|
700,
|
||||||
|
nil,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if !fit {
|
||||||
|
t.Fatal("expected trimmed history to fit context window")
|
||||||
|
}
|
||||||
|
if len(trimmedHistory) != 4 {
|
||||||
|
t.Fatalf("trimmed history len = %d, want 4", len(trimmedHistory))
|
||||||
|
}
|
||||||
|
if trimmedHistory[0].Content != history[2].Content {
|
||||||
|
t.Fatalf("first kept message = %q, want second turn start", trimmedHistory[0].Content)
|
||||||
|
}
|
||||||
|
if isOverContextBudget(700, messages, nil, 0) {
|
||||||
|
t.Fatal("trimmed messages should be within budget")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrimHistoryToFitContextWindow_ClearsSingleOversizedTurn(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msgUser(strings.Repeat("oversized ", 200)),
|
||||||
|
msgAssistant(strings.Repeat("oversized ", 200)),
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmedHistory, messages, fit := trimHistoryToFitContextWindow(
|
||||||
|
history,
|
||||||
|
func(history []providers.Message) []providers.Message {
|
||||||
|
return append([]providers.Message(nil), history...)
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
nil,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if !fit {
|
||||||
|
t.Fatal("expected empty history rebuild to fit context window")
|
||||||
|
}
|
||||||
|
if len(trimmedHistory) != 0 {
|
||||||
|
t.Fatalf("trimmed history len = %d, want 0", len(trimmedHistory))
|
||||||
|
}
|
||||||
|
if len(messages) != 0 {
|
||||||
|
t.Fatalf("messages len = %d, want 0", len(messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -294,9 +294,9 @@ func (m *legacyContextManager) retryLLMCall(
|
|||||||
var err error
|
var err error
|
||||||
|
|
||||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
m.al.activeRequests.Add(1)
|
m.al.activeRequestsInc()
|
||||||
resp, err = func() (*providers.LLMResponse, error) {
|
resp, err = func() (*providers.LLMResponse, error) {
|
||||||
defer m.al.activeRequests.Done()
|
defer m.al.activeRequestsDec()
|
||||||
return agent.Provider.Chat(
|
return agent.Provider.Chat(
|
||||||
ctx,
|
ctx,
|
||||||
[]providers.Message{{Role: "user", Content: prompt}},
|
[]providers.Message{{Role: "user", Content: prompt}},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
@@ -200,6 +201,7 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
|
|||||||
ModelName: msg.ModelName,
|
ModelName: msg.ModelName,
|
||||||
ReasoningContent: msg.ReasoningContent,
|
ReasoningContent: msg.ReasoningContent,
|
||||||
TokenCount: tokenizer.EstimateMessageTokens(msg),
|
TokenCount: tokenizer.EstimateMessageTokens(msg),
|
||||||
|
CreatedAt: normalizeSeahorseMessageCreatedAt(msg.CreatedAt),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert ToolCalls → MessageParts
|
// Convert ToolCalls → MessageParts
|
||||||
@@ -235,6 +237,13 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeSeahorseMessageCreatedAt(createdAt *time.Time) time.Time {
|
||||||
|
if createdAt == nil || createdAt.IsZero() {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return createdAt.UTC().Truncate(time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
// seahorseToProviderMessages converts a seahorse.AssembleResult to []providers.Message.
|
// seahorseToProviderMessages converts a seahorse.AssembleResult to []providers.Message.
|
||||||
func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes.Message {
|
func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes.Message {
|
||||||
messages := make([]protocoltypes.Message, 0, len(result.Messages))
|
messages := make([]protocoltypes.Message, 0, len(result.Messages))
|
||||||
|
|||||||
@@ -171,11 +171,13 @@ func TestProviderToSeahorseMessageWithMedia(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProviderToSeahorseMessageWithReasoning(t *testing.T) {
|
func TestProviderToSeahorseMessageWithReasoning(t *testing.T) {
|
||||||
|
createdAt := time.Date(2026, 5, 6, 7, 8, 9, 123000000, time.UTC)
|
||||||
msg := protocoltypes.Message{
|
msg := protocoltypes.Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: "response text",
|
Content: "response text",
|
||||||
ModelName: "gpt-5.4-mini",
|
ModelName: "gpt-5.4-mini",
|
||||||
ReasoningContent: "I thought about this carefully",
|
ReasoningContent: "I thought about this carefully",
|
||||||
|
CreatedAt: &createdAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := providerToSeahorseMessage(msg)
|
result := providerToSeahorseMessage(msg)
|
||||||
@@ -185,6 +187,9 @@ func TestProviderToSeahorseMessageWithReasoning(t *testing.T) {
|
|||||||
if result.ModelName != "gpt-5.4-mini" {
|
if result.ModelName != "gpt-5.4-mini" {
|
||||||
t.Errorf("ModelName = %q, want %q", result.ModelName, "gpt-5.4-mini")
|
t.Errorf("ModelName = %q, want %q", result.ModelName, "gpt-5.4-mini")
|
||||||
}
|
}
|
||||||
|
if !result.CreatedAt.Equal(time.Date(2026, 5, 6, 7, 8, 9, 0, time.UTC)) {
|
||||||
|
t.Errorf("CreatedAt = %v, want 2026-05-06 07:08:09 UTC", result.CreatedAt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSeahorseToProviderMessagesWithReasoning(t *testing.T) {
|
func TestSeahorseToProviderMessagesWithReasoning(t *testing.T) {
|
||||||
@@ -288,6 +293,74 @@ func TestSeahorseToProviderMessagesWithToolCalls(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSeahorseAssemblePreservesActiveToolTurnAcrossSanitization(t *testing.T) {
|
||||||
|
engine, err := seahorse.NewEngine(seahorse.Config{
|
||||||
|
DBPath: t.TempDir() + "/seahorse.db",
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewEngine: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
sessionKey := "test:active-tool-turn"
|
||||||
|
_, err = engine.Ingest(ctx, sessionKey, []seahorse.Message{
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "older context",
|
||||||
|
TokenCount: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: "inspect the file",
|
||||||
|
TokenCount: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
TokenCount: 5,
|
||||||
|
Parts: []seahorse.MessagePart{{
|
||||||
|
Type: "tool_use",
|
||||||
|
Name: "read_file",
|
||||||
|
Arguments: `{"path":"/tmp/test.txt"}`,
|
||||||
|
ToolCallID: "tc_1",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "tool",
|
||||||
|
TokenCount: 200,
|
||||||
|
Parts: []seahorse.MessagePart{{
|
||||||
|
Type: "tool_result",
|
||||||
|
ToolCallID: "tc_1",
|
||||||
|
Text: "very large tool output",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "done",
|
||||||
|
TokenCount: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ingest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := engine.Assemble(ctx, sessionKey, seahorse.AssembleInput{Budget: 210})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Assemble: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized := sanitizeHistoryForProvider(seahorseToProviderMessages(result))
|
||||||
|
if len(sanitized) != 4 {
|
||||||
|
t.Fatalf("sanitized history len = %d, want 4 protected-turn messages", len(sanitized))
|
||||||
|
}
|
||||||
|
assertRoles(t, sanitized, "user", "assistant", "tool", "assistant")
|
||||||
|
if len(sanitized[1].ToolCalls) != 1 || sanitized[1].ToolCalls[0].ID != "tc_1" {
|
||||||
|
t.Fatalf("assistant tool calls = %+v, want preserved tool call tc_1", sanitized[1].ToolCalls)
|
||||||
|
}
|
||||||
|
if sanitized[2].ToolCallID != "tc_1" {
|
||||||
|
t.Fatalf("tool result id = %q, want tc_1", sanitized[2].ToolCallID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSeahorseToProviderMessagesToolResult(t *testing.T) {
|
func TestSeahorseToProviderMessagesToolResult(t *testing.T) {
|
||||||
msg := seahorse.Message{
|
msg := seahorse.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUs
|
|||||||
// proactive trigger (msgTokens + toolTokens + maxTokens > contextWindow).
|
// proactive trigger (msgTokens + toolTokens + maxTokens > contextWindow).
|
||||||
compressAt := effectiveWindow
|
compressAt := effectiveWindow
|
||||||
|
|
||||||
|
// summarizeAt = soft summarization trigger: matches maybeSummarize's
|
||||||
|
// threshold (contextWindow * SummarizeTokenPercent / 100).
|
||||||
|
//
|
||||||
|
// The engine compares this against history-message tokens ONLY (not
|
||||||
|
// UsedTokens). HistoryTokens is exposed alongside UsedTokens so the
|
||||||
|
// UI can show both values and avoid user confusion.
|
||||||
|
summarizeAt := contextWindow * agent.SummarizeTokenPercent / 100
|
||||||
|
if summarizeAt <= 0 {
|
||||||
|
summarizeAt = compressAt
|
||||||
|
}
|
||||||
|
|
||||||
usedPercent := 0
|
usedPercent := 0
|
||||||
if compressAt > 0 {
|
if compressAt > 0 {
|
||||||
usedPercent = usedTokens * 100 / compressAt
|
usedPercent = usedTokens * 100 / compressAt
|
||||||
@@ -70,9 +81,11 @@ func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUs
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &bus.ContextUsage{
|
return &bus.ContextUsage{
|
||||||
UsedTokens: usedTokens,
|
UsedTokens: usedTokens,
|
||||||
TotalTokens: contextWindow,
|
TotalTokens: contextWindow,
|
||||||
CompressAtTokens: compressAt,
|
HistoryTokens: historyTokens,
|
||||||
UsedPercent: usedPercent,
|
CompressAtTokens: compressAt,
|
||||||
|
SummarizeAtTokens: summarizeAt,
|
||||||
|
UsedPercent: usedPercent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,6 +202,9 @@ func (b *evolutionBridge) handleTurnEndAsync(meta EventMeta, payload TurnEndPayl
|
|||||||
FinalSuccessfulPath: append([]string(nil), payload.FinalSuccessfulPath...),
|
FinalSuccessfulPath: append([]string(nil), payload.FinalSuccessfulPath...),
|
||||||
SkillContextSnapshots: toEvolutionSkillContextSnapshots(payload.SkillContextSnapshots),
|
SkillContextSnapshots: toEvolutionSkillContextSnapshots(payload.SkillContextSnapshots),
|
||||||
}
|
}
|
||||||
|
if isEvolutionHeartbeatInput(input) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
b.rememberScheduledColdPathWorkspace(input.Workspace)
|
b.rememberScheduledColdPathWorkspace(input.Workspace)
|
||||||
|
|
||||||
b.closeMu.Lock()
|
b.closeMu.Lock()
|
||||||
@@ -228,6 +231,10 @@ func (b *evolutionBridge) handleTurnEndAsync(meta EventMeta, payload TurnEndPayl
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isEvolutionHeartbeatInput(input evolution.TurnCaseInput) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(input.SessionKey), "heartbeat")
|
||||||
|
}
|
||||||
|
|
||||||
func (b *evolutionBridge) subscribeRuntimeEvents(ch runtimeevents.EventChannel) error {
|
func (b *evolutionBridge) subscribeRuntimeEvents(ch runtimeevents.EventChannel) error {
|
||||||
if b == nil || ch == nil {
|
if b == nil || ch == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -513,6 +513,31 @@ func TestEvolutionBridge_DraftModeAutomaticallyRunsColdPathAndCreatesDraftFile(t
|
|||||||
waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1)
|
waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEvolutionBridge_DraftModeDoesNotRunColdPathForHeartbeat(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
seedReadyRule(t, tmpDir)
|
||||||
|
|
||||||
|
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Mode: "draft",
|
||||||
|
}, &simpleMockProvider{
|
||||||
|
response: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"unexpected heartbeat draft","body_or_patch":"## Unexpected"}`,
|
||||||
|
})
|
||||||
|
defer al.Close()
|
||||||
|
|
||||||
|
resp, err := al.ProcessHeartbeat(context.Background(), "check heartbeat tasks", "telegram", "chat-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProcessHeartbeat failed: %v", err)
|
||||||
|
}
|
||||||
|
if resp == "" {
|
||||||
|
t.Fatal("expected non-empty heartbeat response")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "task-records.jsonl"))
|
||||||
|
assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestEvolutionBridge_ScheduledModeDoesNotRunColdPathAfterTurn(t *testing.T) {
|
func TestEvolutionBridge_ScheduledModeDoesNotRunColdPathAfterTurn(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
seedReadyRule(t, tmpDir)
|
seedReadyRule(t, tmpDir)
|
||||||
|
|||||||
+16
-2
@@ -2,7 +2,6 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -44,6 +43,7 @@ type AgentInstance struct {
|
|||||||
SkillsFilter []string
|
SkillsFilter []string
|
||||||
MCPServerAllowlist map[string]struct{}
|
MCPServerAllowlist map[string]struct{}
|
||||||
Candidates []providers.FallbackCandidate
|
Candidates []providers.FallbackCandidate
|
||||||
|
ImageCandidates []providers.FallbackCandidate
|
||||||
|
|
||||||
// Router is non-nil when model routing is configured and the light model
|
// Router is non-nil when model routing is configured and the light model
|
||||||
// was successfully resolved. It scores each incoming message and decides
|
// was successfully resolved. It scores each incoming message and decides
|
||||||
@@ -199,9 +199,19 @@ func NewAgentInstance(
|
|||||||
|
|
||||||
// Resolve fallback candidates
|
// Resolve fallback candidates
|
||||||
candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks)
|
candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks)
|
||||||
|
imageCandidates := resolveModelCandidates(
|
||||||
|
cfg,
|
||||||
|
defaults.Provider,
|
||||||
|
defaults.ImageModel,
|
||||||
|
defaults.ImageModelFallbacks,
|
||||||
|
)
|
||||||
|
|
||||||
candidateProviders := make(map[string]providers.LLMProvider)
|
candidateProviders := make(map[string]providers.LLMProvider)
|
||||||
populateCandidateProvidersFromNames(cfg, workspace, fallbacks, candidateProviders)
|
populateCandidateProvidersFromNames(cfg, workspace, fallbacks, candidateProviders)
|
||||||
|
if strings.TrimSpace(defaults.ImageModel) != "" {
|
||||||
|
imageNames := append([]string{defaults.ImageModel}, defaults.ImageModelFallbacks...)
|
||||||
|
populateCandidateProvidersFromNames(cfg, workspace, imageNames, candidateProviders)
|
||||||
|
}
|
||||||
|
|
||||||
// Model routing setup: pre-resolve light model candidates at creation time
|
// Model routing setup: pre-resolve light model candidates at creation time
|
||||||
// to avoid repeated model_list lookups on every incoming message.
|
// to avoid repeated model_list lookups on every incoming message.
|
||||||
@@ -266,6 +276,7 @@ func NewAgentInstance(
|
|||||||
SkillsFilter: skillsFilter,
|
SkillsFilter: skillsFilter,
|
||||||
MCPServerAllowlist: agentMCPServerAllowlist,
|
MCPServerAllowlist: agentMCPServerAllowlist,
|
||||||
Candidates: candidates,
|
Candidates: candidates,
|
||||||
|
ImageCandidates: imageCandidates,
|
||||||
Router: router,
|
Router: router,
|
||||||
LightCandidates: lightCandidates,
|
LightCandidates: lightCandidates,
|
||||||
LightProvider: lightProvider,
|
LightProvider: lightProvider,
|
||||||
@@ -414,7 +425,10 @@ func compilePatterns(patterns []string) []*regexp.Regexp {
|
|||||||
for _, p := range patterns {
|
for _, p := range patterns {
|
||||||
re, err := regexp.Compile(p)
|
re, err := regexp.Compile(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Warning: invalid path pattern %q: %v\n", p, err)
|
logger.WarnCF("agent", "invalid path pattern in compilePatterns", map[string]any{
|
||||||
|
"pattern": p,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
compiled = append(compiled, re)
|
compiled = append(compiled, re)
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultEventSubscriberBuffer = 16
|
const defaultEventSubscriberBuffer = 16
|
||||||
@@ -88,7 +90,14 @@ func (al *AgentLoop) UnsubscribeEvents(id uint64) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sub := value.(legacyEventSubscription)
|
sub, ok := value.(legacyEventSubscription)
|
||||||
|
if !ok {
|
||||||
|
logger.WarnCF("agent", "UnsubscribeEvents: unexpected type in subscription map", map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"type": fmt.Sprintf("%T", value),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
sub.cancel()
|
sub.cancel()
|
||||||
if sub.sub != nil {
|
if sub.sub != nil {
|
||||||
_ = sub.sub.Close()
|
_ = sub.sub.Close()
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var resolvedImagePathTagRegex = regexp.MustCompile(`\[image:[^\s\]][^\]]*\]`)
|
||||||
|
|
||||||
func messagesContainMedia(messages []providers.Message) bool {
|
func messagesContainMedia(messages []providers.Message) bool {
|
||||||
for _, msg := range messages {
|
for _, msg := range messages {
|
||||||
for _, ref := range msg.Media {
|
for _, ref := range msg.Media {
|
||||||
@@ -65,3 +70,125 @@ func isVisionUnsupportedError(err error) bool {
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func visionUnsupportedModelError(modelName string, imageModelConfigured bool) error {
|
||||||
|
modelName = strings.TrimSpace(modelName)
|
||||||
|
if imageModelConfigured {
|
||||||
|
if modelName != "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"selected vision model %q does not support image input; update agents.defaults.image_model to a multimodal model",
|
||||||
|
modelName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"selected vision model does not support image input; update agents.defaults.image_model to a multimodal model",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if modelName != "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"active model %q does not support image input; configure agents.defaults.image_model with a multimodal model",
|
||||||
|
modelName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"the active model does not support image input; configure agents.defaults.image_model with a multimodal model",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameCandidateSet(a, b []providers.FallbackCandidate) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i].StableKey() != b[i].StableKey() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func messagesContainCurrentTurnMediaTurn(messages []providers.Message) bool {
|
||||||
|
for _, msg := range messages {
|
||||||
|
if len(msg.Media) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if resolvedImagePathTagRegex.MatchString(msg.Content) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pipeline) routeMediaTurn(ts *turnState, exec *turnExecution) error {
|
||||||
|
if p == nil || ts == nil || ts.agent == nil || exec == nil ||
|
||||||
|
!messagesContainCurrentTurnMediaTurn(currentTurnMessages(exec.callMessages, exec.currentTurnStart)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetCandidates []providers.FallbackCandidate
|
||||||
|
var targetModelName string
|
||||||
|
var routeReason string
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case len(ts.agent.ImageCandidates) > 0:
|
||||||
|
targetCandidates = append([]providers.FallbackCandidate(nil), ts.agent.ImageCandidates...)
|
||||||
|
targetModelName = strings.TrimSpace(p.Cfg.Agents.Defaults.ImageModel)
|
||||||
|
routeReason = "configured_image_model"
|
||||||
|
case exec.usedLight && len(ts.agent.Candidates) > 0:
|
||||||
|
targetCandidates = append([]providers.FallbackCandidate(nil), ts.agent.Candidates...)
|
||||||
|
targetModelName = strings.TrimSpace(ts.agent.Model)
|
||||||
|
routeReason = "bypass_light_model_for_media"
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(targetCandidates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
targetModel := resolvedCandidateModel(targetCandidates, targetModelName)
|
||||||
|
targetProvider := exec.activeProvider
|
||||||
|
firstCandidate := targetCandidates[0]
|
||||||
|
if provider, err := providerForFallbackCandidate(
|
||||||
|
ts.agent,
|
||||||
|
ts.agent.Provider,
|
||||||
|
targetCandidates,
|
||||||
|
firstCandidate.Provider,
|
||||||
|
firstCandidate.Model,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
} else if provider != nil {
|
||||||
|
targetProvider = provider
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedModelName := resolvedCandidateModelName(targetCandidates, targetModelName)
|
||||||
|
if sameCandidateSet(exec.activeCandidates, targetCandidates) &&
|
||||||
|
exec.activeModel == targetModel &&
|
||||||
|
exec.llmModelName == resolvedModelName {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exec.activeCandidates = targetCandidates
|
||||||
|
exec.activeModel = targetModel
|
||||||
|
exec.activeProvider = targetProvider
|
||||||
|
exec.activeModelConfig = resolveActiveModelConfig(
|
||||||
|
p.Cfg,
|
||||||
|
ts.agent.Workspace,
|
||||||
|
targetCandidates,
|
||||||
|
targetModel,
|
||||||
|
p.Cfg.Agents.Defaults.Provider,
|
||||||
|
)
|
||||||
|
exec.llmModelName = resolvedModelName
|
||||||
|
exec.usedLight = false
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Media turn routing selected model", map[string]any{
|
||||||
|
"agent_id": ts.agent.ID,
|
||||||
|
"reason": routeReason,
|
||||||
|
"model": exec.activeModel,
|
||||||
|
"model_name": exec.llmModelName,
|
||||||
|
"candidates": len(exec.activeCandidates),
|
||||||
|
"messages_count": len(exec.callMessages),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -295,21 +295,16 @@ toolLoop:
|
|||||||
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
|
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
toolResultMsg := providers.Message{
|
var toolResultMedia []string
|
||||||
Role: "tool",
|
|
||||||
Content: contentForLLM,
|
|
||||||
ToolCallID: tc.ID,
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(hookResult.Media) > 0 && !hookResult.ResponseHandled {
|
if len(hookResult.Media) > 0 && !hookResult.ResponseHandled {
|
||||||
hookResult.ArtifactTags = buildArtifactTags(al.mediaStore, hookResult.Media)
|
hookResult.ArtifactTags = buildArtifactTags(al.mediaStore, hookResult.Media)
|
||||||
contentForLLM = hookResult.ContentForLLM()
|
contentForLLM = hookResult.ContentForLLM()
|
||||||
if al.cfg.Tools.IsFilterSensitiveDataEnabled() {
|
if al.cfg.Tools.IsFilterSensitiveDataEnabled() {
|
||||||
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
|
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
|
||||||
}
|
}
|
||||||
toolResultMsg.Content = contentForLLM
|
toolResultMedia = append(toolResultMedia, hookResult.Media...)
|
||||||
toolResultMsg.Media = append(toolResultMsg.Media, hookResult.Media...)
|
|
||||||
}
|
}
|
||||||
|
toolResultMsg := toolResultPromptMessage(contentForLLM, tc.ID, toolResultMedia)
|
||||||
|
|
||||||
al.emitEvent(
|
al.emitEvent(
|
||||||
runtimeevents.KindAgentToolExecEnd,
|
runtimeevents.KindAgentToolExecEnd,
|
||||||
@@ -695,14 +690,11 @@ toolLoop:
|
|||||||
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
|
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
toolResultMsg := providers.Message{
|
var toolResultMedia []string
|
||||||
Role: "tool",
|
|
||||||
Content: contentForLLM,
|
|
||||||
ToolCallID: toolCallID,
|
|
||||||
}
|
|
||||||
if len(toolResult.Media) > 0 && !toolResult.ResponseHandled {
|
if len(toolResult.Media) > 0 && !toolResult.ResponseHandled {
|
||||||
toolResultMsg.Media = append(toolResultMsg.Media, toolResult.Media...)
|
toolResultMedia = append(toolResultMedia, toolResult.Media...)
|
||||||
}
|
}
|
||||||
|
toolResultMsg := toolResultPromptMessage(contentForLLM, toolCallID, toolResultMedia)
|
||||||
al.emitEvent(
|
al.emitEvent(
|
||||||
runtimeevents.KindAgentToolExecEnd,
|
runtimeevents.KindAgentToolExecEnd,
|
||||||
ts.eventMeta("runTurn", "turn.tool.end"),
|
ts.eventMeta("runTurn", "turn.tool.end"),
|
||||||
|
|||||||
+174
-109
@@ -31,7 +31,7 @@ func (p *Pipeline) CallLLM(
|
|||||||
|
|
||||||
// PreLLM: resolve media refs (except on iteration 1 where user media is already resolved)
|
// PreLLM: resolve media refs (except on iteration 1 where user media is already resolved)
|
||||||
if iteration > 1 {
|
if iteration > 1 {
|
||||||
exec.messages = resolveMediaRefs(exec.messages, p.MediaStore, maxMediaSize)
|
exec.messages = resolveMediaRefs(exec.messages, p.MediaStore, maxMediaSize, exec.currentTurnStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreLLM: graceful terminal handling
|
// PreLLM: graceful terminal handling
|
||||||
@@ -64,6 +64,9 @@ func (p *Pipeline) CallLLM(
|
|||||||
exec.providerToolDefs = nil
|
exec.providerToolDefs = nil
|
||||||
ts.markGracefulTerminalUsed()
|
ts.markGracefulTerminalUsed()
|
||||||
}
|
}
|
||||||
|
if err := p.routeMediaTurn(ts, exec); err != nil {
|
||||||
|
return ControlBreak, err
|
||||||
|
}
|
||||||
|
|
||||||
exec.llmOpts = map[string]any{
|
exec.llmOpts = map[string]any{
|
||||||
"max_tokens": ts.agent.MaxTokens,
|
"max_tokens": ts.agent.MaxTokens,
|
||||||
@@ -157,8 +160,8 @@ func (p *Pipeline) CallLLM(
|
|||||||
ts.clearProviderCancel(providerCancel)
|
ts.clearProviderCancel(providerCancel)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
al.activeRequests.Add(1)
|
al.activeRequestsInc()
|
||||||
defer al.activeRequests.Done()
|
defer al.activeRequestsDec()
|
||||||
|
|
||||||
if response, handled, streamErr := p.tryConfiguredStreamingLLM(
|
if response, handled, streamErr := p.tryConfiguredStreamingLLM(
|
||||||
providerCtx,
|
providerCtx,
|
||||||
@@ -170,36 +173,62 @@ func (p *Pipeline) CallLLM(
|
|||||||
return response, streamErr
|
return response, streamErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(exec.activeCandidates) > 1 && p.Fallback != nil {
|
runCandidate := func(
|
||||||
fbResult, fbErr := p.Fallback.ExecuteCandidate(
|
ctx context.Context,
|
||||||
providerCtx,
|
candidate providers.FallbackCandidate,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
candidateProvider, err := providerForFallbackCandidate(
|
||||||
|
ts.agent,
|
||||||
|
exec.activeProvider,
|
||||||
exec.activeCandidates,
|
exec.activeCandidates,
|
||||||
func(ctx context.Context, candidate providers.FallbackCandidate) (*providers.LLMResponse, error) {
|
candidate.Provider,
|
||||||
candidateProvider, err := providerForFallbackCandidate(
|
candidate.Model,
|
||||||
ts.agent,
|
|
||||||
exec.activeProvider,
|
|
||||||
exec.activeCandidates,
|
|
||||||
candidate.Provider,
|
|
||||||
candidate.Model,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
callOpts := shallowCloneLLMOptions(exec.llmOpts)
|
|
||||||
delete(callOpts, "thinking_level")
|
|
||||||
candidateCfg := resolveActiveModelConfig(
|
|
||||||
p.Cfg,
|
|
||||||
ts.agent.Workspace,
|
|
||||||
[]providers.FallbackCandidate{candidate},
|
|
||||||
candidate.Model,
|
|
||||||
p.Cfg.Agents.Defaults.Provider,
|
|
||||||
)
|
|
||||||
candidateThinking := thinkingSettingsFromModelConfig(candidateCfg)
|
|
||||||
applyThinkingOption(callOpts, candidateProvider, candidateThinking, true, ts.agent.ID)
|
|
||||||
exec.suppressReasoning = shouldSuppressReasoningFor(candidateThinking)
|
|
||||||
return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, candidate.Model, callOpts)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
callOpts := shallowCloneLLMOptions(exec.llmOpts)
|
||||||
|
delete(callOpts, "thinking_level")
|
||||||
|
candidateCfg := resolveActiveModelConfig(
|
||||||
|
p.Cfg,
|
||||||
|
ts.agent.Workspace,
|
||||||
|
[]providers.FallbackCandidate{candidate},
|
||||||
|
candidate.Model,
|
||||||
|
p.Cfg.Agents.Defaults.Provider,
|
||||||
|
)
|
||||||
|
candidateThinking := thinkingSettingsFromModelConfig(candidateCfg)
|
||||||
|
applyThinkingOption(callOpts, candidateProvider, candidateThinking, true, ts.agent.ID)
|
||||||
|
exec.suppressReasoning = shouldSuppressReasoningFor(candidateThinking)
|
||||||
|
return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, candidate.Model, callOpts)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(exec.activeCandidates) > 1 && p.Fallback != nil {
|
||||||
|
var (
|
||||||
|
fbResult *providers.FallbackResult
|
||||||
|
fbErr error
|
||||||
|
)
|
||||||
|
if hasMediaRefs(messagesForCall) {
|
||||||
|
fbResult, fbErr = p.Fallback.ExecuteImage(
|
||||||
|
providerCtx,
|
||||||
|
exec.activeCandidates,
|
||||||
|
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
||||||
|
candidate := providers.FallbackCandidate{Provider: provider, Model: model}
|
||||||
|
for _, configured := range exec.activeCandidates {
|
||||||
|
if configured.Provider == provider && configured.Model == model {
|
||||||
|
candidate = configured
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runCandidate(ctx, candidate)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
fbResult, fbErr = p.Fallback.ExecuteCandidate(
|
||||||
|
providerCtx,
|
||||||
|
exec.activeCandidates,
|
||||||
|
runCandidate,
|
||||||
|
)
|
||||||
|
}
|
||||||
if fbErr != nil {
|
if fbErr != nil {
|
||||||
return nil, fbErr
|
return nil, fbErr
|
||||||
}
|
}
|
||||||
@@ -250,52 +279,16 @@ func (p *Pipeline) CallLLM(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry without media if vision is unsupported
|
if hasMediaRefs(exec.callMessages) && isVisionUnsupportedError(err) {
|
||||||
if hasMediaRefs(exec.callMessages) && isVisionUnsupportedError(err) && retry < maxRetries {
|
return ControlBreak, visionUnsupportedModelError(
|
||||||
al.emitEvent(
|
exec.llmModelName,
|
||||||
runtimeevents.KindAgentLLMRetry,
|
len(ts.agent.ImageCandidates) > 0,
|
||||||
ts.eventMeta("runTurn", "turn.llm.retry"),
|
|
||||||
LLMRetryPayload{
|
|
||||||
Attempt: retry + 1,
|
|
||||||
MaxRetries: maxRetries,
|
|
||||||
Reason: "vision_unsupported",
|
|
||||||
Error: err.Error(),
|
|
||||||
Backoff: 0,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
logger.WarnCF("agent", "Vision unsupported, retrying without media", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
"retry": retry,
|
|
||||||
})
|
|
||||||
exec.callMessages = stripMessageMedia(exec.callMessages)
|
|
||||||
if !ts.opts.NoHistory {
|
|
||||||
exec.history = stripMessageMedia(exec.history)
|
|
||||||
ts.agent.Sessions.SetHistory(ts.sessionKey, exec.history)
|
|
||||||
for i := range ts.persistedMessages {
|
|
||||||
ts.persistedMessages[i].Media = nil
|
|
||||||
}
|
|
||||||
ts.refreshRestorePointFromSession(ts.agent)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
errMsg := strings.ToLower(err.Error())
|
errMsg := strings.ToLower(err.Error())
|
||||||
isTimeoutError := errors.Is(err, context.DeadlineExceeded) ||
|
retryReason, isTransientError := transientLLMRetryReason(err)
|
||||||
strings.Contains(errMsg, "deadline exceeded") ||
|
isContextError := !isTransientError && (strings.Contains(errMsg, "context_length_exceeded") ||
|
||||||
strings.Contains(errMsg, "client.timeout") ||
|
|
||||||
strings.Contains(errMsg, "timed out") ||
|
|
||||||
strings.Contains(errMsg, "timeout exceeded")
|
|
||||||
|
|
||||||
isNetworkError := !isTimeoutError && (strings.Contains(errMsg, "connection reset") ||
|
|
||||||
strings.Contains(errMsg, "connection refused") ||
|
|
||||||
strings.Contains(errMsg, "broken pipe") ||
|
|
||||||
strings.Contains(errMsg, "no such host") ||
|
|
||||||
strings.Contains(errMsg, "network is unreachable") ||
|
|
||||||
strings.Contains(errMsg, "read tcp") ||
|
|
||||||
strings.Contains(errMsg, "write tcp") ||
|
|
||||||
strings.Contains(errMsg, "eof"))
|
|
||||||
|
|
||||||
isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") ||
|
|
||||||
strings.Contains(errMsg, "context window") ||
|
strings.Contains(errMsg, "context window") ||
|
||||||
strings.Contains(errMsg, "context_window") ||
|
strings.Contains(errMsg, "context_window") ||
|
||||||
strings.Contains(errMsg, "maximum context length") ||
|
strings.Contains(errMsg, "maximum context length") ||
|
||||||
@@ -306,7 +299,7 @@ func (p *Pipeline) CallLLM(
|
|||||||
strings.Contains(errMsg, "prompt is too long") ||
|
strings.Contains(errMsg, "prompt is too long") ||
|
||||||
strings.Contains(errMsg, "request too large"))
|
strings.Contains(errMsg, "request too large"))
|
||||||
|
|
||||||
if isTimeoutError && retry < maxRetries {
|
if isTransientError && retry < maxRetries {
|
||||||
backoff := time.Duration(retry+1) * time.Duration(backoffSecs) * time.Second
|
backoff := time.Duration(retry+1) * time.Duration(backoffSecs) * time.Second
|
||||||
al.emitEvent(
|
al.emitEvent(
|
||||||
runtimeevents.KindAgentLLMRetry,
|
runtimeevents.KindAgentLLMRetry,
|
||||||
@@ -314,42 +307,14 @@ func (p *Pipeline) CallLLM(
|
|||||||
LLMRetryPayload{
|
LLMRetryPayload{
|
||||||
Attempt: retry + 1,
|
Attempt: retry + 1,
|
||||||
MaxRetries: maxRetries,
|
MaxRetries: maxRetries,
|
||||||
Reason: "timeout",
|
Reason: retryReason,
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
Backoff: backoff,
|
Backoff: backoff,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{
|
logger.WarnCF("agent", "Transient LLM error, retrying after backoff", map[string]any{
|
||||||
"error": err.Error(),
|
|
||||||
"retry": retry,
|
|
||||||
"backoff": backoff.String(),
|
|
||||||
})
|
|
||||||
if sleepErr := sleepWithContext(turnCtx, backoff); sleepErr != nil {
|
|
||||||
if ts.hardAbortRequested() {
|
|
||||||
_ = ts.requestHardAbort()
|
|
||||||
return ControlBreak, nil
|
|
||||||
}
|
|
||||||
err = sleepErr
|
|
||||||
break
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if isNetworkError && retry < maxRetries {
|
|
||||||
backoff := time.Duration(retry+1) * time.Duration(backoffSecs) * time.Second
|
|
||||||
al.emitEvent(
|
|
||||||
runtimeevents.KindAgentLLMRetry,
|
|
||||||
ts.eventMeta("runTurn", "turn.llm.retry"),
|
|
||||||
LLMRetryPayload{
|
|
||||||
Attempt: retry + 1,
|
|
||||||
MaxRetries: maxRetries,
|
|
||||||
Reason: "network",
|
|
||||||
Error: err.Error(),
|
|
||||||
Backoff: backoff,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
logger.WarnCF("agent", "Network error, retrying after backoff", map[string]any{
|
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
"reason": retryReason,
|
||||||
"retry": retry,
|
"retry": retry,
|
||||||
"backoff": backoff.String(),
|
"backoff": backoff.String(),
|
||||||
})
|
})
|
||||||
@@ -415,14 +380,72 @@ func (p *Pipeline) CallLLM(
|
|||||||
contextualSkills = ts.agent.ContextBuilder.ResolveActiveSkillsForContext(ts.activeSkills)
|
contextualSkills = ts.agent.ContextBuilder.ResolveActiveSkillsForContext(ts.activeSkills)
|
||||||
}
|
}
|
||||||
ts.recordSkillContextSnapshot(skillContextTriggerContextRetryRebuild, contextualSkills)
|
ts.recordSkillContextSnapshot(skillContextTriggerContextRetryRebuild, contextualSkills)
|
||||||
rebuildPromptReq := promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil, p.Cfg)
|
stableHistory, protectedTurnTail := splitHistoryForActiveTurn(
|
||||||
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
exec.history,
|
||||||
exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
|
ts.persistedMessagesSnapshot(),
|
||||||
exec.callMessages = exec.messages
|
)
|
||||||
|
buildMessages := func(trimmedHistory []providers.Message) []providers.Message {
|
||||||
|
fullHistory := append(append([]providers.Message(nil), trimmedHistory...), protectedTurnTail...)
|
||||||
|
rebuildPromptReq := promptBuildRequestForTurn(ts, fullHistory, exec.summary, "", nil, p.Cfg)
|
||||||
|
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
||||||
|
rebuilt := ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
|
||||||
|
return resolveMediaRefs(
|
||||||
|
rebuilt,
|
||||||
|
p.MediaStore,
|
||||||
|
maxMediaSize,
|
||||||
|
len(rebuilt)-len(protectedTurnTail),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
originalHistoryCount := len(exec.history)
|
||||||
|
var fit bool
|
||||||
|
var trimmedStableHistory []providers.Message
|
||||||
|
trimmedStableHistory, exec.callMessages, fit = trimHistoryToFitContextWindow(
|
||||||
|
stableHistory,
|
||||||
|
func(trimmedHistory []providers.Message) []providers.Message {
|
||||||
|
rebuilt := buildMessages(trimmedHistory)
|
||||||
|
if exec.gracefulTerminal {
|
||||||
|
return append(append([]providers.Message(nil), rebuilt...), ts.interruptHintMessage())
|
||||||
|
}
|
||||||
|
return rebuilt
|
||||||
|
},
|
||||||
|
ts.agent.ContextWindow,
|
||||||
|
exec.providerToolDefs,
|
||||||
|
ts.agent.MaxTokens,
|
||||||
|
)
|
||||||
|
exec.history = append(trimmedStableHistory, protectedTurnTail...)
|
||||||
|
exec.messages = buildMessages(trimmedStableHistory)
|
||||||
|
exec.currentTurnStart = len(exec.messages) - len(protectedTurnTail)
|
||||||
if exec.gracefulTerminal {
|
if exec.gracefulTerminal {
|
||||||
msgs := append([]providers.Message(nil), exec.messages...)
|
msgs := append([]providers.Message(nil), exec.messages...)
|
||||||
exec.callMessages = append(msgs, ts.interruptHintMessage())
|
exec.callMessages = append(msgs, ts.interruptHintMessage())
|
||||||
}
|
}
|
||||||
|
if dropped := originalHistoryCount - len(exec.history); dropped > 0 {
|
||||||
|
logger.WarnCF("agent", "Trimmed rebuilt history after context retry compaction", map[string]any{
|
||||||
|
"session_key": ts.sessionKey,
|
||||||
|
"retry": retry,
|
||||||
|
"dropped_msgs": dropped,
|
||||||
|
"remaining_msgs": len(exec.history),
|
||||||
|
"context_window": ts.agent.ContextWindow,
|
||||||
|
"max_tokens": ts.agent.MaxTokens,
|
||||||
|
"still_overlimit": !fit,
|
||||||
|
})
|
||||||
|
} else if !fit {
|
||||||
|
logger.WarnCF("agent", "Context still exceeds budget after retry compaction rebuild", map[string]any{
|
||||||
|
"session_key": ts.sessionKey,
|
||||||
|
"retry": retry,
|
||||||
|
"history_msgs": len(exec.history),
|
||||||
|
"protected_turn_msgs": len(protectedTurnTail),
|
||||||
|
"context_window": ts.agent.ContextWindow,
|
||||||
|
"max_tokens": ts.agent.MaxTokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !fit {
|
||||||
|
err = fmt.Errorf(
|
||||||
|
"context window still exceeded after retry compaction; refusing to drop active turn messages: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -684,3 +707,45 @@ func providerForFallbackCandidate(
|
|||||||
}
|
}
|
||||||
return activeProvider, nil
|
return activeProvider, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func transientLLMRetryReason(err error) (string, bool) {
|
||||||
|
if err == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if failErr := providers.ClassifyError(err, "", ""); failErr != nil {
|
||||||
|
switch failErr.Reason {
|
||||||
|
case providers.FailoverTimeout:
|
||||||
|
if failErr.Status >= 500 {
|
||||||
|
return "server_error", true
|
||||||
|
}
|
||||||
|
return "timeout", true
|
||||||
|
case providers.FailoverNetwork:
|
||||||
|
return "network", true
|
||||||
|
case providers.FailoverRateLimit, providers.FailoverOverloaded:
|
||||||
|
return "rate_limit", true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errMsg := strings.ToLower(err.Error())
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) ||
|
||||||
|
strings.Contains(errMsg, "deadline exceeded") ||
|
||||||
|
strings.Contains(errMsg, "client.timeout") ||
|
||||||
|
strings.Contains(errMsg, "timed out") ||
|
||||||
|
strings.Contains(errMsg, "timeout exceeded") {
|
||||||
|
return "timeout", true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(errMsg, "connection reset") ||
|
||||||
|
strings.Contains(errMsg, "connection refused") ||
|
||||||
|
strings.Contains(errMsg, "broken pipe") ||
|
||||||
|
strings.Contains(errMsg, "no such host") ||
|
||||||
|
strings.Contains(errMsg, "network is unreachable") ||
|
||||||
|
strings.Contains(errMsg, "read tcp") ||
|
||||||
|
strings.Contains(errMsg, "write tcp") ||
|
||||||
|
strings.Contains(errMsg, "eof") {
|
||||||
|
return "network", true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,8 +39,12 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
|||||||
initialPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media, cfg)
|
initialPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media, cfg)
|
||||||
initialPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
initialPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
||||||
messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt(initialPromptReq)
|
messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt(initialPromptReq)
|
||||||
|
currentTurnStart := len(messages)
|
||||||
|
if strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0 {
|
||||||
|
currentTurnStart = len(messages) - 1
|
||||||
|
}
|
||||||
|
|
||||||
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
|
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize, currentTurnStart)
|
||||||
|
|
||||||
if !ts.opts.NoHistory {
|
if !ts.opts.NoHistory {
|
||||||
toolDefs := filterToolsByTurnProfile(ts.agent.Tools.ToProviderDefs(), ts.profile)
|
toolDefs := filterToolsByTurnProfile(ts.agent.Tools.ToProviderDefs(), ts.profile)
|
||||||
@@ -66,10 +70,49 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
|||||||
history = resp.History
|
history = resp.History
|
||||||
summary = resp.Summary
|
summary = resp.Summary
|
||||||
}
|
}
|
||||||
rebuildPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media, cfg)
|
originalHistoryCount := len(history)
|
||||||
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
var fit bool
|
||||||
messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
|
history, messages, fit = trimHistoryToFitContextWindow(
|
||||||
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
|
history,
|
||||||
|
func(trimmedHistory []providers.Message) []providers.Message {
|
||||||
|
rebuildPromptReq := promptBuildRequestForTurn(
|
||||||
|
ts,
|
||||||
|
trimmedHistory,
|
||||||
|
summary,
|
||||||
|
ts.userMessage,
|
||||||
|
ts.media,
|
||||||
|
cfg,
|
||||||
|
)
|
||||||
|
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
||||||
|
rebuilt := ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
|
||||||
|
rebuiltCurrentTurnStart := len(rebuilt)
|
||||||
|
if strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0 {
|
||||||
|
rebuiltCurrentTurnStart = len(rebuilt) - 1
|
||||||
|
}
|
||||||
|
return resolveMediaRefs(rebuilt, p.MediaStore, maxMediaSize, rebuiltCurrentTurnStart)
|
||||||
|
},
|
||||||
|
ts.agent.ContextWindow,
|
||||||
|
toolDefs,
|
||||||
|
ts.agent.MaxTokens,
|
||||||
|
)
|
||||||
|
if dropped := originalHistoryCount - len(history); dropped > 0 {
|
||||||
|
logger.WarnCF("agent", "Trimmed rebuilt history after proactive compaction", map[string]any{
|
||||||
|
"session_key": ts.sessionKey,
|
||||||
|
"dropped_msgs": dropped,
|
||||||
|
"remaining_msgs": len(history),
|
||||||
|
"context_window": ts.agent.ContextWindow,
|
||||||
|
"max_tokens": ts.agent.MaxTokens,
|
||||||
|
"still_overlimit": !fit,
|
||||||
|
})
|
||||||
|
} else if !fit {
|
||||||
|
logger.WarnCF("agent", "Context still exceeds budget "+
|
||||||
|
"after proactive compaction rebuild", map[string]any{
|
||||||
|
"session_key": ts.sessionKey,
|
||||||
|
"history_msgs": len(history),
|
||||||
|
"context_window": ts.agent.ContextWindow,
|
||||||
|
"max_tokens": ts.agent.MaxTokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +145,7 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
|||||||
summary,
|
summary,
|
||||||
messages,
|
messages,
|
||||||
)
|
)
|
||||||
|
exec.currentTurnStart = currentTurnStart
|
||||||
exec.activeCandidates = activeCandidates
|
exec.activeCandidates = activeCandidates
|
||||||
exec.activeModel = activeModel
|
exec.activeModel = activeModel
|
||||||
exec.activeModelConfig = resolveActiveModelConfig(
|
exec.activeModelConfig = resolveActiveModelConfig(
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const (
|
|||||||
PromptSlotMessage PromptSlot = "message"
|
PromptSlotMessage PromptSlot = "message"
|
||||||
PromptSlotSteering PromptSlot = "steering"
|
PromptSlotSteering PromptSlot = "steering"
|
||||||
PromptSlotSubTurn PromptSlot = "subturn"
|
PromptSlotSubTurn PromptSlot = "subturn"
|
||||||
|
PromptSlotToolResult PromptSlot = "tool_result"
|
||||||
PromptSlotInterrupt PromptSlot = "interrupt"
|
PromptSlotInterrupt PromptSlot = "interrupt"
|
||||||
PromptSlotOutput PromptSlot = "output"
|
PromptSlotOutput PromptSlot = "output"
|
||||||
)
|
)
|
||||||
@@ -60,6 +61,7 @@ const (
|
|||||||
PromptSourceUserMessage PromptSourceID = "turn:user_message"
|
PromptSourceUserMessage PromptSourceID = "turn:user_message"
|
||||||
PromptSourceSteering PromptSourceID = "turn:steering"
|
PromptSourceSteering PromptSourceID = "turn:steering"
|
||||||
PromptSourceSubTurnResult PromptSourceID = "turn:subturn_result"
|
PromptSourceSubTurnResult PromptSourceID = "turn:subturn_result"
|
||||||
|
PromptSourceToolResult PromptSourceID = "turn:tool_result"
|
||||||
PromptSourceInterrupt PromptSourceID = "turn:interrupt"
|
PromptSourceInterrupt PromptSourceID = "turn:interrupt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,29 @@ func userPromptMessage(content string, media []string) providers.Message {
|
|||||||
return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotMessage, PromptSourceUserMessage)
|
return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotMessage, PromptSourceUserMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toolResultPromptMessage(content, toolCallID string, media []string) providers.Message {
|
||||||
|
msg := providers.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: content,
|
||||||
|
ToolCallID: toolCallID,
|
||||||
|
}
|
||||||
|
if len(media) > 0 {
|
||||||
|
msg.Media = append([]string(nil), media...)
|
||||||
|
}
|
||||||
|
return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotToolResult, PromptSourceToolResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolImageFollowUpPromptMessage(media []string) providers.Message {
|
||||||
|
msg := providers.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: "[Loaded image from tool result above]",
|
||||||
|
}
|
||||||
|
if len(media) > 0 {
|
||||||
|
msg.Media = append([]string(nil), media...)
|
||||||
|
}
|
||||||
|
return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotToolResult, PromptSourceToolResult)
|
||||||
|
}
|
||||||
|
|
||||||
func steeringPromptMessage(msg providers.Message) providers.Message {
|
func steeringPromptMessage(msg providers.Message) providers.Message {
|
||||||
return promptMessageWithDefaultMetadata(msg, PromptLayerTurn, PromptSlotSteering, PromptSourceSteering)
|
return promptMessageWithDefaultMetadata(msg, PromptLayerTurn, PromptSlotSteering, PromptSourceSteering)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,6 +11,7 @@ import (
|
|||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRuntimeEventLoggerFiltering(t *testing.T) {
|
func TestRuntimeEventLoggerFiltering(t *testing.T) {
|
||||||
@@ -191,6 +194,164 @@ func TestReloadProviderAndConfigRefreshesRuntimeEventLogger(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type reloadBlockingProvider struct {
|
||||||
|
chatStarted chan struct{}
|
||||||
|
releaseChat chan struct{}
|
||||||
|
closeCalled chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *reloadBlockingProvider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []providers.Message,
|
||||||
|
tools []providers.ToolDefinition,
|
||||||
|
model string,
|
||||||
|
options map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
select {
|
||||||
|
case <-p.chatStarted:
|
||||||
|
default:
|
||||||
|
close(p.chatStarted)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-p.releaseChat:
|
||||||
|
return &providers.LLMResponse{Content: "done"}, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *reloadBlockingProvider) GetDefaultModel() string {
|
||||||
|
return "reload-blocking"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *reloadBlockingProvider) Close() {
|
||||||
|
select {
|
||||||
|
case <-p.closeCalled:
|
||||||
|
default:
|
||||||
|
close(p.closeCalled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReloadProviderAndConfigWaitsForInFlightRequestsBeforeClosingOldProvider(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Workspace = t.TempDir()
|
||||||
|
|
||||||
|
oldProvider := &reloadBlockingProvider{
|
||||||
|
chatStarted: make(chan struct{}),
|
||||||
|
releaseChat: make(chan struct{}),
|
||||||
|
closeCalled: make(chan struct{}),
|
||||||
|
}
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), oldProvider)
|
||||||
|
defer al.Close()
|
||||||
|
|
||||||
|
msg := testInboundMessage(bus.InboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "reload-chat",
|
||||||
|
SenderID: "user-1",
|
||||||
|
Content: "hold request open",
|
||||||
|
})
|
||||||
|
|
||||||
|
reqDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := al.processMessage(context.Background(), msg)
|
||||||
|
reqDone <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-oldProvider.chatStarted:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for in-flight provider request")
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
reloaded := config.DefaultConfig()
|
||||||
|
reloaded.Agents.Defaults.Workspace = cfg.Agents.Defaults.Workspace
|
||||||
|
reloadDone <- al.ReloadProviderAndConfig(context.Background(), &mockProvider{}, reloaded)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-oldProvider.closeCalled:
|
||||||
|
t.Fatal("old provider closed before in-flight request completed")
|
||||||
|
case err := <-reloadDone:
|
||||||
|
t.Fatalf("reload returned early: %v", err)
|
||||||
|
case <-time.After(150 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
close(oldProvider.releaseChat)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-reqDone:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for in-flight request to complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-reloadDone:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReloadProviderAndConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for reload to finish")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-oldProvider.closeCalled:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for old provider close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitForActiveRequestsHonorsContextCancellation(t *testing.T) {
|
||||||
|
al := &AgentLoop{}
|
||||||
|
al.activeReqCond = sync.NewCond(&al.activeReqMu)
|
||||||
|
al.activeRequestsInc()
|
||||||
|
defer al.activeRequestsDec()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if al.waitForActiveRequests(ctx, time.Second) {
|
||||||
|
t.Fatal("waitForActiveRequests() = true, want false on canceled context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReloadProviderAndConfigReturnsCanceledErrorWhenRegistryCreationPanics(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Workspace = t.TempDir()
|
||||||
|
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
|
||||||
|
defer al.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := al.ReloadProviderAndConfig(ctx, &panicProviderForReloadTest{}, cfg)
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("ReloadProviderAndConfig() error = %v, want context canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type panicProviderForReloadTest struct{}
|
||||||
|
|
||||||
|
func (p *panicProviderForReloadTest) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []providers.Message,
|
||||||
|
tools []providers.ToolDefinition,
|
||||||
|
model string,
|
||||||
|
options map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
return &providers.LLMResponse{Content: "unused"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *panicProviderForReloadTest) GetDefaultModel() string {
|
||||||
|
panic("boom")
|
||||||
|
}
|
||||||
|
|
||||||
func TestCloseRuntimeEventLoggerSubscriptionWaitsForDrain(t *testing.T) {
|
func TestCloseRuntimeEventLoggerSubscriptionWaitsForDrain(t *testing.T) {
|
||||||
eventBus := runtimeevents.NewBus()
|
eventBus := runtimeevents.NewBus()
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|||||||
@@ -1618,6 +1618,8 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defaultAgent.Sessions.SetHistory(sessionKey, originalHistory)
|
defaultAgent.Sessions.SetHistory(sessionKey, originalHistory)
|
||||||
|
|
||||||
|
originalHistory = defaultAgent.Sessions.GetHistory(sessionKey)
|
||||||
|
|
||||||
runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest(
|
runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest(
|
||||||
t,
|
t,
|
||||||
al,
|
al,
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
|
|||||||
|
|
||||||
// Inject pending steering messages
|
// Inject pending steering messages
|
||||||
if len(pendingMessages) > 0 {
|
if len(pendingMessages) > 0 {
|
||||||
resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize)
|
resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize, 0)
|
||||||
totalContentLen := 0
|
totalContentLen := 0
|
||||||
for i, pm := range pendingMessages {
|
for i, pm := range pendingMessages {
|
||||||
messages = append(messages, resolvedPending[i])
|
messages = append(messages, resolvedPending[i])
|
||||||
@@ -431,7 +431,11 @@ func (al *AgentLoop) askSideQuestion(
|
|||||||
messages := agent.ContextBuilder.BuildMessagesFromPrompt(promptReq)
|
messages := agent.ContextBuilder.BuildMessagesFromPrompt(promptReq)
|
||||||
|
|
||||||
maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize()
|
maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize()
|
||||||
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
currentTurnStart := len(messages)
|
||||||
|
if strings.TrimSpace(question) != "" || len(media) > 0 {
|
||||||
|
currentTurnStart = len(messages) - 1
|
||||||
|
}
|
||||||
|
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize, currentTurnStart)
|
||||||
|
|
||||||
activeCandidates, activeModel, usedLight := al.selectCandidates(agent, question, messages)
|
activeCandidates, activeModel, usedLight := al.selectCandidates(agent, question, messages)
|
||||||
selectedModelName := sideQuestionModelName(agent, usedLight)
|
selectedModelName := sideQuestionModelName(agent, usedLight)
|
||||||
|
|||||||
@@ -193,6 +193,38 @@ func (p *errorProvider) GetDefaultModel() string {
|
|||||||
return "error-model"
|
return "error-model"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type failOnceLLMProvider struct {
|
||||||
|
err error
|
||||||
|
response string
|
||||||
|
callCount int
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *failOnceLLMProvider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []providers.Message,
|
||||||
|
tools []providers.ToolDefinition,
|
||||||
|
model string,
|
||||||
|
opts map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.callCount++
|
||||||
|
callCount := p.callCount
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if callCount == 1 {
|
||||||
|
return nil, p.err
|
||||||
|
}
|
||||||
|
return &providers.LLMResponse{
|
||||||
|
Content: p.response,
|
||||||
|
FinishReason: "stop",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *failOnceLLMProvider) GetDefaultModel() string {
|
||||||
|
return "fail-once-model"
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Test Helper Functions
|
// Test Helper Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -586,6 +618,59 @@ func TestPipeline_CallLLM_TimeoutRetry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPipeline_CallLLM_HTTP5xxRetry(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
provider := &failOnceLLMProvider{
|
||||||
|
err: errors.New("API request failed:\n Status: 500\n Body: internal server error"),
|
||||||
|
response: "Recovered from server error",
|
||||||
|
}
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
MaxLLMRetries: 1,
|
||||||
|
LLMRetryBackoffSecs: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
defer al.Close()
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
t.Fatal("expected default agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeline := NewPipeline(al)
|
||||||
|
ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{
|
||||||
|
turnID: "turn-1",
|
||||||
|
context: newTurnContext(nil, nil, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
exec, err := pipeline.SetupTurn(context.Background(), ts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetupTurn failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected HTTP 500 retry to recover, got error: %v", err)
|
||||||
|
}
|
||||||
|
if ctrl != ControlBreak {
|
||||||
|
t.Fatalf("expected ControlBreak, got %v", ctrl)
|
||||||
|
}
|
||||||
|
if exec.finalContent != "Recovered from server error" {
|
||||||
|
t.Fatalf("finalContent = %q, want recovered response", exec.finalContent)
|
||||||
|
}
|
||||||
|
if provider.callCount != 2 {
|
||||||
|
t.Fatalf("callCount = %d, want 2", provider.callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPipeline_CallLLM_ContextLengthError(t *testing.T) {
|
func TestPipeline_CallLLM_ContextLengthError(t *testing.T) {
|
||||||
errorPrv := &errorProvider{errType: "context_length"}
|
errorPrv := &errorProvider{errType: "context_length"}
|
||||||
al, agent, cleanup := newTurnCoordTestLoop(t, errorPrv)
|
al, agent, cleanup := newTurnCoordTestLoop(t, errorPrv)
|
||||||
|
|||||||
@@ -105,9 +105,10 @@ func TestTurnProfile_DisabledPreservesDefaultHistoryAndPrompt(t *testing.T) {
|
|||||||
al := newTurnProfileAgentLoop(t, cfg, provider)
|
al := newTurnProfileAgentLoop(t, cfg, provider)
|
||||||
agent := al.GetRegistry().GetDefaultAgent()
|
agent := al.GetRegistry().GetDefaultAgent()
|
||||||
sessionKey := "agent:default:test-default"
|
sessionKey := "agent:default:test-default"
|
||||||
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
initialHistory := []providers.Message{
|
initialHistory := []providers.Message{
|
||||||
{Role: "user", Content: "old user"},
|
{Role: "user", Content: "old user", CreatedAt: &ts},
|
||||||
{Role: "assistant", Content: "old assistant"},
|
{Role: "assistant", Content: "old assistant", CreatedAt: &ts},
|
||||||
}
|
}
|
||||||
agent.Sessions.SetHistory(sessionKey, initialHistory)
|
agent.Sessions.SetHistory(sessionKey, initialHistory)
|
||||||
agent.Sessions.SetSummary(sessionKey, "old summary")
|
agent.Sessions.SetSummary(sessionKey, "old summary")
|
||||||
@@ -154,9 +155,10 @@ func TestTurnProfile_HistoryOffSuppressesHistoryAndPersistence(t *testing.T) {
|
|||||||
al := newTurnProfileAgentLoop(t, cfg, provider)
|
al := newTurnProfileAgentLoop(t, cfg, provider)
|
||||||
agent := al.GetRegistry().GetDefaultAgent()
|
agent := al.GetRegistry().GetDefaultAgent()
|
||||||
sessionKey := "agent:default:test-history-off"
|
sessionKey := "agent:default:test-history-off"
|
||||||
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
initialHistory := []providers.Message{
|
initialHistory := []providers.Message{
|
||||||
{Role: "user", Content: "old user"},
|
{Role: "user", Content: "old user", CreatedAt: &ts},
|
||||||
{Role: "assistant", Content: "old assistant"},
|
{Role: "assistant", Content: "old assistant", CreatedAt: &ts},
|
||||||
}
|
}
|
||||||
agent.Sessions.SetHistory(sessionKey, initialHistory)
|
agent.Sessions.SetHistory(sessionKey, initialHistory)
|
||||||
agent.Sessions.SetSummary(sessionKey, "old summary")
|
agent.Sessions.SetSummary(sessionKey, "old summary")
|
||||||
|
|||||||
+101
-15
@@ -114,10 +114,11 @@ type ActiveTurnInfo struct {
|
|||||||
|
|
||||||
type turnExecution struct {
|
type turnExecution struct {
|
||||||
// Core message state (accumulates throughout the turn)
|
// Core message state (accumulates throughout the turn)
|
||||||
messages []providers.Message // built from ContextBuilder, grows per-iteration
|
messages []providers.Message // built from ContextBuilder, grows per-iteration
|
||||||
pendingMessages []providers.Message // steering/SubTurn messages awaiting injection
|
pendingMessages []providers.Message // steering/SubTurn messages awaiting injection
|
||||||
history []providers.Message // from ContextManager.Assemble
|
history []providers.Message // from ContextManager.Assemble
|
||||||
summary string
|
summary string
|
||||||
|
currentTurnStart int
|
||||||
|
|
||||||
// Turn output
|
// Turn output
|
||||||
finalContent string
|
finalContent string
|
||||||
@@ -164,12 +165,13 @@ func newTurnExecution(
|
|||||||
messages []providers.Message,
|
messages []providers.Message,
|
||||||
) *turnExecution {
|
) *turnExecution {
|
||||||
return &turnExecution{
|
return &turnExecution{
|
||||||
history: history,
|
history: history,
|
||||||
summary: summary,
|
summary: summary,
|
||||||
messages: messages,
|
messages: messages,
|
||||||
pendingMessages: append([]providers.Message(nil), opts.InitialSteeringMessages...),
|
pendingMessages: append([]providers.Message(nil), opts.InitialSteeringMessages...),
|
||||||
iteration: 0,
|
currentTurnStart: len(messages),
|
||||||
phase: LLMPhaseSetup,
|
iteration: 0,
|
||||||
|
phase: LLMPhaseSetup,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +290,17 @@ func (al *AgentLoop) registerActiveTurn(ts *turnState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) clearActiveTurn(ts *turnState) {
|
func (al *AgentLoop) clearActiveTurn(ts *turnState) {
|
||||||
al.activeTurnStates.Delete(ts.sessionKey)
|
al.releaseSessionTurnState(ts.sessionKey, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) releaseSessionTurnState(sessionKey string, expected *turnState) {
|
||||||
|
if expected == nil {
|
||||||
|
al.activeTurnStates.Delete(sessionKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if actual, ok := al.activeTurnStates.Load(sessionKey); ok && actual == expected {
|
||||||
|
al.activeTurnStates.Delete(sessionKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) getActiveTurnState(sessionKey string) *turnState {
|
func (al *AgentLoop) getActiveTurnState(sessionKey string) *turnState {
|
||||||
@@ -643,13 +655,17 @@ func (ts *turnState) recordPersistedMessage(msg providers.Message) {
|
|||||||
ts.persistedMessages = append(ts.persistedMessages, msg)
|
ts.persistedMessages = append(ts.persistedMessages, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ts *turnState) persistedMessagesSnapshot() []providers.Message {
|
||||||
|
ts.mu.RLock()
|
||||||
|
defer ts.mu.RUnlock()
|
||||||
|
return append([]providers.Message(nil), ts.persistedMessages...)
|
||||||
|
}
|
||||||
|
|
||||||
func (ts *turnState) refreshRestorePointFromSession(agent *AgentInstance) {
|
func (ts *turnState) refreshRestorePointFromSession(agent *AgentInstance) {
|
||||||
history := agent.Sessions.GetHistory(ts.sessionKey)
|
history := agent.Sessions.GetHistory(ts.sessionKey)
|
||||||
summary := agent.Sessions.GetSummary(ts.sessionKey)
|
summary := agent.Sessions.GetSummary(ts.sessionKey)
|
||||||
|
|
||||||
ts.mu.RLock()
|
persisted := ts.persistedMessagesSnapshot()
|
||||||
persisted := append([]providers.Message(nil), ts.persistedMessages...)
|
|
||||||
ts.mu.RUnlock()
|
|
||||||
|
|
||||||
if matched := matchingTurnMessageTail(history, persisted); matched > 0 {
|
if matched := matchingTurnMessageTail(history, persisted); matched > 0 {
|
||||||
history = append([]providers.Message(nil), history[:len(history)-matched]...)
|
history = append([]providers.Message(nil), history[:len(history)-matched]...)
|
||||||
@@ -689,13 +705,83 @@ func (ts *turnState) restoreSession(agent *AgentInstance) error {
|
|||||||
func matchingTurnMessageTail(history, persisted []providers.Message) int {
|
func matchingTurnMessageTail(history, persisted []providers.Message) int {
|
||||||
maxMatch := min(len(history), len(persisted))
|
maxMatch := min(len(history), len(persisted))
|
||||||
for size := maxMatch; size > 0; size-- {
|
for size := maxMatch; size > 0; size-- {
|
||||||
if reflect.DeepEqual(history[len(history)-size:], persisted[len(persisted)-size:]) {
|
if messageSlicesEquivalent(history[len(history)-size:], persisted[len(persisted)-size:]) {
|
||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func splitHistoryForActiveTurn(
|
||||||
|
history []providers.Message,
|
||||||
|
persisted []providers.Message,
|
||||||
|
) ([]providers.Message, []providers.Message) {
|
||||||
|
matched := matchingTurnMessageTail(history, persisted)
|
||||||
|
if matched <= 0 {
|
||||||
|
return append([]providers.Message(nil), history...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stable := append([]providers.Message(nil), history[:len(history)-matched]...)
|
||||||
|
protected := append([]providers.Message(nil), history[len(history)-matched:]...)
|
||||||
|
return stable, protected
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageSlicesEquivalent(a, b []providers.Message) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if !messagesEquivalent(a[i], b[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func messagesEquivalent(a, b providers.Message) bool {
|
||||||
|
return reflect.DeepEqual(normalizeMessageForComparison(a), normalizeMessageForComparison(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMessageForComparison(msg providers.Message) providers.Message {
|
||||||
|
msg.PromptLayer = ""
|
||||||
|
msg.PromptSlot = ""
|
||||||
|
msg.PromptSource = ""
|
||||||
|
|
||||||
|
if len(msg.Media) == 0 {
|
||||||
|
msg.Media = nil
|
||||||
|
}
|
||||||
|
if len(msg.Attachments) == 0 {
|
||||||
|
msg.Attachments = nil
|
||||||
|
}
|
||||||
|
if len(msg.SystemParts) == 0 {
|
||||||
|
msg.SystemParts = nil
|
||||||
|
} else {
|
||||||
|
msg.SystemParts = append([]providers.ContentBlock(nil), msg.SystemParts...)
|
||||||
|
for i := range msg.SystemParts {
|
||||||
|
msg.SystemParts[i].PromptLayer = ""
|
||||||
|
msg.SystemParts[i].PromptSlot = ""
|
||||||
|
msg.SystemParts[i].PromptSource = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(msg.ToolCalls) == 0 {
|
||||||
|
msg.ToolCalls = nil
|
||||||
|
} else {
|
||||||
|
msg.ToolCalls = append([]providers.ToolCall(nil), msg.ToolCalls...)
|
||||||
|
for i := range msg.ToolCalls {
|
||||||
|
msg.ToolCalls[i].Name = ""
|
||||||
|
msg.ToolCalls[i].Arguments = nil
|
||||||
|
msg.ToolCalls[i].ThoughtSignature = ""
|
||||||
|
if msg.ToolCalls[i].Function != nil {
|
||||||
|
fn := *msg.ToolCalls[i].Function
|
||||||
|
fn.ThoughtSignature = ""
|
||||||
|
msg.ToolCalls[i].Function = &fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
func (ts *turnState) interruptHintMessage() providers.Message {
|
func (ts *turnState) interruptHintMessage() providers.Message {
|
||||||
_, hint := ts.gracefulInterruptRequested()
|
_, hint := ts.gracefulInterruptRequested()
|
||||||
content := "Interrupt requested. Stop scheduling tools and provide a short final summary."
|
content := "Interrupt requested. Stop scheduling tools and provide a short final summary."
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMatchingTurnMessageTail_IgnoresInternalRuntimeFields(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
{Role: "user", Content: "question"},
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
ToolCalls: []providers.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_1",
|
||||||
|
Type: "function",
|
||||||
|
Function: &providers.FunctionCall{
|
||||||
|
Name: "read_file",
|
||||||
|
Arguments: `{"path":"/tmp/test"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
persisted := []providers.Message{
|
||||||
|
userPromptMessage("question", nil),
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
ToolCalls: []providers.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_1",
|
||||||
|
Type: "function",
|
||||||
|
Name: "read_file",
|
||||||
|
Arguments: map[string]any{"path": "/tmp/test"},
|
||||||
|
ThoughtSignature: "internal-signature",
|
||||||
|
Function: &providers.FunctionCall{
|
||||||
|
Name: "read_file",
|
||||||
|
Arguments: `{"path":"/tmp/test"}`,
|
||||||
|
ThoughtSignature: "internal-signature",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := matchingTurnMessageTail(history, persisted); got != 2 {
|
||||||
|
t.Fatalf("matchingTurnMessageTail() = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitHistoryForActiveTurn_ProtectsPersistedTail(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
{Role: "user", Content: "old question"},
|
||||||
|
{Role: "assistant", Content: "old answer"},
|
||||||
|
{Role: "user", Content: "current question"},
|
||||||
|
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
persisted := []providers.Message{
|
||||||
|
userPromptMessage("current question", nil),
|
||||||
|
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
stable, protected := splitHistoryForActiveTurn(history, persisted)
|
||||||
|
if len(stable) != 2 {
|
||||||
|
t.Fatalf("stable history len = %d, want 2", len(stable))
|
||||||
|
}
|
||||||
|
if len(protected) != 2 {
|
||||||
|
t.Fatalf("protected tail len = %d, want 2", len(protected))
|
||||||
|
}
|
||||||
|
if protected[0].Content != "current question" {
|
||||||
|
t.Fatalf("protected[0].Content = %q, want current question", protected[0].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrimHistoryToFitContextWindow_WithProtectedTurnTailKeepsActiveTurn(t *testing.T) {
|
||||||
|
current := strings.Repeat("current turn ", 80)
|
||||||
|
history := []providers.Message{
|
||||||
|
{Role: "user", Content: strings.Repeat("old turn ", 60)},
|
||||||
|
{Role: "assistant", Content: strings.Repeat("old reply ", 60)},
|
||||||
|
{Role: "user", Content: current},
|
||||||
|
}
|
||||||
|
|
||||||
|
stable, protected := splitHistoryForActiveTurn(history, []providers.Message{
|
||||||
|
userPromptMessage(current, nil),
|
||||||
|
})
|
||||||
|
trimmedStable, messages, fit := trimHistoryToFitContextWindow(
|
||||||
|
stable,
|
||||||
|
func(trimmedHistory []providers.Message) []providers.Message {
|
||||||
|
return append(append([]providers.Message(nil), trimmedHistory...), protected...)
|
||||||
|
},
|
||||||
|
120,
|
||||||
|
nil,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if fit {
|
||||||
|
t.Fatal("expected protected active turn alone to remain over budget")
|
||||||
|
}
|
||||||
|
if len(trimmedStable) != 0 {
|
||||||
|
t.Fatalf("trimmed stable history len = %d, want 0", len(trimmedStable))
|
||||||
|
}
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("messages len = %d, want 1 protected active-turn message", len(messages))
|
||||||
|
}
|
||||||
|
if messages[0].Content != current {
|
||||||
|
t.Fatalf("messages[0].Content = %q, want protected current turn", messages[0].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
-2
@@ -25,6 +25,8 @@ Instead:
|
|||||||
|
|
||||||
- `voice.tts_model_name` selects a named entry from `model_list`.
|
- `voice.tts_model_name` selects a named entry from `model_list`.
|
||||||
- That `model_list` entry provides the provider, model ID, API base, and proxy settings.
|
- That `model_list` entry provides the provider, model ID, API base, and proxy settings.
|
||||||
|
- For providers that need model-specific TTS parameters, use `model_list[].extra_body`
|
||||||
|
to pass fields such as `voice` and `response_format`.
|
||||||
- `.security.yml` stores the API key for the same named model entry.
|
- `.security.yml` stores the API key for the same named model entry.
|
||||||
|
|
||||||
This is the recommended and supported configuration pattern.
|
This is the recommended and supported configuration pattern.
|
||||||
@@ -87,6 +89,43 @@ model_list:
|
|||||||
|
|
||||||
If you use a custom MiMo endpoint, you can also set `api_base` explicitly. Otherwise PicoClaw will use the provider default.
|
If you use a custom MiMo endpoint, you can also set `api_base` explicitly. Otherwise PicoClaw will use the provider default.
|
||||||
|
|
||||||
|
### Option C: OpenRouter MAI Voice 2
|
||||||
|
|
||||||
|
Some OpenAI-compatible TTS routes require provider-specific request fields.
|
||||||
|
OpenRouter's `microsoft/mai-voice-2` is one example: it needs a model-specific
|
||||||
|
voice name and works best with `response_format: "mp3"`.
|
||||||
|
|
||||||
|
`config.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"voice": {
|
||||||
|
"tts_model_name": "mai-voice-2"
|
||||||
|
},
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "mai-voice-2",
|
||||||
|
"provider": "openrouter",
|
||||||
|
"model": "microsoft/mai-voice-2",
|
||||||
|
"api_base": "https://openrouter.ai/api/v1",
|
||||||
|
"extra_body": {
|
||||||
|
"voice": "en-US-Harper:MAI-Voice-2",
|
||||||
|
"response_format": "mp3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`.security.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
mai-voice-2:
|
||||||
|
api_keys:
|
||||||
|
- "sk-or-your-openrouter-key"
|
||||||
|
```
|
||||||
|
|
||||||
## What PicoClaw Sends Today
|
## What PicoClaw Sends Today
|
||||||
|
|
||||||
The current TTS runtime uses an OpenAI-compatible speech request with these defaults:
|
The current TTS runtime uses an OpenAI-compatible speech request with these defaults:
|
||||||
@@ -96,11 +135,14 @@ The current TTS runtime uses an OpenAI-compatible speech request with these defa
|
|||||||
- Voice: `alloy`
|
- Voice: `alloy`
|
||||||
- Model: taken from the selected `model_list` entry
|
- Model: taken from the selected `model_list` entry
|
||||||
|
|
||||||
|
These defaults can now be overridden per model through `model_list[].extra_body`.
|
||||||
|
|
||||||
That means:
|
That means:
|
||||||
|
|
||||||
- `openai/tts-1` works naturally.
|
- `openai/tts-1` works naturally.
|
||||||
- Other OpenAI-compatible providers can work if they accept the same request format.
|
- Other OpenAI-compatible providers can work if they accept the same request format.
|
||||||
- PicoClaw currently does not expose a user-facing config field for changing the TTS voice from `alloy`.
|
- Provider-specific TTS models may need their own `voice` and `response_format` values.
|
||||||
|
- If a provider rejects `response_format`, PicoClaw retries once without that field.
|
||||||
|
|
||||||
## How PicoClaw Chooses a TTS Provider
|
## How PicoClaw Chooses a TTS Provider
|
||||||
|
|
||||||
@@ -124,7 +166,8 @@ PicoClaw normalizes the configured base URL for TTS:
|
|||||||
|
|
||||||
- Setting `voice.tts_model_name` to a name that does not exist in `model_list`.
|
- Setting `voice.tts_model_name` to a name that does not exist in `model_list`.
|
||||||
- Adding a TTS model but forgetting to put its API key in `.security.yml`.
|
- Adding a TTS model but forgetting to put its API key in `.security.yml`.
|
||||||
- Assuming PicoClaw will automatically use provider-specific custom voices.
|
- Assuming PicoClaw will automatically infer provider-specific custom voices.
|
||||||
|
- Forgetting to set `model_list[].extra_body.voice` or `model_list[].extra_body.response_format` for TTS models that require them.
|
||||||
- Using a provider endpoint that is not compatible with the OpenAI `/audio/speech` request format.
|
- Using a provider endpoint that is not compatible with the OpenAI `/audio/speech` request format.
|
||||||
|
|
||||||
## Minimal Checklist
|
## Minimal Checklist
|
||||||
|
|||||||
+119
-16
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -16,14 +17,49 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type OpenAITTSProvider struct {
|
type OpenAITTSProvider struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
apiBase string
|
apiBase string
|
||||||
voice string
|
voice string
|
||||||
model string
|
model string
|
||||||
httpClient *http.Client
|
responseFormat string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenAITTSOptions struct {
|
||||||
|
Voice string
|
||||||
|
ResponseFormat string
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAITTSAudioStream struct {
|
||||||
|
io.ReadCloser
|
||||||
|
fileExt string
|
||||||
|
contentType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *openAITTSAudioStream) AudioFileMeta() (string, string) {
|
||||||
|
return s.fileExt, s.contentType
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAITTSAPIError struct {
|
||||||
|
statusCode int
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *openAITTSAPIError) Error() string {
|
||||||
|
return fmt.Sprintf("API error (status %d): %s", e.statusCode, e.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOpenAITTSProvider(apiKey string, apiBase string, proxyURL string, model string) *OpenAITTSProvider {
|
func NewOpenAITTSProvider(apiKey string, apiBase string, proxyURL string, model string) *OpenAITTSProvider {
|
||||||
|
return NewOpenAITTSProviderWithOptions(apiKey, apiBase, proxyURL, model, OpenAITTSOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOpenAITTSProviderWithOptions(
|
||||||
|
apiKey string,
|
||||||
|
apiBase string,
|
||||||
|
proxyURL string,
|
||||||
|
model string,
|
||||||
|
options OpenAITTSOptions,
|
||||||
|
) *OpenAITTSProvider {
|
||||||
// Normalize apiBase to avoid malformed endpoints like
|
// Normalize apiBase to avoid malformed endpoints like
|
||||||
// "https://api.openai.com/audio/speech" when "/v1" is required.
|
// "https://api.openai.com/audio/speech" when "/v1" is required.
|
||||||
if apiBase == "" {
|
if apiBase == "" {
|
||||||
@@ -75,12 +111,23 @@ func NewOpenAITTSProvider(apiKey string, apiBase string, proxyURL string, model
|
|||||||
model = "tts-1"
|
model = "tts-1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
voice := strings.TrimSpace(options.Voice)
|
||||||
|
if voice == "" {
|
||||||
|
voice = "alloy"
|
||||||
|
}
|
||||||
|
|
||||||
|
responseFormat := strings.TrimSpace(options.ResponseFormat)
|
||||||
|
if responseFormat == "" {
|
||||||
|
responseFormat = "opus"
|
||||||
|
}
|
||||||
|
|
||||||
return &OpenAITTSProvider{
|
return &OpenAITTSProvider{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: apiBase,
|
apiBase: apiBase,
|
||||||
voice: "alloy",
|
voice: voice,
|
||||||
model: model,
|
model: model,
|
||||||
httpClient: client,
|
responseFormat: responseFormat,
|
||||||
|
httpClient: client,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,11 +138,42 @@ func (t *OpenAITTSProvider) Name() string {
|
|||||||
func (t *OpenAITTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) {
|
func (t *OpenAITTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) {
|
||||||
logger.DebugCF("voice-tts", "Starting TTS synthesis", map[string]any{"text_len": len(text)})
|
logger.DebugCF("voice-tts", "Starting TTS synthesis", map[string]any{"text_len": len(text)})
|
||||||
|
|
||||||
|
responseFormat := t.responseFormat
|
||||||
|
stream, err := t.doSpeechRequest(ctx, text, responseFormat)
|
||||||
|
if err != nil {
|
||||||
|
var apiErr *openAITTSAPIError
|
||||||
|
if errors.As(err, &apiErr) && shouldRetryWithoutResponseFormat(apiErr.body) {
|
||||||
|
logger.InfoCF("voice-tts", "Retrying TTS without response_format after provider rejection", map[string]any{
|
||||||
|
"model": t.model,
|
||||||
|
})
|
||||||
|
responseFormat = ""
|
||||||
|
stream, err = t.doSpeechRequest(ctx, text, responseFormat)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileExt, contentType := audioFileMetaForResponseFormat(responseFormat)
|
||||||
|
return &openAITTSAudioStream{
|
||||||
|
ReadCloser: stream,
|
||||||
|
fileExt: fileExt,
|
||||||
|
contentType: contentType,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *OpenAITTSProvider) doSpeechRequest(
|
||||||
|
ctx context.Context,
|
||||||
|
text string,
|
||||||
|
responseFormat string,
|
||||||
|
) (io.ReadCloser, error) {
|
||||||
reqBody := map[string]any{
|
reqBody := map[string]any{
|
||||||
"model": t.model,
|
"model": t.model,
|
||||||
"input": text,
|
"input": text,
|
||||||
"voice": t.voice,
|
"voice": t.voice,
|
||||||
"response_format": "opus",
|
}
|
||||||
|
if responseFormat != "" {
|
||||||
|
reqBody["response_format"] = responseFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonData, err := json.Marshal(reqBody)
|
jsonData, err := json.Marshal(reqBody)
|
||||||
@@ -118,9 +196,34 @@ func (t *OpenAITTSProvider) Synthesize(ctx context.Context, text string) (io.Rea
|
|||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
if readErr != nil {
|
||||||
|
body = []byte(fmt.Sprintf("(failed to read error body: %v)", readErr))
|
||||||
|
}
|
||||||
|
return nil, &openAITTSAPIError{
|
||||||
|
statusCode: resp.StatusCode,
|
||||||
|
body: string(body),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp.Body, nil
|
return resp.Body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldRetryWithoutResponseFormat(body string) bool {
|
||||||
|
lower := strings.ToLower(body)
|
||||||
|
if !strings.Contains(lower, "response_format") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(lower, "invalid") || strings.Contains(lower, "unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func audioFileMetaForResponseFormat(responseFormat string) (string, string) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(responseFormat)) {
|
||||||
|
case "", "mp3":
|
||||||
|
return ".mp3", "audio/mpeg"
|
||||||
|
case "wav":
|
||||||
|
return ".wav", "audio/wav"
|
||||||
|
default:
|
||||||
|
return ".ogg", "audio/ogg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+33
-2
@@ -19,6 +19,10 @@ type TTSProvider interface {
|
|||||||
Synthesize(ctx context.Context, text string) (io.ReadCloser, error)
|
Synthesize(ctx context.Context, text string) (io.ReadCloser, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ttsAudioMetaProvider interface {
|
||||||
|
AudioFileMeta() (fileExt string, contentType string)
|
||||||
|
}
|
||||||
|
|
||||||
func providerFromModelConfig(mc *config.ModelConfig) TTSProvider {
|
func providerFromModelConfig(mc *config.ModelConfig) TTSProvider {
|
||||||
if mc == nil || mc.APIKey() == "" {
|
if mc == nil || mc.APIKey() == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -33,10 +37,31 @@ func providerFromModelConfig(mc *config.ModelConfig) TTSProvider {
|
|||||||
case "mimo":
|
case "mimo":
|
||||||
return NewMimoTTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), modelID, mc.Proxy)
|
return NewMimoTTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), modelID, mc.Proxy)
|
||||||
default:
|
default:
|
||||||
return NewOpenAITTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), mc.Proxy, modelID)
|
return NewOpenAITTSProviderWithOptions(
|
||||||
|
mc.APIKey(),
|
||||||
|
providers.ResolveAPIBase(mc),
|
||||||
|
mc.Proxy,
|
||||||
|
modelID,
|
||||||
|
openAITTSOptionsFromModelConfig(mc),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func openAITTSOptionsFromModelConfig(mc *config.ModelConfig) OpenAITTSOptions {
|
||||||
|
options := OpenAITTSOptions{}
|
||||||
|
if mc == nil || mc.ExtraBody == nil {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
if voice, ok := mc.ExtraBody["voice"].(string); ok {
|
||||||
|
options.Voice = strings.TrimSpace(voice)
|
||||||
|
}
|
||||||
|
if responseFormat, ok := mc.ExtraBody["response_format"].(string); ok {
|
||||||
|
options.ResponseFormat = strings.TrimSpace(responseFormat)
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
func DetectTTS(cfg *config.Config) TTSProvider {
|
func DetectTTS(cfg *config.Config) TTSProvider {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -100,6 +125,12 @@ func SynthesizeAndStore(
|
|||||||
fileExt = ".mp3"
|
fileExt = ".mp3"
|
||||||
contentType = "audio/mpeg"
|
contentType = "audio/mpeg"
|
||||||
}
|
}
|
||||||
|
if metaProvider, ok := stream.(ttsAudioMetaProvider); ok {
|
||||||
|
if ext, ct := metaProvider.AudioFileMeta(); ext != "" && ct != "" {
|
||||||
|
fileExt = ext
|
||||||
|
contentType = ct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
file, err := os.CreateTemp(media.TempDir(), "tts-*"+fileExt)
|
file, err := os.CreateTemp(media.TempDir(), "tts-*"+fileExt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,7 +146,7 @@ func SynthesizeAndStore(
|
|||||||
|
|
||||||
_, err = io.Copy(file, stream)
|
_, err = io.Copy(file, stream)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
file.Close()
|
_ = file.Close()
|
||||||
return "", fmt.Errorf("failed to write tts audio: %w", err)
|
return "", fmt.Errorf("failed to write tts audio: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,64 @@ func TestOpenAITTSProvider_SynthesizeNon200(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenAITTSProvider_SynthesizeRetriesWithoutResponseFormat(t *testing.T) {
|
||||||
|
var requestBodies []map[string]any
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bodyBytes, _ := io.ReadAll(r.Body)
|
||||||
|
_ = r.Body.Close()
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.Unmarshal(bodyBytes, &body)
|
||||||
|
requestBodies = append(requestBodies, body)
|
||||||
|
|
||||||
|
if len(requestBodies) == 1 {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"response_format is invalid"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("audio-bytes"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
provider := NewOpenAITTSProvider("k123", server.URL, "", "x-ai/grok-voice-tts-1.0")
|
||||||
|
stream, err := provider.Synthesize(context.Background(), "hello")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Synthesize failed: %v", err)
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(stream)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read stream failed: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "audio-bytes" {
|
||||||
|
t.Fatalf("response body mismatch: got %q", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(requestBodies) != 2 {
|
||||||
|
t.Fatalf("request count mismatch: got %d, want 2", len(requestBodies))
|
||||||
|
}
|
||||||
|
if requestBodies[0]["response_format"] != "opus" {
|
||||||
|
t.Fatalf("first request should include opus response_format, got %#v", requestBodies[0]["response_format"])
|
||||||
|
}
|
||||||
|
if _, ok := requestBodies[1]["response_format"]; ok {
|
||||||
|
t.Fatalf("second request should omit response_format, got %#v", requestBodies[1]["response_format"])
|
||||||
|
}
|
||||||
|
|
||||||
|
metaStream, ok := stream.(interface {
|
||||||
|
AudioFileMeta() (string, string)
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("stream does not expose audio metadata")
|
||||||
|
}
|
||||||
|
fileExt, contentType := metaStream.AudioFileMeta()
|
||||||
|
if fileExt != ".mp3" || contentType != "audio/mpeg" {
|
||||||
|
t.Fatalf("audio metadata mismatch: got (%q, %q)", fileExt, contentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewOpenAITTSProvider_UsesConfiguredModel(t *testing.T) {
|
func TestNewOpenAITTSProvider_UsesConfiguredModel(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -144,6 +202,27 @@ func TestNewOpenAITTSProvider_UsesConfiguredModel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewOpenAITTSProvider_UsesConfiguredVoiceAndResponseFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
provider := NewOpenAITTSProviderWithOptions(
|
||||||
|
"key",
|
||||||
|
"https://openrouter.ai/api/v1",
|
||||||
|
"",
|
||||||
|
"microsoft/mai-voice-2",
|
||||||
|
OpenAITTSOptions{
|
||||||
|
Voice: "en-US-Harper:MAI-Voice-2",
|
||||||
|
ResponseFormat: "mp3",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if provider.voice != "en-US-Harper:MAI-Voice-2" {
|
||||||
|
t.Fatalf("voice mismatch: got %q", provider.voice)
|
||||||
|
}
|
||||||
|
if provider.responseFormat != "mp3" {
|
||||||
|
t.Fatalf("responseFormat mismatch: got %q", provider.responseFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetectTTS_UsesMimoProviderForMimoModels(t *testing.T) {
|
func TestDetectTTS_UsesMimoProviderForMimoModels(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -170,6 +249,36 @@ func TestDetectTTS_UsesMimoProviderForMimoModels(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetectTTS_UsesOpenAIExtraBodyVoiceAndResponseFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
provider := DetectTTS(&config.Config{
|
||||||
|
Voice: config.VoiceConfig{TTSModelName: "mai-voice"},
|
||||||
|
ModelList: []*config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "mai-voice",
|
||||||
|
Model: "openrouter/microsoft/mai-voice-2",
|
||||||
|
APIKeys: config.SimpleSecureStrings("sk-openrouter"),
|
||||||
|
ExtraBody: map[string]any{
|
||||||
|
"voice": "en-US-Harper:MAI-Voice-2",
|
||||||
|
"response_format": "mp3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ttsProvider, ok := provider.(*OpenAITTSProvider)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("DetectTTS() type = %T, want *OpenAITTSProvider", provider)
|
||||||
|
}
|
||||||
|
if ttsProvider.voice != "en-US-Harper:MAI-Voice-2" {
|
||||||
|
t.Fatalf("voice mismatch: got %q", ttsProvider.voice)
|
||||||
|
}
|
||||||
|
if ttsProvider.responseFormat != "mp3" {
|
||||||
|
t.Fatalf("responseFormat mismatch: got %q", ttsProvider.responseFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type stubTTSProvider struct {
|
type stubTTSProvider struct {
|
||||||
name string
|
name string
|
||||||
}
|
}
|
||||||
@@ -245,3 +354,51 @@ func TestSynthesizeAndStore_UsesMp3MetadataForMimo(t *testing.T) {
|
|||||||
t.Fatalf("filename extension = %q, want %q", filepath.Ext(meta.Filename), ".mp3")
|
t.Fatalf("filename extension = %q, want %q", filepath.Ext(meta.Filename), ".mp3")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSynthesizeAndStore_UsesStreamProvidedAudioMetadata(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bodyBytes, _ := io.ReadAll(r.Body)
|
||||||
|
_ = r.Body.Close()
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.Unmarshal(bodyBytes, &body)
|
||||||
|
if body["response_format"] == "opus" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"response_format is invalid"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("mp3-audio"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
provider := NewOpenAITTSProvider("k123", server.URL, "", "x-ai/grok-voice-tts-1.0")
|
||||||
|
ref, err := SynthesizeAndStore(
|
||||||
|
context.Background(),
|
||||||
|
provider,
|
||||||
|
store,
|
||||||
|
"hello",
|
||||||
|
"",
|
||||||
|
"telegram",
|
||||||
|
"chat123",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SynthesizeAndStore failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path, meta, err := store.ResolveWithMeta(ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveWithMeta failed: %v", err)
|
||||||
|
}
|
||||||
|
if meta.ContentType != "audio/mpeg" {
|
||||||
|
t.Fatalf("ContentType = %q, want %q", meta.ContentType, "audio/mpeg")
|
||||||
|
}
|
||||||
|
if filepath.Ext(path) != ".mp3" {
|
||||||
|
t.Fatalf("stored file extension = %q, want %q", filepath.Ext(path), ".mp3")
|
||||||
|
}
|
||||||
|
if filepath.Ext(meta.Filename) != ".mp3" {
|
||||||
|
t.Fatalf("filename extension = %q, want %q", filepath.Ext(meta.Filename), ".mp3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+207
-18
@@ -3,8 +3,10 @@ package bus
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
@@ -13,6 +15,10 @@ import (
|
|||||||
// ErrBusClosed is returned when publishing to a closed MessageBus.
|
// ErrBusClosed is returned when publishing to a closed MessageBus.
|
||||||
var ErrBusClosed = errors.New("message bus closed")
|
var ErrBusClosed = errors.New("message bus closed")
|
||||||
|
|
||||||
|
// ErrBusBackpressure is returned when a publish attempt exceeds the configured
|
||||||
|
// backpressure wait budget and the message is dropped.
|
||||||
|
var ErrBusBackpressure = errors.New("message bus backpressure")
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrMissingInboundContext = errors.New("inbound message context is required")
|
ErrMissingInboundContext = errors.New("inbound message context is required")
|
||||||
ErrMissingOutboundContext = errors.New("outbound message context is required")
|
ErrMissingOutboundContext = errors.New("outbound message context is required")
|
||||||
@@ -21,6 +27,41 @@ var (
|
|||||||
|
|
||||||
const defaultBusBufferSize = 64
|
const defaultBusBufferSize = 64
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultAudioPublishTimeout = 150 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
type publishPolicy struct {
|
||||||
|
stream string
|
||||||
|
// timeout is the backpressure drop budget. When positive, a full channel
|
||||||
|
// causes the message to be dropped after this duration. When zero, the
|
||||||
|
// publish blocks until context cancellation or bus close (no drop).
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type streamStats struct {
|
||||||
|
dropped atomic.Uint64
|
||||||
|
lastDropped atomic.Int64
|
||||||
|
lastWaitNanos atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageBusStats struct {
|
||||||
|
Inbound StreamStats `json:"inbound"`
|
||||||
|
Outbound StreamStats `json:"outbound"`
|
||||||
|
OutboundMedia StreamStats `json:"outbound_media"`
|
||||||
|
AudioChunks StreamStats `json:"audio_chunks"`
|
||||||
|
VoiceControls StreamStats `json:"voice_controls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamStats struct {
|
||||||
|
Depth int `json:"depth"`
|
||||||
|
Capacity int `json:"capacity"`
|
||||||
|
DroppedTotal uint64 `json:"dropped_total"`
|
||||||
|
LastDroppedAt time.Time `json:"last_dropped_at,omitempty"`
|
||||||
|
LastDropWait string `json:"last_drop_wait,omitempty"`
|
||||||
|
LastDropWaitMillis int64 `json:"last_drop_wait_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// StreamDelegate is implemented by the channel Manager to provide streaming
|
// StreamDelegate is implemented by the channel Manager to provide streaming
|
||||||
// capabilities to the agent loop without tight coupling.
|
// capabilities to the agent loop without tight coupling.
|
||||||
type StreamDelegate interface {
|
type StreamDelegate interface {
|
||||||
@@ -62,8 +103,14 @@ type MessageBus struct {
|
|||||||
done chan struct{}
|
done chan struct{}
|
||||||
closed atomic.Bool
|
closed atomic.Bool
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
publishMu sync.Mutex
|
||||||
streamDelegate atomic.Value // stores StreamDelegate
|
streamDelegate atomic.Value // stores StreamDelegate
|
||||||
eventPublisher atomic.Value // stores EventPublisher
|
eventPublisher atomic.Value // stores EventPublisher
|
||||||
|
inboundStats streamStats
|
||||||
|
outboundStats streamStats
|
||||||
|
mediaStats streamStats
|
||||||
|
audioStats streamStats
|
||||||
|
voiceStats streamStats
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventPublisher is the minimal runtime event publisher used by MessageBus.
|
// EventPublisher is the minimal runtime event publisher used by MessageBus.
|
||||||
@@ -83,13 +130,13 @@ func NewMessageBus() *MessageBus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error {
|
func (mb *MessageBus) enterPublish(ctx context.Context) error {
|
||||||
// check bus closed before acquiring wg, to avoid unnecessary wg.Add and potential deadlock
|
mb.publishMu.Lock()
|
||||||
|
defer mb.publishMu.Unlock()
|
||||||
|
|
||||||
if mb.closed.Load() {
|
if mb.closed.Load() {
|
||||||
return ErrBusClosed
|
return ErrBusClosed
|
||||||
}
|
}
|
||||||
|
|
||||||
// check again,before sending message, to avoid sending to closed channel
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -99,8 +146,63 @@ func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
mb.wg.Add(1)
|
mb.wg.Add(1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func publish[T any](
|
||||||
|
ctx context.Context,
|
||||||
|
mb *MessageBus,
|
||||||
|
ch chan T,
|
||||||
|
msg T,
|
||||||
|
policy publishPolicy,
|
||||||
|
stats *streamStats,
|
||||||
|
scope runtimeevents.Scope,
|
||||||
|
) error {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if err := mb.enterPublish(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
defer mb.wg.Done()
|
defer mb.wg.Done()
|
||||||
|
|
||||||
|
// timeout == 0 means no backpressure drop budget; block until context
|
||||||
|
// cancellation or bus close. This is the default for critical streams
|
||||||
|
// (inbound, outbound, outboundMedia, voiceControl) where dropping
|
||||||
|
// messages silently is undesirable.
|
||||||
|
if policy.timeout > 0 {
|
||||||
|
timer := time.NewTimer(policy.timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case ch <- msg:
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
droppedTotal := stats.dropped.Add(1)
|
||||||
|
now := time.Now()
|
||||||
|
stats.lastDropped.Store(now.UnixNano())
|
||||||
|
stats.lastWaitNanos.Store(policy.timeout.Nanoseconds())
|
||||||
|
queueDepth := len(ch)
|
||||||
|
queueCap := cap(ch)
|
||||||
|
mb.publishDrop(
|
||||||
|
policy.stream, scope, "queue_full_timeout",
|
||||||
|
policy.timeout, queueDepth, queueCap, droppedTotal,
|
||||||
|
)
|
||||||
|
logger.WarnCF("bus", "Dropped bus message due to backpressure", map[string]any{
|
||||||
|
"stream": policy.stream,
|
||||||
|
"wait_ms": policy.timeout.Milliseconds(),
|
||||||
|
"queue_depth": queueDepth,
|
||||||
|
"queue_capacity": queueCap,
|
||||||
|
"dropped_total": droppedTotal,
|
||||||
|
})
|
||||||
|
return fmt.Errorf("%w: %s queue full after %s", ErrBusBackpressure, policy.stream, policy.timeout)
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case ch <- msg:
|
case ch <- msg:
|
||||||
return nil
|
return nil
|
||||||
@@ -117,8 +219,13 @@ func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) er
|
|||||||
mb.publishFailure("inbound", runtimeScopeFromInboundContext(msg.Context), ErrMissingInboundContext)
|
mb.publishFailure("inbound", runtimeScopeFromInboundContext(msg.Context), ErrMissingInboundContext)
|
||||||
return ErrMissingInboundContext
|
return ErrMissingInboundContext
|
||||||
}
|
}
|
||||||
if err := publish(ctx, mb, mb.inbound, msg); err != nil {
|
if err := publish(ctx, mb, mb.inbound, msg, publishPolicy{
|
||||||
mb.publishFailure("inbound", runtimeScopeFromInboundContext(msg.Context), err)
|
stream: "inbound",
|
||||||
|
}, &mb.inboundStats, runtimeScopeFromInboundContext(msg.Context)); err != nil {
|
||||||
|
scope := runtimeScopeFromInboundContext(msg.Context)
|
||||||
|
if !errors.Is(err, ErrBusBackpressure) {
|
||||||
|
mb.publishFailure("inbound", scope, err)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -134,8 +241,13 @@ func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage)
|
|||||||
mb.publishFailure("outbound", runtimeScopeFromInboundContext(msg.Context), ErrMissingOutboundContext)
|
mb.publishFailure("outbound", runtimeScopeFromInboundContext(msg.Context), ErrMissingOutboundContext)
|
||||||
return ErrMissingOutboundContext
|
return ErrMissingOutboundContext
|
||||||
}
|
}
|
||||||
if err := publish(ctx, mb, mb.outbound, msg); err != nil {
|
if err := publish(ctx, mb, mb.outbound, msg, publishPolicy{
|
||||||
mb.publishFailure("outbound", runtimeScopeFromInboundContext(msg.Context), err)
|
stream: "outbound",
|
||||||
|
}, &mb.outboundStats, runtimeScopeFromInboundContext(msg.Context)); err != nil {
|
||||||
|
scope := runtimeScopeFromInboundContext(msg.Context)
|
||||||
|
if !errors.Is(err, ErrBusBackpressure) {
|
||||||
|
mb.publishFailure("outbound", scope, err)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -151,8 +263,13 @@ func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMedi
|
|||||||
mb.publishFailure("outbound_media", runtimeScopeFromInboundContext(msg.Context), ErrMissingOutboundMediaContext)
|
mb.publishFailure("outbound_media", runtimeScopeFromInboundContext(msg.Context), ErrMissingOutboundMediaContext)
|
||||||
return ErrMissingOutboundMediaContext
|
return ErrMissingOutboundMediaContext
|
||||||
}
|
}
|
||||||
if err := publish(ctx, mb, mb.outboundMedia, msg); err != nil {
|
if err := publish(ctx, mb, mb.outboundMedia, msg, publishPolicy{
|
||||||
mb.publishFailure("outbound_media", runtimeScopeFromInboundContext(msg.Context), err)
|
stream: "outbound_media",
|
||||||
|
}, &mb.mediaStats, runtimeScopeFromInboundContext(msg.Context)); err != nil {
|
||||||
|
scope := runtimeScopeFromInboundContext(msg.Context)
|
||||||
|
if !errors.Is(err, ErrBusBackpressure) {
|
||||||
|
mb.publishFailure("outbound_media", scope, err)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -163,8 +280,14 @@ func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishAudioChunk(ctx context.Context, chunk AudioChunk) error {
|
func (mb *MessageBus) PublishAudioChunk(ctx context.Context, chunk AudioChunk) error {
|
||||||
if err := publish(ctx, mb, mb.audioChunks, chunk); err != nil {
|
if err := publish(ctx, mb, mb.audioChunks, chunk, publishPolicy{
|
||||||
mb.publishFailure("audio_chunk", runtimeScopeFromAudioChunk(chunk), err)
|
stream: "audio_chunk",
|
||||||
|
timeout: defaultAudioPublishTimeout,
|
||||||
|
}, &mb.audioStats, runtimeScopeFromAudioChunk(chunk)); err != nil {
|
||||||
|
scope := runtimeScopeFromAudioChunk(chunk)
|
||||||
|
if !errors.Is(err, ErrBusBackpressure) {
|
||||||
|
mb.publishFailure("audio_chunk", scope, err)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -175,8 +298,13 @@ func (mb *MessageBus) AudioChunksChan() <-chan AudioChunk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishVoiceControl(ctx context.Context, ctrl VoiceControl) error {
|
func (mb *MessageBus) PublishVoiceControl(ctx context.Context, ctrl VoiceControl) error {
|
||||||
if err := publish(ctx, mb, mb.voiceControls, ctrl); err != nil {
|
if err := publish(ctx, mb, mb.voiceControls, ctrl, publishPolicy{
|
||||||
mb.publishFailure("voice_control", runtimeScopeFromVoiceControl(ctrl), err)
|
stream: "voice_control",
|
||||||
|
}, &mb.voiceStats, runtimeScopeFromVoiceControl(ctrl)); err != nil {
|
||||||
|
scope := runtimeScopeFromVoiceControl(ctrl)
|
||||||
|
if !errors.Is(err, ErrBusBackpressure) {
|
||||||
|
mb.publishFailure("voice_control", scope, err)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -204,15 +332,76 @@ func (mb *MessageBus) GetStreamer(ctx context.Context, channel, chatID, sessionK
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (mb *MessageBus) Stats() MessageBusStats {
|
||||||
|
if mb == nil {
|
||||||
|
return MessageBusStats{}
|
||||||
|
}
|
||||||
|
return MessageBusStats{
|
||||||
|
Inbound: snapshotStreamStats(mb.inbound, &mb.inboundStats),
|
||||||
|
Outbound: snapshotStreamStats(mb.outbound, &mb.outboundStats),
|
||||||
|
OutboundMedia: snapshotStreamStats(mb.outboundMedia, &mb.mediaStats),
|
||||||
|
AudioChunks: snapshotStreamStats(mb.audioChunks, &mb.audioStats),
|
||||||
|
VoiceControls: snapshotStreamStats(mb.voiceControls, &mb.voiceStats),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck returns a snapshot of queue depths and cumulative drop counts
|
||||||
|
// across all streams. It always reports ok=true: backpressure-induced drops are
|
||||||
|
// reflected in the message string for telemetry but do not affect the boolean.
|
||||||
|
// Callers that need readiness semantics (e.g. returning 503 when drops occur)
|
||||||
|
// should inspect Stats() directly and apply their own threshold logic.
|
||||||
|
func (mb *MessageBus) HealthCheck() (bool, string) {
|
||||||
|
stats := mb.Stats()
|
||||||
|
totalDropped := stats.Inbound.DroppedTotal +
|
||||||
|
stats.Outbound.DroppedTotal +
|
||||||
|
stats.OutboundMedia.DroppedTotal +
|
||||||
|
stats.AudioChunks.DroppedTotal +
|
||||||
|
stats.VoiceControls.DroppedTotal
|
||||||
|
message := fmt.Sprintf(
|
||||||
|
"in=%d/%d out=%d/%d media=%d/%d audio=%d/%d voice=%d/%d dropped=%d",
|
||||||
|
stats.Inbound.Depth,
|
||||||
|
stats.Inbound.Capacity,
|
||||||
|
stats.Outbound.Depth,
|
||||||
|
stats.Outbound.Capacity,
|
||||||
|
stats.OutboundMedia.Depth,
|
||||||
|
stats.OutboundMedia.Capacity,
|
||||||
|
stats.AudioChunks.Depth,
|
||||||
|
stats.AudioChunks.Capacity,
|
||||||
|
stats.VoiceControls.Depth,
|
||||||
|
stats.VoiceControls.Capacity,
|
||||||
|
totalDropped,
|
||||||
|
)
|
||||||
|
|
||||||
|
return true, message
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotStreamStats[T any](ch chan T, stats *streamStats) StreamStats {
|
||||||
|
snapshot := StreamStats{
|
||||||
|
DroppedTotal: stats.dropped.Load(),
|
||||||
|
}
|
||||||
|
if ch != nil {
|
||||||
|
snapshot.Depth = len(ch)
|
||||||
|
snapshot.Capacity = cap(ch)
|
||||||
|
}
|
||||||
|
if unixNano := stats.lastDropped.Load(); unixNano > 0 {
|
||||||
|
snapshot.LastDroppedAt = time.Unix(0, unixNano)
|
||||||
|
}
|
||||||
|
if waitNanos := stats.lastWaitNanos.Load(); waitNanos > 0 {
|
||||||
|
wait := time.Duration(waitNanos)
|
||||||
|
snapshot.LastDropWait = wait.String()
|
||||||
|
snapshot.LastDropWaitMillis = wait.Milliseconds()
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) Close() {
|
func (mb *MessageBus) Close() {
|
||||||
mb.closeOnce.Do(func() {
|
mb.closeOnce.Do(func() {
|
||||||
mb.publishCloseEvent(runtimeevents.KindBusCloseStarted, 0)
|
mb.publishCloseEvent(runtimeevents.KindBusCloseStarted, 0)
|
||||||
// notify all blocked publishers to exit
|
|
||||||
close(mb.done)
|
|
||||||
|
|
||||||
// because every publisher will check mb.closed before acquiring wg
|
mb.publishMu.Lock()
|
||||||
// so we can be sure that new publishers will not be added new messages after this point
|
|
||||||
mb.closed.Store(true)
|
mb.closed.Store(true)
|
||||||
|
close(mb.done)
|
||||||
|
mb.publishMu.Unlock()
|
||||||
|
|
||||||
// wait for all ongoing Publish calls to finish, ensuring all messages have been sent to channels or exited
|
// wait for all ongoing Publish calls to finish, ensuring all messages have been sent to channels or exited
|
||||||
mb.wg.Wait()
|
mb.wg.Wait()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package bus
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -459,6 +460,84 @@ func TestPublishAudioChunkSubscribe(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPublishAudioChunk_BackpressureDropPublishesRuntimeEvent(t *testing.T) {
|
||||||
|
eventBus := runtimeevents.NewBus()
|
||||||
|
defer func() {
|
||||||
|
if err := eventBus.Close(); err != nil {
|
||||||
|
t.Errorf("event bus close failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, eventsCh, err := eventBus.Channel().OfKind(runtimeevents.KindBusMessageDropped).SubscribeChan(
|
||||||
|
t.Context(),
|
||||||
|
runtimeevents.SubscribeOptions{Name: "bus-drop-events", Buffer: 1},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SubscribeChan failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
mb.SetEventPublisher(eventBus)
|
||||||
|
|
||||||
|
for i := range defaultBusBufferSize * 4 {
|
||||||
|
if pubErr := mb.PublishAudioChunk(context.Background(), AudioChunk{
|
||||||
|
SessionID: "voice-1",
|
||||||
|
SpeakerID: "speaker-1",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Channel: "discord",
|
||||||
|
Sequence: uint64(i),
|
||||||
|
Format: "opus",
|
||||||
|
Data: []byte{0x01},
|
||||||
|
}); pubErr != nil {
|
||||||
|
t.Fatalf("fill failed at %d: %v", i, pubErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mb.PublishAudioChunk(context.Background(), AudioChunk{
|
||||||
|
SessionID: "voice-1",
|
||||||
|
SpeakerID: "speaker-1",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Channel: "discord",
|
||||||
|
Sequence: 999,
|
||||||
|
Format: "opus",
|
||||||
|
Data: []byte{0x01},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrBusBackpressure) {
|
||||||
|
t.Fatalf("PublishAudioChunk() error = %v, want %v", err, ErrBusBackpressure)
|
||||||
|
}
|
||||||
|
|
||||||
|
evt := receiveBusRuntimeEvent(t, eventsCh)
|
||||||
|
if evt.Kind != runtimeevents.KindBusMessageDropped ||
|
||||||
|
evt.Source.Name != "audio_chunk" ||
|
||||||
|
evt.Severity != runtimeevents.SeverityWarn {
|
||||||
|
t.Fatalf("drop event = %+v", evt)
|
||||||
|
}
|
||||||
|
if evt.Scope.Channel != "discord" || evt.Scope.ChatID != "chat-1" {
|
||||||
|
t.Fatalf("drop event scope = %+v", evt.Scope)
|
||||||
|
}
|
||||||
|
if evt.Attrs["stream"] != "audio_chunk" ||
|
||||||
|
evt.Attrs["reason"] != "queue_full_timeout" ||
|
||||||
|
evt.Attrs["wait_ms"] != defaultAudioPublishTimeout.Milliseconds() ||
|
||||||
|
evt.Attrs["queue_depth"] != defaultBusBufferSize*4 ||
|
||||||
|
evt.Attrs["queue_capacity"] != defaultBusBufferSize*4 ||
|
||||||
|
evt.Attrs["dropped_total"] != uint64(1) {
|
||||||
|
t.Fatalf("drop event attrs = %#v", evt.Attrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := mb.Stats()
|
||||||
|
if stats.AudioChunks.DroppedTotal != 1 {
|
||||||
|
t.Fatalf("AudioChunks dropped = %d, want 1", stats.AudioChunks.DroppedTotal)
|
||||||
|
}
|
||||||
|
if stats.AudioChunks.Depth != defaultBusBufferSize*4 {
|
||||||
|
t.Fatalf("AudioChunks depth = %d, want %d", stats.AudioChunks.Depth, defaultBusBufferSize*4)
|
||||||
|
}
|
||||||
|
wantWaitMS := defaultAudioPublishTimeout.Milliseconds()
|
||||||
|
if stats.AudioChunks.LastDropWaitMillis != wantWaitMS {
|
||||||
|
t.Fatalf("AudioChunks last wait ms = %d, want %d", stats.AudioChunks.LastDropWaitMillis, wantWaitMS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublishVoiceControlSubscribe(t *testing.T) {
|
func TestPublishVoiceControlSubscribe(t *testing.T) {
|
||||||
mb := NewMessageBus()
|
mb := NewMessageBus()
|
||||||
defer mb.Close()
|
defer mb.Close()
|
||||||
@@ -728,6 +807,74 @@ func TestPublishInbound_FullBuffer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPublishInbound_FullBufferUsesBusBackpressureBudget exercises the generic
|
||||||
|
// publish() backpressure path directly (with a short 20ms timeout) rather than
|
||||||
|
// going through PublishInbound(). This avoids waiting for a long context timeout
|
||||||
|
// and keeps the test fast. Context validation and public-API wiring are covered
|
||||||
|
// by TestPublishInbound_FullBuffer and TestPublishInbound_ContextCancel.
|
||||||
|
func TestPublishInbound_FullBufferUsesBusBackpressureBudget(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ch := make(chan InboundMessage, 1)
|
||||||
|
ch <- InboundMessage{Content: "fill"}
|
||||||
|
|
||||||
|
scope := runtimeevents.Scope{Channel: "test", ChatID: "chat-overflow"}
|
||||||
|
err := publish(context.Background(), mb, ch, InboundMessage{Content: "overflow"}, publishPolicy{
|
||||||
|
stream: "inbound",
|
||||||
|
timeout: 20 * time.Millisecond,
|
||||||
|
}, &mb.inboundStats, scope)
|
||||||
|
if !errors.Is(err, ErrBusBackpressure) {
|
||||||
|
t.Fatalf("publish() error = %v, want %v", err, ErrBusBackpressure)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := mb.Stats()
|
||||||
|
if stats.Inbound.DroppedTotal != 1 {
|
||||||
|
t.Fatalf("Inbound dropped = %d, want 1", stats.Inbound.DroppedTotal)
|
||||||
|
}
|
||||||
|
if stats.Inbound.LastDropWaitMillis != 20 {
|
||||||
|
t.Fatalf("Inbound last wait ms = %d, want 20", stats.Inbound.LastDropWaitMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageBusHealthCheckIncludesQueueDepthAndDrops(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ok, msg := mb.HealthCheck()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("HealthCheck should remain ok for backpressure telemetry")
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
t.Fatal("HealthCheck message should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range cap(mb.audioChunks) {
|
||||||
|
if err := mb.PublishAudioChunk(context.Background(), AudioChunk{
|
||||||
|
Channel: "discord",
|
||||||
|
ChatID: "voice-room",
|
||||||
|
Sequence: uint64(i),
|
||||||
|
Data: []byte("fill"),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("fill audio buffer at %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = mb.PublishAudioChunk(context.Background(), AudioChunk{
|
||||||
|
Channel: "discord",
|
||||||
|
ChatID: "voice-room",
|
||||||
|
Sequence: 999,
|
||||||
|
Data: []byte("overflow"),
|
||||||
|
})
|
||||||
|
|
||||||
|
stats := mb.Stats()
|
||||||
|
if stats.AudioChunks.Depth != cap(mb.audioChunks) {
|
||||||
|
t.Fatalf("audio depth = %d, want %d", stats.AudioChunks.Depth, cap(mb.audioChunks))
|
||||||
|
}
|
||||||
|
if stats.AudioChunks.DroppedTotal != 1 {
|
||||||
|
t.Fatalf("audio dropped = %d, want 1", stats.AudioChunks.DroppedTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCloseIdempotent(t *testing.T) {
|
func TestCloseIdempotent(t *testing.T) {
|
||||||
mb := NewMessageBus()
|
mb := NewMessageBus()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package bus
|
package bus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,6 +15,15 @@ type busClosePayload struct {
|
|||||||
Drained int `json:"drained,omitempty"`
|
Drained int `json:"drained,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type busMessageDroppedPayload struct {
|
||||||
|
Stream string `json:"stream"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
WaitMS int64 `json:"wait_ms"`
|
||||||
|
QueueDepth int `json:"queue_depth"`
|
||||||
|
QueueCap int `json:"queue_capacity"`
|
||||||
|
DroppedTotal uint64 `json:"dropped_total"`
|
||||||
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) publishFailure(stream string, scope runtimeevents.Scope, err error) {
|
func (mb *MessageBus) publishFailure(stream string, scope runtimeevents.Scope, err error) {
|
||||||
if mb == nil || err == nil {
|
if mb == nil || err == nil {
|
||||||
return
|
return
|
||||||
@@ -38,6 +49,46 @@ func (mb *MessageBus) publishFailure(stream string, scope runtimeevents.Scope, e
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (mb *MessageBus) publishDrop(
|
||||||
|
stream string,
|
||||||
|
scope runtimeevents.Scope,
|
||||||
|
reason string,
|
||||||
|
wait time.Duration,
|
||||||
|
queueDepth, queueCap int,
|
||||||
|
droppedTotal uint64,
|
||||||
|
) {
|
||||||
|
if mb == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
publisher, ok := mb.eventPublisher.Load().(EventPublisher)
|
||||||
|
if !ok || publisher == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
publisher.PublishNonBlocking(runtimeevents.Event{
|
||||||
|
Kind: runtimeevents.KindBusMessageDropped,
|
||||||
|
Source: runtimeevents.Source{Component: "bus", Name: stream},
|
||||||
|
Scope: scope,
|
||||||
|
Severity: runtimeevents.SeverityWarn,
|
||||||
|
Payload: busMessageDroppedPayload{
|
||||||
|
Stream: stream,
|
||||||
|
Reason: reason,
|
||||||
|
WaitMS: wait.Milliseconds(),
|
||||||
|
QueueDepth: queueDepth,
|
||||||
|
QueueCap: queueCap,
|
||||||
|
DroppedTotal: droppedTotal,
|
||||||
|
},
|
||||||
|
Attrs: map[string]any{
|
||||||
|
"stream": stream,
|
||||||
|
"reason": reason,
|
||||||
|
"wait_ms": wait.Milliseconds(),
|
||||||
|
"queue_depth": queueDepth,
|
||||||
|
"queue_capacity": queueCap,
|
||||||
|
"dropped_total": droppedTotal,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) publishCloseEvent(kind runtimeevents.Kind, drained int) {
|
func (mb *MessageBus) publishCloseEvent(kind runtimeevents.Kind, drained int) {
|
||||||
if mb == nil {
|
if mb == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
+6
-4
@@ -64,10 +64,12 @@ type OutboundScope struct {
|
|||||||
// ContextUsage describes how much of the model's context window the current
|
// ContextUsage describes how much of the model's context window the current
|
||||||
// session consumes, and how far it is from triggering compression.
|
// session consumes, and how far it is from triggering compression.
|
||||||
type ContextUsage struct {
|
type ContextUsage struct {
|
||||||
UsedTokens int `json:"used_tokens"`
|
UsedTokens int `json:"used_tokens"`
|
||||||
TotalTokens int `json:"total_tokens"` // model context window
|
TotalTokens int `json:"total_tokens"` // model context window
|
||||||
CompressAtTokens int `json:"compress_at_tokens"` // threshold that triggers compression
|
HistoryTokens int `json:"history_tokens"` // history-message tokens only (what maybeSummarize checks)
|
||||||
UsedPercent int `json:"used_percent"` // 0-100
|
CompressAtTokens int `json:"compress_at_tokens"` // hard budget compression threshold (contextWindow - maxTokens)
|
||||||
|
SummarizeAtTokens int `json:"summarize_at_tokens"` // soft summarization trigger (vs history tokens)
|
||||||
|
UsedPercent int `json:"used_percent"` // 0-100, relative to compressAt
|
||||||
}
|
}
|
||||||
|
|
||||||
type OutboundMessage struct {
|
type OutboundMessage struct {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -605,10 +606,11 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
|||||||
scope := channels.BuildMediaScope("discord", m.ChannelID, m.ID)
|
scope := channels.BuildMediaScope("discord", m.ChannelID, m.ID)
|
||||||
|
|
||||||
// Helper to register a local file with the media store
|
// Helper to register a local file with the media store
|
||||||
storeMedia := func(localPath, filename string) string {
|
storeMedia := func(localPath string, attachment *discordgo.MessageAttachment) string {
|
||||||
if store := c.GetMediaStore(); store != nil {
|
if store := c.GetMediaStore(); store != nil {
|
||||||
ref, err := store.Store(localPath, media.MediaMeta{
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
Filename: filename,
|
Filename: attachment.Filename,
|
||||||
|
ContentType: attachment.ContentType,
|
||||||
Source: "discord",
|
Source: "discord",
|
||||||
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
||||||
}, scope)
|
}, scope)
|
||||||
@@ -620,22 +622,16 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, attachment := range m.Attachments {
|
for _, attachment := range m.Attachments {
|
||||||
isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)
|
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
|
||||||
|
if localPath != "" {
|
||||||
if isAudio {
|
mediaPaths = append(mediaPaths, storeMedia(localPath, attachment))
|
||||||
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
|
tag := attachmentMediaTag(attachment.Filename, attachment.ContentType)
|
||||||
if localPath != "" {
|
content = appendContent(content, fmt.Sprintf("[%s: %s]", tag, attachment.Filename))
|
||||||
mediaPaths = append(mediaPaths, storeMedia(localPath, attachment.Filename))
|
|
||||||
content = appendContent(content, fmt.Sprintf("[audio: %s]", attachment.Filename))
|
|
||||||
} else {
|
|
||||||
logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{
|
|
||||||
"url": attachment.URL,
|
|
||||||
"filename": attachment.Filename,
|
|
||||||
})
|
|
||||||
mediaPaths = append(mediaPaths, attachment.URL)
|
|
||||||
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
logger.WarnCF("discord", "Failed to download attachment", map[string]any{
|
||||||
|
"url": attachment.URL,
|
||||||
|
"filename": attachment.Filename,
|
||||||
|
})
|
||||||
mediaPaths = append(mediaPaths, attachment.URL)
|
mediaPaths = append(mediaPaths, attachment.URL)
|
||||||
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
|
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
|
||||||
}
|
}
|
||||||
@@ -748,6 +744,30 @@ func (c *DiscordChannel) downloadAttachment(url, filename string) string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attachmentMediaTag(filename, contentType string) string {
|
||||||
|
ct := strings.ToLower(contentType)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(ct, "image/"):
|
||||||
|
return "image"
|
||||||
|
case strings.HasPrefix(ct, "audio/"), ct == "application/ogg", ct == "application/x-ogg":
|
||||||
|
return "audio"
|
||||||
|
case strings.HasPrefix(ct, "video/"):
|
||||||
|
return "video"
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp":
|
||||||
|
return "image"
|
||||||
|
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma":
|
||||||
|
return "audio"
|
||||||
|
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
|
||||||
|
return "video"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
|
||||||
func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error {
|
func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error {
|
||||||
var proxyFunc func(*http.Request) (*url.URL, error)
|
var proxyFunc func(*http.Request) (*url.URL, error)
|
||||||
if proxyAddr != "" {
|
if proxyAddr != "" {
|
||||||
|
|||||||
@@ -298,9 +298,11 @@ func (c *DiscordChannel) receiveVoice(vc *discordgo.VoiceConnection, guildID str
|
|||||||
Data: p.Opus,
|
Data: p.Opus,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(c.ctx, 100*time.Millisecond)
|
// Pass the parent context directly; the bus applies its own
|
||||||
err := c.bus.PublishAudioChunk(ctx, chunk)
|
// audio drop budget internally.
|
||||||
cancel()
|
// A 100ms caller-level timeout would fire before the bus's
|
||||||
|
// backpressure handling, masking drop counters and events.
|
||||||
|
err := c.bus.PublishAudioChunk(c.ctx, chunk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("discord", "Failed to publish audio chunk", map[string]any{
|
logger.ErrorCF("discord", "Failed to publish audio chunk", map[string]any{
|
||||||
"guild": guildID,
|
"guild": guildID,
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import (
|
|||||||
func ClassifySendError(statusCode int, rawErr error) error {
|
func ClassifySendError(statusCode int, rawErr error) error {
|
||||||
switch {
|
switch {
|
||||||
case statusCode == http.StatusTooManyRequests:
|
case statusCode == http.StatusTooManyRequests:
|
||||||
return fmt.Errorf("%w: %v", ErrRateLimit, rawErr)
|
return fmt.Errorf("%w: %w", ErrRateLimit, rawErr)
|
||||||
case statusCode >= 500:
|
case statusCode >= 500:
|
||||||
return fmt.Errorf("%w: %v", ErrTemporary, rawErr)
|
return fmt.Errorf("%w: %w", ErrTemporary, rawErr)
|
||||||
case statusCode >= 400:
|
case statusCode >= 400:
|
||||||
return fmt.Errorf("%w: %v", ErrSendFailed, rawErr)
|
return fmt.Errorf("%w: %w", ErrSendFailed, rawErr)
|
||||||
default:
|
default:
|
||||||
return rawErr
|
return rawErr
|
||||||
}
|
}
|
||||||
@@ -26,5 +26,5 @@ func ClassifyNetError(err error) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%w: %v", ErrTemporary, err)
|
return fmt.Errorf("%w: %w", ErrTemporary, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ type FeishuChannel struct {
|
|||||||
|
|
||||||
progress *channels.ToolFeedbackAnimator
|
progress *channels.ToolFeedbackAnimator
|
||||||
deleteMessageFn func(context.Context, string, string) error
|
deleteMessageFn func(context.Context, string, string) error
|
||||||
|
sendMediaPartFn func(context.Context, string, bus.MediaPart, media.MediaStore) error
|
||||||
|
sendTextFn func(context.Context, string, string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type cachedMessage struct {
|
type cachedMessage struct {
|
||||||
@@ -78,6 +80,8 @@ func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.M
|
|||||||
client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...),
|
client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...),
|
||||||
}
|
}
|
||||||
ch.deleteMessageFn = ch.deleteMessageAPI
|
ch.deleteMessageFn = ch.deleteMessageAPI
|
||||||
|
ch.sendMediaPartFn = ch.sendMediaPart
|
||||||
|
ch.sendTextFn = ch.sendText
|
||||||
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
|
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
|
||||||
ch.SetOwner(ch)
|
ch.SetOwner(ch)
|
||||||
return ch, nil
|
return ch, nil
|
||||||
@@ -304,7 +308,7 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
|
|||||||
}
|
}
|
||||||
|
|
||||||
req := larkim.NewCreateMessageReqBuilder().
|
req := larkim.NewCreateMessageReqBuilder().
|
||||||
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
|
||||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||||
ReceiveId(chatID).
|
ReceiveId(chatID).
|
||||||
MsgType(larkim.MsgTypeInteractive).
|
MsgType(larkim.MsgTypeInteractive).
|
||||||
@@ -497,8 +501,16 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
|
|||||||
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
caption := firstMediaCaption(msg.Parts)
|
||||||
|
sentAny := false
|
||||||
for _, part := range msg.Parts {
|
for _, part := range msg.Parts {
|
||||||
if err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil {
|
if err := c.sendMediaPartFn(ctx, msg.ChatID, part, store); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sentAny = true
|
||||||
|
}
|
||||||
|
if sentAny && caption != "" {
|
||||||
|
if _, err := c.sendTextFn(ctx, msg.ChatID, caption); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -557,6 +569,15 @@ func (c *FeishuChannel) sendMediaPart(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstMediaCaption(parts []bus.MediaPart) string {
|
||||||
|
for _, part := range parts {
|
||||||
|
if caption := strings.TrimSpace(part.Caption); caption != "" {
|
||||||
|
return caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// --- Inbound message handling ---
|
// --- Inbound message handling ---
|
||||||
|
|
||||||
func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
|
func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
|
||||||
@@ -725,8 +746,8 @@ func (c *FeishuChannel) isBotMentioned(message *larkim.EventMessage) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
knownID, _ := c.botOpenID.Load().(string)
|
knownID, ok := c.botOpenID.Load().(string)
|
||||||
if knownID == "" {
|
if !ok || knownID == "" {
|
||||||
logger.DebugCF("feishu", "Bot open_id unknown, cannot detect @mention", nil)
|
logger.DebugCF("feishu", "Bot open_id unknown, cannot detect @mention", nil)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -992,7 +1013,13 @@ func (c *FeishuChannel) storeResourceFile(
|
|||||||
})
|
})
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
out.Close()
|
if closeErr := out.Close(); closeErr != nil {
|
||||||
|
logger.ErrorCF("feishu", "Failed to close downloaded resource file", map[string]any{
|
||||||
|
"error": closeErr.Error(),
|
||||||
|
})
|
||||||
|
os.Remove(localPath)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
ref, err := store.Store(localPath, media.MediaMeta{
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
@@ -1047,7 +1074,7 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string {
|
|||||||
// sendCard sends an interactive card message to a chat.
|
// sendCard sends an interactive card message to a chat.
|
||||||
func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) (string, error) {
|
func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) (string, error) {
|
||||||
req := larkim.NewCreateMessageReqBuilder().
|
req := larkim.NewCreateMessageReqBuilder().
|
||||||
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
|
||||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||||
ReceiveId(chatID).
|
ReceiveId(chatID).
|
||||||
MsgType(larkim.MsgTypeInteractive).
|
MsgType(larkim.MsgTypeInteractive).
|
||||||
@@ -1080,7 +1107,7 @@ func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) (stri
|
|||||||
content, _ := json.Marshal(map[string]string{"text": text})
|
content, _ := json.Marshal(map[string]string{"text": text})
|
||||||
|
|
||||||
req := larkim.NewCreateMessageReqBuilder().
|
req := larkim.NewCreateMessageReqBuilder().
|
||||||
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
|
||||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||||
ReceiveId(chatID).
|
ReceiveId(chatID).
|
||||||
MsgType(larkim.MsgTypeText).
|
MsgType(larkim.MsgTypeText).
|
||||||
@@ -1134,7 +1161,7 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F
|
|||||||
// Send image message
|
// Send image message
|
||||||
content, _ := json.Marshal(map[string]string{"image_key": imageKey})
|
content, _ := json.Marshal(map[string]string{"image_key": imageKey})
|
||||||
req := larkim.NewCreateMessageReqBuilder().
|
req := larkim.NewCreateMessageReqBuilder().
|
||||||
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
|
||||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||||
ReceiveId(chatID).
|
ReceiveId(chatID).
|
||||||
MsgType(larkim.MsgTypeImage).
|
MsgType(larkim.MsgTypeImage).
|
||||||
@@ -1190,7 +1217,7 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi
|
|||||||
// Send file message
|
// Send file message
|
||||||
content, _ := json.Marshal(map[string]string{"file_key": fileKey})
|
content, _ := json.Marshal(map[string]string{"file_key": fileKey})
|
||||||
req := larkim.NewCreateMessageReqBuilder().
|
req := larkim.NewCreateMessageReqBuilder().
|
||||||
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
|
||||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||||
ReceiveId(chatID).
|
ReceiveId(chatID).
|
||||||
MsgType(larkim.MsgTypeFile).
|
MsgType(larkim.MsgTypeFile).
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
|
|
||||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExtractContent(t *testing.T) {
|
func TestExtractContent(t *testing.T) {
|
||||||
@@ -319,6 +321,43 @@ func TestFinalizeTrackedToolFeedbackMessage_ClearAfterSuccessfulEdit(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendMedia_SendsCaptionFallbackAfterMedia(t *testing.T) {
|
||||||
|
ch := &FeishuChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("feishu", nil, nil, nil),
|
||||||
|
progress: channels.NewToolFeedbackAnimator(nil),
|
||||||
|
}
|
||||||
|
ch.SetRunning(true)
|
||||||
|
ch.SetMediaStore(media.NewFileMediaStore())
|
||||||
|
|
||||||
|
var mediaOrder []string
|
||||||
|
var textCalls []string
|
||||||
|
ch.sendMediaPartFn = func(ctx context.Context, chatID string, part bus.MediaPart, store media.MediaStore) error {
|
||||||
|
mediaOrder = append(mediaOrder, part.Type)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ch.sendTextFn = func(ctx context.Context, chatID, text string) (string, error) {
|
||||||
|
textCalls = append(textCalls, chatID+"|"+text)
|
||||||
|
return "msg-1", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||||
|
ChatID: "oc_123",
|
||||||
|
Parts: []bus.MediaPart{
|
||||||
|
{Type: "image", Caption: "shared caption"},
|
||||||
|
{Type: "file"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendMedia() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(mediaOrder) != 2 {
|
||||||
|
t.Fatalf("media sends = %v, want 2 sends", mediaOrder)
|
||||||
|
}
|
||||||
|
if len(textCalls) != 1 || textCalls[0] != "oc_123|shared caption" {
|
||||||
|
t.Fatalf("textCalls = %v, want [oc_123|shared caption]", textCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) {
|
func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) {
|
||||||
ch := &FeishuChannel{
|
ch := &FeishuChannel{
|
||||||
progress: channels.NewToolFeedbackAnimator(nil),
|
progress: channels.NewToolFeedbackAnimator(nil),
|
||||||
|
|||||||
+41
-1
@@ -13,6 +13,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -169,6 +170,13 @@ func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool {
|
|||||||
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func outboundMessageIsToolCalls(msg bus.OutboundMessage) bool {
|
||||||
|
if len(msg.Context.Raw) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_calls")
|
||||||
|
}
|
||||||
|
|
||||||
func outboundMessageHasAuxiliaryKind(msg bus.OutboundMessage) bool {
|
func outboundMessageHasAuxiliaryKind(msg bus.OutboundMessage) bool {
|
||||||
if len(msg.Context.Raw) == 0 {
|
if len(msg.Context.Raw) == 0 {
|
||||||
return false
|
return false
|
||||||
@@ -379,6 +387,7 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
|
|||||||
}
|
}
|
||||||
|
|
||||||
isToolFeedback := outboundMessageIsToolFeedback(msg)
|
isToolFeedback := outboundMessageIsToolFeedback(msg)
|
||||||
|
isToolCalls := outboundMessageIsToolCalls(msg)
|
||||||
isAuxiliaryMessage := outboundMessageHasAuxiliaryKind(msg)
|
isAuxiliaryMessage := outboundMessageHasAuxiliaryKind(msg)
|
||||||
isFinalMessage := outboundMessageIsFinal(msg)
|
isFinalMessage := outboundMessageIsFinal(msg)
|
||||||
separateToolFeedbackMessages := m.toolFeedbackSeparateMessagesEnabled()
|
separateToolFeedbackMessages := m.toolFeedbackSeparateMessagesEnabled()
|
||||||
@@ -388,7 +397,9 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
|
|||||||
// finalization bypasses the worker queue, so older queued feedback/thoughts
|
// finalization bypasses the worker queue, so older queued feedback/thoughts
|
||||||
// can arrive before the normal final outbound message that cleans up the
|
// can arrive before the normal final outbound message that cleans up the
|
||||||
// marker and placeholder.
|
// marker and placeholder.
|
||||||
if isAuxiliaryMessage {
|
// Note: tool_calls messages must NOT be dropped as they represent new tool
|
||||||
|
// invocations for the current turn that must be delivered to the UI.
|
||||||
|
if isAuxiliaryMessage && !isToolCalls {
|
||||||
if _, loaded := m.streamActive.Load(streamKey); loaded {
|
if _, loaded := m.streamActive.Load(streamKey); loaded {
|
||||||
return nil, true
|
return nil, true
|
||||||
}
|
}
|
||||||
@@ -1269,6 +1280,16 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
|||||||
for _, listener := range m.httpListeners {
|
for _, listener := range m.httpListeners {
|
||||||
ln := listener
|
ln := listener
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.ErrorCF("channels", "HTTP server goroutine panic recovered",
|
||||||
|
map[string]any{
|
||||||
|
"addr": ln.Addr().String(),
|
||||||
|
"panic": fmt.Sprintf("%v", r),
|
||||||
|
"stack": string(debug.Stack()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
|
logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
|
||||||
"addr": ln.Addr().String(),
|
"addr": ln.Addr().String(),
|
||||||
})
|
})
|
||||||
@@ -1282,6 +1303,16 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.ErrorCF("channels", "HTTP server goroutine panic recovered",
|
||||||
|
map[string]any{
|
||||||
|
"addr": m.httpServer.Addr,
|
||||||
|
"panic": fmt.Sprintf("%v", r),
|
||||||
|
"stack": string(debug.Stack()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
|
logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
|
||||||
"addr": m.httpServer.Addr,
|
"addr": m.httpServer.Addr,
|
||||||
})
|
})
|
||||||
@@ -1933,6 +1964,15 @@ func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error {
|
|||||||
// Commit hashes only on full success.
|
// Commit hashes only on full success.
|
||||||
m.channelHashes = list
|
m.channelHashes = list
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.ErrorCF("channels", "channel registration goroutine panic recovered",
|
||||||
|
map[string]any{
|
||||||
|
"panic": fmt.Sprintf("%v", r),
|
||||||
|
"stack": string(debug.Stack()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
for _, f := range deferFuncs {
|
for _, f := range deferFuncs {
|
||||||
f()
|
f()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
@@ -11,17 +12,27 @@ import (
|
|||||||
func toChannelHashes(cfg *config.Config) map[string]string {
|
func toChannelHashes(cfg *config.Config) map[string]string {
|
||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
ch := cfg.Channels
|
ch := cfg.Channels
|
||||||
// should not be error
|
marshal, err := json.Marshal(ch)
|
||||||
marshal, _ := json.Marshal(ch)
|
if err != nil {
|
||||||
|
log.Printf("[manager_channel] failed to marshal channels config: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
var channelConfig map[string]map[string]any
|
var channelConfig map[string]map[string]any
|
||||||
_ = json.Unmarshal(marshal, &channelConfig)
|
if err := json.Unmarshal(marshal, &channelConfig); err != nil {
|
||||||
|
log.Printf("[manager_channel] failed to unmarshal channels config: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
for key, value := range channelConfig {
|
for key, value := range channelConfig {
|
||||||
if !value["enabled"].(bool) {
|
if enabled, ok := value["enabled"].(bool); !ok || !enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
hiddenValues(key, value, ch.Get(key))
|
hiddenValues(key, value, ch.Get(key))
|
||||||
valueBytes, _ := json.Marshal(value)
|
valueBytes, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[manager_channel] failed to marshal channel %s config: %v", key, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
hash := md5.Sum(valueBytes)
|
hash := md5.Sum(valueBytes)
|
||||||
result[key] = hex.EncodeToString(hash[:])
|
result[key] = hex.EncodeToString(hash[:])
|
||||||
}
|
}
|
||||||
@@ -94,7 +105,15 @@ func hiddenValues(key string, value map[string]any, ch *config.Channel) {
|
|||||||
vv := value["webhooks"]
|
vv := value["webhooks"]
|
||||||
webhooks := make(map[string]string)
|
webhooks := make(map[string]string)
|
||||||
if vv != nil {
|
if vv != nil {
|
||||||
webhooks = vv.(map[string]string)
|
if m, ok := vv.(map[string]string); ok {
|
||||||
|
webhooks = m
|
||||||
|
} else if m, ok := vv.(map[string]any); ok {
|
||||||
|
for k, w := range m {
|
||||||
|
if s, ok := w.(string); ok {
|
||||||
|
webhooks[k] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if settings, ok := v.(*config.TeamsWebhookSettings); ok {
|
if settings, ok := v.(*config.TeamsWebhookSettings); ok {
|
||||||
for name, target := range settings.Webhooks {
|
for name, target := range settings.Webhooks {
|
||||||
|
|||||||
@@ -151,3 +151,57 @@ func TestToChannelHashes_RealWorldChannel(t *testing.T) {
|
|||||||
assert.Equal(t, 1, len(h))
|
assert.Equal(t, 1, len(h))
|
||||||
assert.Contains(t, h, "telegram")
|
assert.Contains(t, h, "telegram")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToChannelHashes_MissingEnabledKey(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Channels["test"] = &config.Channel{
|
||||||
|
Settings: config.RawNode(`{"key":"value"}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not panic — the ok check safely handles the missing/false case
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
_ = toChannelHashes(cfg)
|
||||||
|
})
|
||||||
|
h := toChannelHashes(cfg)
|
||||||
|
assert.Equal(t, 0, len(h), "channel with Enabled=false (default) skipped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToChannelHashes_EnabledNotBool(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Channels["test"] = &config.Channel{
|
||||||
|
Enabled: false,
|
||||||
|
Settings: config.RawNode(`{"enabled":"yes","boolField":true}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not panic — string "enabled" won't match bool assertion, ok=false
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
_ = toChannelHashes(cfg)
|
||||||
|
})
|
||||||
|
h := toChannelHashes(cfg)
|
||||||
|
assert.Equal(t, 0, len(h), "string enabled not treated as true")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToChannelHashes_TeamsWebhookWithWebhooks(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
// teams_webhook with configured webhooks — this is the real-world
|
||||||
|
// scenario where the map type from JSON unmarshal (map[string]any)
|
||||||
|
// would cause a panic on the old unchecked vv.(map[string]string)
|
||||||
|
settings, _ := json.Marshal(map[string]any{
|
||||||
|
"enabled": true,
|
||||||
|
"webhooks": map[string]any{
|
||||||
|
"hook1": "https://example.com/webhook",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
cfg.Channels["teams_webhook"] = &config.Channel{
|
||||||
|
Enabled: true,
|
||||||
|
Type: config.ChannelTeamsWebHook,
|
||||||
|
Settings: config.RawNode(settings),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
_ = toChannelHashes(cfg)
|
||||||
|
})
|
||||||
|
h := toChannelHashes(cfg)
|
||||||
|
assert.Equal(t, 1, len(h))
|
||||||
|
assert.Contains(t, h, "teams_webhook")
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
type OneBotChannel struct {
|
type OneBotChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
config *config.OneBotSettings
|
config *config.OneBotSettings
|
||||||
|
downloadFn func(urlStr, filename string) string
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -194,7 +195,7 @@ func (c *OneBotChannel) connect() error {
|
|||||||
|
|
||||||
conn, resp, err := dialer.Dial(c.config.WSUrl, header)
|
conn, resp, err := dialer.Dial(c.config.WSUrl, header)
|
||||||
if resp != nil {
|
if resp != nil {
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -795,9 +796,7 @@ func (c *OneBotChannel) parseMessageSegments(
|
|||||||
} else if n, ok := data["name"].(string); ok && n != "" {
|
} else if n, ok := data["name"].(string); ok && n != "" {
|
||||||
filename = n
|
filename = n
|
||||||
}
|
}
|
||||||
localPath := utils.DownloadFile(url, filename, utils.DownloadOptions{
|
localPath := c.downloadInboundFile(url, filename)
|
||||||
LoggerPrefix: "onebot",
|
|
||||||
})
|
|
||||||
if localPath != "" {
|
if localPath != "" {
|
||||||
mediaRefs = append(mediaRefs, storeFile(localPath, filename))
|
mediaRefs = append(mediaRefs, storeFile(localPath, filename))
|
||||||
textParts = append(textParts, fmt.Sprintf("[%s]", segType))
|
textParts = append(textParts, fmt.Sprintf("[%s]", segType))
|
||||||
@@ -809,9 +808,7 @@ func (c *OneBotChannel) parseMessageSegments(
|
|||||||
if data != nil {
|
if data != nil {
|
||||||
url, _ := data["url"].(string)
|
url, _ := data["url"].(string)
|
||||||
if url != "" {
|
if url != "" {
|
||||||
localPath := utils.DownloadFile(url, "voice.amr", utils.DownloadOptions{
|
localPath := c.downloadInboundFile(url, "voice.amr")
|
||||||
LoggerPrefix: "onebot",
|
|
||||||
})
|
|
||||||
if localPath != "" {
|
if localPath != "" {
|
||||||
textParts = append(textParts, "[voice]")
|
textParts = append(textParts, "[voice]")
|
||||||
mediaRefs = append(mediaRefs, storeFile(localPath, "voice.amr"))
|
mediaRefs = append(mediaRefs, storeFile(localPath, "voice.amr"))
|
||||||
@@ -847,6 +844,16 @@ func (c *OneBotChannel) parseMessageSegments(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) downloadInboundFile(urlStr, filename string) string {
|
||||||
|
if c.downloadFn != nil {
|
||||||
|
return c.downloadFn(urlStr, filename)
|
||||||
|
}
|
||||||
|
return utils.DownloadFile(urlStr, filename, utils.DownloadOptions{
|
||||||
|
LoggerPrefix: "onebot",
|
||||||
|
BlockPrivateTargets: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
||||||
switch raw.PostType {
|
switch raw.PostType {
|
||||||
case "message":
|
case "message":
|
||||||
@@ -995,7 +1002,6 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
|||||||
|
|
||||||
senderID := strconv.FormatInt(userID, 10)
|
senderID := strconv.FormatInt(userID, 10)
|
||||||
var chatID string
|
var chatID string
|
||||||
var contextChatID string
|
|
||||||
var contextChatType string
|
var contextChatType string
|
||||||
|
|
||||||
metadata := map[string]string{}
|
metadata := map[string]string{}
|
||||||
@@ -1007,13 +1013,11 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
|||||||
switch raw.MessageType {
|
switch raw.MessageType {
|
||||||
case "private":
|
case "private":
|
||||||
chatID = "private:" + senderID
|
chatID = "private:" + senderID
|
||||||
contextChatID = senderID
|
|
||||||
contextChatType = "direct"
|
contextChatType = "direct"
|
||||||
|
|
||||||
case "group":
|
case "group":
|
||||||
groupIDStr := strconv.FormatInt(groupID, 10)
|
groupIDStr := strconv.FormatInt(groupID, 10)
|
||||||
chatID = "group:" + groupIDStr
|
chatID = "group:" + groupIDStr
|
||||||
contextChatID = groupIDStr
|
|
||||||
contextChatType = "group"
|
contextChatType = "group"
|
||||||
metadata["group_id"] = groupIDStr
|
metadata["group_id"] = groupIDStr
|
||||||
|
|
||||||
@@ -1080,7 +1084,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
|||||||
|
|
||||||
inboundCtx := bus.InboundContext{
|
inboundCtx := bus.InboundContext{
|
||||||
Channel: c.Name(),
|
Channel: c.Name(),
|
||||||
ChatID: contextChatID,
|
ChatID: chatID,
|
||||||
ChatType: contextChatType,
|
ChatType: contextChatType,
|
||||||
SenderID: senderID,
|
SenderID: senderID,
|
||||||
MessageID: messageID,
|
MessageID: messageID,
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package onebot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseMessageSegments_BlocksLoopbackInboundMediaURL(t *testing.T) {
|
||||||
|
ch := &OneBotChannel{}
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
|
||||||
|
raw := json.RawMessage(`[
|
||||||
|
{"type":"text","data":{"text":"see attachment"}},
|
||||||
|
{"type":"image","data":{"url":"http://127.0.0.1:8080/evil.png","file":"evil.png"}}
|
||||||
|
]`)
|
||||||
|
|
||||||
|
result := ch.parseMessageSegments(raw, 0, store, "onebot:test:msg1")
|
||||||
|
|
||||||
|
if got := result.Text; got != "see attachment" {
|
||||||
|
t.Fatalf("Text = %q, want %q", got, "see attachment")
|
||||||
|
}
|
||||||
|
if len(result.Media) != 0 {
|
||||||
|
t.Fatalf("Media count = %d, want 0", len(result.Media))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMessageSegments_BlocksInboundMediaRedirectToLoopback(t *testing.T) {
|
||||||
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("secret"))
|
||||||
|
}))
|
||||||
|
defer target.Close()
|
||||||
|
|
||||||
|
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got, want := r.URL.String(), "http://example.com/evil.png"; got != want {
|
||||||
|
t.Fatalf("proxy request URL = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, target.URL, http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer proxy.Close()
|
||||||
|
|
||||||
|
t.Setenv("HTTP_PROXY", proxy.URL)
|
||||||
|
t.Setenv("http_proxy", proxy.URL)
|
||||||
|
t.Setenv("HTTPS_PROXY", "")
|
||||||
|
t.Setenv("https_proxy", "")
|
||||||
|
t.Setenv("ALL_PROXY", "")
|
||||||
|
t.Setenv("all_proxy", "")
|
||||||
|
t.Setenv("NO_PROXY", "")
|
||||||
|
t.Setenv("no_proxy", "")
|
||||||
|
|
||||||
|
ch := &OneBotChannel{}
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
|
||||||
|
raw := json.RawMessage(`[
|
||||||
|
{"type":"text","data":{"text":"see attachment"}},
|
||||||
|
{"type":"image","data":{"url":"http://example.com/evil.png","file":"evil.png"}}
|
||||||
|
]`)
|
||||||
|
|
||||||
|
result := ch.parseMessageSegments(raw, 0, store, "onebot:test:msg-redirect")
|
||||||
|
|
||||||
|
if got := result.Text; got != "see attachment" {
|
||||||
|
t.Fatalf("Text = %q, want %q", got, "see attachment")
|
||||||
|
}
|
||||||
|
if len(result.Media) != 0 {
|
||||||
|
t.Fatalf("Media count = %d, want 0", len(result.Media))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMessageSegments_StoresDownloadedMediaRef(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
localPath := filepath.Join(tmpDir, "image.png")
|
||||||
|
if err := os.WriteFile(localPath, []byte("fake-image"), 0o600); err != nil {
|
||||||
|
t.Fatalf("os.WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := &OneBotChannel{
|
||||||
|
downloadFn: func(urlStr, filename string) string {
|
||||||
|
if urlStr != "https://cdn.example.com/image.png" {
|
||||||
|
t.Fatalf("download url = %q, want %q", urlStr, "https://cdn.example.com/image.png")
|
||||||
|
}
|
||||||
|
if filename != "image.png" {
|
||||||
|
t.Fatalf("download filename = %q, want %q", filename, "image.png")
|
||||||
|
}
|
||||||
|
return localPath
|
||||||
|
},
|
||||||
|
}
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
|
||||||
|
raw := json.RawMessage(`[
|
||||||
|
{"type":"text","data":{"text":"see attachment"}},
|
||||||
|
{"type":"image","data":{"url":"https://cdn.example.com/image.png","file":"image.png"}}
|
||||||
|
]`)
|
||||||
|
|
||||||
|
result := ch.parseMessageSegments(raw, 0, store, "onebot:test:msg2")
|
||||||
|
|
||||||
|
if got := result.Text; got != "see attachment[image]" {
|
||||||
|
t.Fatalf("Text = %q, want %q", got, "see attachment[image]")
|
||||||
|
}
|
||||||
|
if len(result.Media) != 1 {
|
||||||
|
t.Fatalf("Media count = %d, want 1", len(result.Media))
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(result.Media[0], "media://") {
|
||||||
|
t.Fatalf("media ref = %q, want media:// prefix", result.Media[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedPath, meta, err := store.ResolveWithMeta(result.Media[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveWithMeta() error = %v", err)
|
||||||
|
}
|
||||||
|
if resolvedPath != localPath {
|
||||||
|
t.Fatalf("resolved path = %q, want %q", resolvedPath, localPath)
|
||||||
|
}
|
||||||
|
if meta.Source != "onebot" {
|
||||||
|
t.Fatalf("meta.Source = %q, want %q", meta.Source, "onebot")
|
||||||
|
}
|
||||||
|
if meta.Filename != "image.png" {
|
||||||
|
t.Fatalf("meta.Filename = %q, want %q", meta.Filename, "image.png")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ func (c *PicoClientChannel) dial() error {
|
|||||||
|
|
||||||
ws, resp, err := websocket.DefaultDialer.DialContext(c.ctx, c.config.URL, header)
|
ws, resp, err := websocket.DefaultDialer.DialContext(c.ctx, c.config.URL, header)
|
||||||
if resp != nil && resp.Body != nil {
|
if resp != nil && resp.Body != nil {
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1394,10 +1394,12 @@ func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
payload["context_usage"] = map[string]any{
|
payload["context_usage"] = map[string]any{
|
||||||
"used_tokens": u.UsedTokens,
|
"used_tokens": u.UsedTokens,
|
||||||
"total_tokens": u.TotalTokens,
|
"total_tokens": u.TotalTokens,
|
||||||
"compress_at_tokens": u.CompressAtTokens,
|
"history_tokens": u.HistoryTokens,
|
||||||
"used_percent": u.UsedPercent,
|
"compress_at_tokens": u.CompressAtTokens,
|
||||||
|
"summarize_at_tokens": u.SummarizeAtTokens,
|
||||||
|
"used_percent": u.UsedPercent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -602,10 +602,12 @@ func TestBeginStream_FinalizeIncludesContextUsage(t *testing.T) {
|
|||||||
t.Fatal("streamer should support FinalizeWithContext")
|
t.Fatal("streamer should support FinalizeWithContext")
|
||||||
}
|
}
|
||||||
if err := contextStreamer.FinalizeWithContext(context.Background(), "final", &bus.ContextUsage{
|
if err := contextStreamer.FinalizeWithContext(context.Background(), "final", &bus.ContextUsage{
|
||||||
UsedTokens: 10,
|
UsedTokens: 10,
|
||||||
TotalTokens: 100,
|
TotalTokens: 100,
|
||||||
CompressAtTokens: 80,
|
HistoryTokens: 5,
|
||||||
UsedPercent: 10,
|
CompressAtTokens: 80,
|
||||||
|
SummarizeAtTokens: 60,
|
||||||
|
UsedPercent: 10,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("FinalizeWithContext() error = %v", err)
|
t.Fatalf("FinalizeWithContext() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -627,6 +629,12 @@ func TestBeginStream_FinalizeIncludesContextUsage(t *testing.T) {
|
|||||||
if got := rawUsage["used_tokens"]; got != float64(10) {
|
if got := rawUsage["used_tokens"]; got != float64(10) {
|
||||||
t.Fatalf("used_tokens = %#v, want 10", got)
|
t.Fatalf("used_tokens = %#v, want 10", got)
|
||||||
}
|
}
|
||||||
|
if got := rawUsage["history_tokens"]; got != float64(5) {
|
||||||
|
t.Fatalf("history_tokens = %#v, want 5", got)
|
||||||
|
}
|
||||||
|
if got := rawUsage["summarize_at_tokens"]; got != float64(60) {
|
||||||
|
t.Fatalf("summarize_at_tokens = %#v, want 60", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateAndAddConnection_RespectsMaxConnectionsConcurrently(t *testing.T) {
|
func TestCreateAndAddConnection_RespectsMaxConnectionsConcurrently(t *testing.T) {
|
||||||
@@ -835,6 +843,75 @@ func TestSendMedia_DismissesTrackedToolFeedbackMessage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendMedia_IncludesCaptionAndAttachmentsInSinglePayload(t *testing.T) {
|
||||||
|
ch := newTestPicoChannel(t)
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
ch.SetMediaStore(store)
|
||||||
|
|
||||||
|
if err := ch.Start(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Start() error = %v", err)
|
||||||
|
}
|
||||||
|
defer ch.Stop(context.Background())
|
||||||
|
|
||||||
|
clientConn, received, cleanup := newTestPicoWebSocket(t)
|
||||||
|
defer cleanup()
|
||||||
|
ch.addConnForTest(&picoConn{id: "conn-1", conn: clientConn, sessionID: "sess-1"})
|
||||||
|
|
||||||
|
localPath := filepath.Join(t.TempDir(), "photo.png")
|
||||||
|
if err := os.WriteFile(localPath, []byte("png-body"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
|
Filename: "photo.png",
|
||||||
|
ContentType: "image/png",
|
||||||
|
}, "test-scope")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Store() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||||
|
ChatID: "pico:sess-1",
|
||||||
|
Parts: []bus.MediaPart{{
|
||||||
|
Ref: ref,
|
||||||
|
Type: "image",
|
||||||
|
Filename: "photo.png",
|
||||||
|
ContentType: "image/png",
|
||||||
|
Caption: "recipe translation",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendMedia() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case msg := <-received:
|
||||||
|
if msg.Type != TypeMessageCreate {
|
||||||
|
t.Fatalf("message type = %q, want %q", msg.Type, TypeMessageCreate)
|
||||||
|
}
|
||||||
|
payload := msg.Payload
|
||||||
|
if got := payload[PayloadKeyContent]; got != "recipe translation" {
|
||||||
|
t.Fatalf("content = %#v, want %q", got, "recipe translation")
|
||||||
|
}
|
||||||
|
rawAttachments, ok := payload["attachments"].([]any)
|
||||||
|
if !ok || len(rawAttachments) != 1 {
|
||||||
|
t.Fatalf("attachments = %#v, want 1 attachment", payload["attachments"])
|
||||||
|
}
|
||||||
|
attachment, ok := rawAttachments[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("attachment = %#v, want map", rawAttachments[0])
|
||||||
|
}
|
||||||
|
if got := attachment["type"]; got != "image" {
|
||||||
|
t.Fatalf("attachment type = %#v, want image", got)
|
||||||
|
}
|
||||||
|
if got := attachment["filename"]; got != "photo.png" {
|
||||||
|
t.Fatalf("attachment filename = %#v, want photo.png", got)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("expected media payload to be delivered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPicoDownloadURLForRef(t *testing.T) {
|
func TestPicoDownloadURLForRef(t *testing.T) {
|
||||||
got, err := picoDownloadURLForRef("media://attachment-1")
|
got, err := picoDownloadURLForRef("media://attachment-1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ type SlackChannel struct {
|
|||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
pendingAcks sync.Map
|
pendingAcks sync.Map
|
||||||
|
uploadFileFn func(context.Context, slack.UploadFileParameters) error
|
||||||
|
postTextFn func(context.Context, string, string, string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type slackMessageRef struct {
|
type slackMessageRef struct {
|
||||||
@@ -63,6 +65,18 @@ func NewSlackChannel(
|
|||||||
config: cfg,
|
config: cfg,
|
||||||
api: api,
|
api: api,
|
||||||
socketClient: socketClient,
|
socketClient: socketClient,
|
||||||
|
uploadFileFn: func(ctx context.Context, params slack.UploadFileParameters) error {
|
||||||
|
_, err := api.UploadFileContext(ctx, params)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
postTextFn: func(ctx context.Context, channelID, threadTS, text string) error {
|
||||||
|
opts := []slack.MsgOption{slack.MsgOptionText(text, false)}
|
||||||
|
if threadTS != "" {
|
||||||
|
opts = append(opts, slack.MsgOptionTS(threadTS))
|
||||||
|
}
|
||||||
|
_, _, err := api.PostMessageContext(ctx, channelID, opts...)
|
||||||
|
return err
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +154,10 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]str
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ref, ok := c.pendingAcks.LoadAndDelete(deliveryChatID); ok {
|
if ref, ok := c.pendingAcks.LoadAndDelete(deliveryChatID); ok {
|
||||||
msgRef := ref.(slackMessageRef)
|
msgRef, ok := ref.(slackMessageRef)
|
||||||
|
if !ok {
|
||||||
|
return []string{ts}, nil
|
||||||
|
}
|
||||||
c.api.AddReaction("white_check_mark", slack.ItemRef{
|
c.api.AddReaction("white_check_mark", slack.ItemRef{
|
||||||
Channel: msgRef.ChannelID,
|
Channel: msgRef.ChannelID,
|
||||||
Timestamp: msgRef.Timestamp,
|
Timestamp: msgRef.Timestamp,
|
||||||
@@ -171,6 +188,8 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
|
|||||||
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
caption := slackFirstMediaCaption(msg.Parts)
|
||||||
|
sentAny := false
|
||||||
for _, part := range msg.Parts {
|
for _, part := range msg.Parts {
|
||||||
localPath, err := store.Resolve(part.Ref)
|
localPath, err := store.Resolve(part.Ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -191,7 +210,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
|
|||||||
title = filename
|
title = filename
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.api.UploadFileContext(ctx, slack.UploadFileParameters{
|
err = c.uploadFileFn(ctx, slack.UploadFileParameters{
|
||||||
Channel: channelID,
|
Channel: channelID,
|
||||||
ThreadTimestamp: threadTS,
|
ThreadTimestamp: threadTS,
|
||||||
File: localPath,
|
File: localPath,
|
||||||
@@ -205,6 +224,13 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
|
|||||||
})
|
})
|
||||||
return nil, fmt.Errorf("slack send media: %w", channels.ErrTemporary)
|
return nil, fmt.Errorf("slack send media: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
sentAny = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if sentAny && caption != "" {
|
||||||
|
if err := c.postTextFn(ctx, channelID, threadTS, caption); err != nil {
|
||||||
|
return nil, fmt.Errorf("slack send media caption fallback: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadFile does not expose the posted message timestamp in its
|
// UploadFile does not expose the posted message timestamp in its
|
||||||
@@ -212,6 +238,15 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func slackFirstMediaCaption(parts []bus.MediaPart) string {
|
||||||
|
for _, part := range parts {
|
||||||
|
if caption := strings.TrimSpace(part.Caption); caption != "" {
|
||||||
|
return caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// ReactToMessage implements channels.ReactionCapable.
|
// ReactToMessage implements channels.ReactionCapable.
|
||||||
// It adds an "eyes" (👀) reaction to the inbound message and returns an undo function
|
// It adds an "eyes" (👀) reaction to the inbound message and returns an undo function
|
||||||
// that removes the reaction.
|
// that removes the reaction.
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
package slack
|
package slack
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
slacksdk "github.com/slack-go/slack"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseSlackChatID(t *testing.T) {
|
func TestParseSlackChatID(t *testing.T) {
|
||||||
@@ -184,3 +191,74 @@ func TestSlackChannelIsAllowed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendMedia_SendsCaptionFallbackAfterUploads(t *testing.T) {
|
||||||
|
ch := &SlackChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("slack", nil, nil, nil),
|
||||||
|
}
|
||||||
|
ch.SetRunning(true)
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
ch.SetMediaStore(store)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
localPath := filepath.Join(tmpDir, "report.txt")
|
||||||
|
if err := os.WriteFile(localPath, []byte("attachment body"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
|
Filename: "report.txt",
|
||||||
|
ContentType: "text/plain",
|
||||||
|
}, "test-scope")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Store() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var uploaded []slackUploadRecord
|
||||||
|
var posted []string
|
||||||
|
ch.uploadFileFn = func(ctx context.Context, params slacksdk.UploadFileParameters) error {
|
||||||
|
uploaded = append(uploaded, slackUploadRecord{
|
||||||
|
Channel: params.Channel,
|
||||||
|
Thread: params.ThreadTimestamp,
|
||||||
|
File: params.File,
|
||||||
|
Name: params.Filename,
|
||||||
|
Title: params.Title,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ch.postTextFn = func(ctx context.Context, channelID, threadTS, text string) error {
|
||||||
|
posted = append(posted, channelID+"|"+threadTS+"|"+text)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||||
|
ChatID: "C123456/1234567890.123456",
|
||||||
|
Parts: []bus.MediaPart{{
|
||||||
|
Ref: ref,
|
||||||
|
Type: "file",
|
||||||
|
Filename: "report.txt",
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Caption: "shared caption",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendMedia() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(uploaded) != 1 {
|
||||||
|
t.Fatalf("uploads = %v, want 1 upload", uploaded)
|
||||||
|
}
|
||||||
|
if uploaded[0].Title != "shared caption" {
|
||||||
|
t.Fatalf("upload title = %q, want shared caption", uploaded[0].Title)
|
||||||
|
}
|
||||||
|
if len(posted) != 1 || posted[0] != "C123456|1234567890.123456|shared caption" {
|
||||||
|
t.Fatalf("posted = %v, want fallback text in same thread", posted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type slackUploadRecord struct {
|
||||||
|
Channel string
|
||||||
|
Thread string
|
||||||
|
File string
|
||||||
|
Name string
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ var (
|
|||||||
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultMediaGroupDelay = 500 * time.Millisecond
|
const (
|
||||||
|
defaultMediaGroupDelay = 500 * time.Millisecond
|
||||||
|
telegramCaptionLimit = 1024
|
||||||
|
)
|
||||||
|
|
||||||
type TelegramChannel struct {
|
type TelegramChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
@@ -639,6 +642,34 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
|||||||
}
|
}
|
||||||
|
|
||||||
var messageIDs []string
|
var messageIDs []string
|
||||||
|
leadingCaption := telegramLeadingCaption(msg.Parts)
|
||||||
|
if len([]rune(leadingCaption)) > telegramCaptionLimit {
|
||||||
|
leadingIDs, leadingErr := c.sendCaptionText(ctx, chatID, threadID, leadingCaption)
|
||||||
|
if leadingErr != nil {
|
||||||
|
return nil, leadingErr
|
||||||
|
}
|
||||||
|
messageIDs = append(messageIDs, leadingIDs...)
|
||||||
|
msg = telegramClearMediaCaptions(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.Parts) > 1 && telegramCanSendMediaGroup(msg.Parts) {
|
||||||
|
groupIDs, err := c.sendImageMediaGroups(ctx, chatID, threadID, store, msg.Parts)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("telegram", "Failed to send media group", map[string]any{
|
||||||
|
"count": len(msg.Parts),
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("telegram send media group: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
if len(groupIDs) > 0 {
|
||||||
|
messageIDs = append(messageIDs, groupIDs...)
|
||||||
|
if hasTrackedMsg {
|
||||||
|
c.dismissTrackedToolFeedbackMessage(ctx, trackedChatID, trackedMsgID)
|
||||||
|
}
|
||||||
|
return messageIDs, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, part := range msg.Parts {
|
for _, part := range msg.Parts {
|
||||||
localPath, err := store.Resolve(part.Ref)
|
localPath, err := store.Resolve(part.Ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -742,6 +773,154 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
|||||||
return messageIDs, nil
|
return messageIDs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func telegramCanSendMediaGroup(parts []bus.MediaPart) bool {
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, part := range parts {
|
||||||
|
if part.Type != "image" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TelegramChannel) sendImageMediaGroups(
|
||||||
|
ctx context.Context,
|
||||||
|
chatID int64,
|
||||||
|
threadID int,
|
||||||
|
store media.MediaStore,
|
||||||
|
parts []bus.MediaPart,
|
||||||
|
) ([]string, error) {
|
||||||
|
const maxGroupSize = 10
|
||||||
|
|
||||||
|
messageIDs := make([]string, 0, len(parts))
|
||||||
|
for start := 0; start < len(parts); start += maxGroupSize {
|
||||||
|
end := start + maxGroupSize
|
||||||
|
if end > len(parts) {
|
||||||
|
end = len(parts)
|
||||||
|
}
|
||||||
|
groupIDs, err := c.sendSingleImageMediaGroup(ctx, chatID, threadID, store, parts[start:end])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
messageIDs = append(messageIDs, groupIDs...)
|
||||||
|
}
|
||||||
|
return messageIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TelegramChannel) sendSingleImageMediaGroup(
|
||||||
|
ctx context.Context,
|
||||||
|
chatID int64,
|
||||||
|
threadID int,
|
||||||
|
store media.MediaStore,
|
||||||
|
parts []bus.MediaPart,
|
||||||
|
) ([]string, error) {
|
||||||
|
opened := make([]*os.File, 0, len(parts))
|
||||||
|
defer func() {
|
||||||
|
for _, file := range opened {
|
||||||
|
file.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
inputMedia := make([]telego.InputMedia, 0, len(parts))
|
||||||
|
for i, part := range parts {
|
||||||
|
localPath, err := store.Resolve(part.Ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("telegram", "Failed to resolve media ref for media group", map[string]any{
|
||||||
|
"ref": part.Ref,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("telegram", "Failed to open media file for media group", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
opened = append(opened, file)
|
||||||
|
|
||||||
|
mediaItem := &telego.InputMediaPhoto{
|
||||||
|
Type: telego.MediaTypePhoto,
|
||||||
|
Media: telego.InputFile{File: file},
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
mediaItem.Caption = part.Caption
|
||||||
|
}
|
||||||
|
inputMedia = append(inputMedia, mediaItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := c.bot.SendMediaGroup(ctx, &telego.SendMediaGroupParams{
|
||||||
|
ChatID: tu.ID(chatID),
|
||||||
|
MessageThreadID: threadID,
|
||||||
|
Media: inputMedia,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
messageIDs := make([]string, 0, len(results))
|
||||||
|
for _, result := range results {
|
||||||
|
messageIDs = append(messageIDs, strconv.Itoa(result.MessageID))
|
||||||
|
}
|
||||||
|
return messageIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TelegramChannel) sendCaptionText(
|
||||||
|
ctx context.Context,
|
||||||
|
chatID int64,
|
||||||
|
threadID int,
|
||||||
|
text string,
|
||||||
|
) ([]string, error) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
chunks := channels.SplitMessage(text, c.MaxMessageLength())
|
||||||
|
messageIDs := make([]string, 0, len(chunks))
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
chunk = strings.TrimSpace(chunk)
|
||||||
|
if chunk == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msgID, err := c.sendChunk(ctx, sendChunkParams{
|
||||||
|
chatID: chatID,
|
||||||
|
threadID: threadID,
|
||||||
|
content: chunk,
|
||||||
|
mdFallback: chunk,
|
||||||
|
useMarkdownV2: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
messageIDs = append(messageIDs, msgID)
|
||||||
|
}
|
||||||
|
return messageIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func telegramLeadingCaption(parts []bus.MediaPart) string {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(parts[0].Caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
func telegramClearMediaCaptions(msg bus.OutboundMediaMessage) bus.OutboundMediaMessage {
|
||||||
|
if len(msg.Parts) == 0 {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
cloned := msg
|
||||||
|
cloned.Parts = append([]bus.MediaPart(nil), msg.Parts...)
|
||||||
|
for i := range cloned.Parts {
|
||||||
|
cloned.Parts[i].Caption = ""
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
|
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
|
||||||
if message != nil && strings.TrimSpace(message.MediaGroupID) != "" {
|
if message != nil && strings.TrimSpace(message.MediaGroupID) != "" {
|
||||||
return c.bufferMediaGroupMessage(ctx, message)
|
return c.bufferMediaGroupMessage(ctx, message)
|
||||||
@@ -1006,7 +1185,7 @@ func (c *TelegramChannel) handleMessages(ctx context.Context, messages []*telego
|
|||||||
|
|
||||||
inboundCtx := bus.InboundContext{
|
inboundCtx := bus.InboundContext{
|
||||||
Channel: c.Name(),
|
Channel: c.Name(),
|
||||||
ChatID: fmt.Sprintf("%d", chatID),
|
ChatID: compositeChatID,
|
||||||
ChatType: peerKind,
|
ChatType: peerKind,
|
||||||
SenderID: platformID,
|
SenderID: platformID,
|
||||||
MessageID: messageID,
|
MessageID: messageID,
|
||||||
@@ -1048,6 +1227,13 @@ func (c *TelegramChannel) collectTelegramMessageParts(
|
|||||||
if caption := strings.TrimSpace(msg.Caption); caption != "" {
|
if caption := strings.TrimSpace(msg.Caption); caption != "" {
|
||||||
parts.content = append(parts.content, caption)
|
parts.content = append(parts.content, caption)
|
||||||
}
|
}
|
||||||
|
if msg.Location != nil {
|
||||||
|
parts.content = append(parts.content, fmt.Sprintf(
|
||||||
|
"[User location: lat=%.6f, lng=%.6f]",
|
||||||
|
msg.Location.Latitude,
|
||||||
|
msg.Location.Longitude,
|
||||||
|
))
|
||||||
|
}
|
||||||
if len(msg.Photo) > 0 {
|
if len(msg.Photo) > 0 {
|
||||||
photo := msg.Photo[len(msg.Photo)-1]
|
photo := msg.Photo[len(msg.Photo)-1]
|
||||||
photoPath := c.downloadPhoto(ctx, photo.FileID)
|
photoPath := c.downloadPhoto(ctx, photo.FileID)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -110,6 +112,17 @@ func successResponseWithMessageID(t *testing.T, messageID int) *ta.Response {
|
|||||||
return &ta.Response{Ok: true, Result: b}
|
return &ta.Response{Ok: true, Result: b}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func successMediaGroupResponse(t *testing.T, messageIDs ...int) *ta.Response {
|
||||||
|
t.Helper()
|
||||||
|
messages := make([]telego.Message, 0, len(messageIDs))
|
||||||
|
for _, messageID := range messageIDs {
|
||||||
|
messages = append(messages, telego.Message{MessageID: messageID})
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(messages)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return &ta.Response{Ok: true, Result: b}
|
||||||
|
}
|
||||||
|
|
||||||
func successUserResponse(t *testing.T, user *telego.User) *ta.Response {
|
func successUserResponse(t *testing.T, user *telego.User) *ta.Response {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
b, err := json.Marshal(user)
|
b, err := json.Marshal(user)
|
||||||
@@ -202,6 +215,33 @@ func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) {
|
|||||||
assert.Equal(t, "caption", constructor.calls[1].Parameters["caption"])
|
assert.Equal(t, "caption", constructor.calls[1].Parameters["caption"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDownloadFileWithInfo_AllowsLocalConfiguredBaseURL(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got, want := r.URL.Path, "/file/bot"+testToken+"/photos/image"; got != want {
|
||||||
|
t.Fatalf("request path = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("telegram-local-bot-api"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ch, err := NewTelegramChannel(
|
||||||
|
&config.Channel{Type: config.ChannelTelegram, Enabled: true},
|
||||||
|
&config.TelegramSettings{
|
||||||
|
Token: *config.NewSecureString(testToken),
|
||||||
|
BaseURL: server.URL,
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
path := ch.downloadFileWithInfo(&telego.File{FilePath: "photos/image"}, "")
|
||||||
|
if path == "" {
|
||||||
|
t.Fatal("expected local base_url download to succeed")
|
||||||
|
}
|
||||||
|
defer os.Remove(path)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) {
|
func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) {
|
||||||
constructor := &multipartRecordingConstructor{}
|
constructor := &multipartRecordingConstructor{}
|
||||||
caller := &stubCaller{
|
caller := &stubCaller{
|
||||||
@@ -237,6 +277,276 @@ func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) {
|
|||||||
assert.NotContains(t, caller.calls[0].URL, "sendDocument")
|
assert.NotContains(t, caller.calls[0].URL, "sendDocument")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendMedia_MultipleImagesUseMediaGroup(t *testing.T) {
|
||||||
|
constructor := &multipartRecordingConstructor{}
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
if strings.Contains(url, "sendMediaGroup") {
|
||||||
|
return successMediaGroupResponse(t, 101, 102), nil
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected API call: %s", url)
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannelWithConstructor(t, caller, constructor)
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
ch.SetMediaStore(store)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
firstPath := filepath.Join(tmpDir, "first.png")
|
||||||
|
secondPath := filepath.Join(tmpDir, "second.png")
|
||||||
|
require.NoError(t, os.WriteFile(firstPath, []byte("first-image"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(secondPath, []byte("second-image"), 0o644))
|
||||||
|
|
||||||
|
firstRef, err := store.Store(firstPath, media.MediaMeta{Filename: "first.png", ContentType: "image/png"}, "scope-1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
secondRef, err := store.Store(
|
||||||
|
secondPath,
|
||||||
|
media.MediaMeta{Filename: "second.png", ContentType: "image/png"},
|
||||||
|
"scope-1",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Parts: []bus.MediaPart{
|
||||||
|
{Type: "image", Ref: firstRef, Caption: "album caption"},
|
||||||
|
{Type: "image", Ref: secondRef},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"101", "102"}, ids)
|
||||||
|
require.Len(t, caller.calls, 1)
|
||||||
|
assert.Contains(t, caller.calls[0].URL, "sendMediaGroup")
|
||||||
|
require.Len(t, constructor.calls, 1)
|
||||||
|
require.Len(t, constructor.calls[0].FileSizes, 2)
|
||||||
|
|
||||||
|
var mediaPayload []map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(constructor.calls[0].Parameters["media"]), &mediaPayload))
|
||||||
|
require.Len(t, mediaPayload, 2)
|
||||||
|
assert.Equal(t, "album caption", mediaPayload[0]["caption"])
|
||||||
|
_, hasSecondCaption := mediaPayload[1]["caption"]
|
||||||
|
assert.False(t, hasSecondCaption)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMedia_MoreThanTenImagesSplitIntoMediaGroups(t *testing.T) {
|
||||||
|
constructor := &multipartRecordingConstructor{}
|
||||||
|
callIndex := 0
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
if !strings.Contains(url, "sendMediaGroup") {
|
||||||
|
t.Fatalf("unexpected API call: %s", url)
|
||||||
|
}
|
||||||
|
callIndex++
|
||||||
|
if callIndex == 1 {
|
||||||
|
return successMediaGroupResponse(t, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010), nil
|
||||||
|
}
|
||||||
|
if callIndex == 2 {
|
||||||
|
return successMediaGroupResponse(t, 1011, 1012, 1013, 1014, 1015), nil
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected sendMediaGroup call #%d", callIndex)
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannelWithConstructor(t, caller, constructor)
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
ch.SetMediaStore(store)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
parts := make([]bus.MediaPart, 0, 15)
|
||||||
|
for i := 0; i < 15; i++ {
|
||||||
|
path := filepath.Join(tmpDir, "image-"+strconv.Itoa(i)+".png")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("img-"+strconv.Itoa(i)), 0o644))
|
||||||
|
ref, err := store.Store(
|
||||||
|
path,
|
||||||
|
media.MediaMeta{Filename: filepath.Base(path), ContentType: "image/png"},
|
||||||
|
"scope-1",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
part := bus.MediaPart{Type: "image", Ref: ref}
|
||||||
|
if i == 0 {
|
||||||
|
part.Caption = "long album caption"
|
||||||
|
}
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Parts: parts,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"1001", "1002", "1003", "1004", "1005",
|
||||||
|
"1006", "1007", "1008", "1009", "1010",
|
||||||
|
"1011", "1012", "1013", "1014", "1015",
|
||||||
|
}, ids)
|
||||||
|
require.Len(t, caller.calls, 2)
|
||||||
|
require.Len(t, constructor.calls, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMedia_SingleImageLongCaptionSendsTextFirst(t *testing.T) {
|
||||||
|
constructor := &multipartRecordingConstructor{}
|
||||||
|
longCaption := strings.Repeat("a", telegramCaptionLimit) + " tail overflow"
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(url, "sendMessage"):
|
||||||
|
return successResponseWithMessageID(t, 201), nil
|
||||||
|
case strings.Contains(url, "sendPhoto"):
|
||||||
|
return successResponseWithMessageID(t, 202), nil
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected API call: %s", url)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannelWithConstructor(t, caller, constructor)
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
ch.SetMediaStore(store)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpDir, "image.png")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("img"), 0o644))
|
||||||
|
ref, err := store.Store(path, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Parts: []bus.MediaPart{{
|
||||||
|
Type: "image",
|
||||||
|
Ref: ref,
|
||||||
|
Caption: longCaption,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"201", "202"}, ids)
|
||||||
|
require.Len(t, caller.calls, 2)
|
||||||
|
assert.Contains(t, caller.calls[0].URL, "sendMessage")
|
||||||
|
assert.Contains(t, caller.calls[1].URL, "sendPhoto")
|
||||||
|
assert.Equal(t, "", constructor.calls[0].Parameters["caption"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMedia_MediaGroupLongCaptionSendsTextFirst(t *testing.T) {
|
||||||
|
constructor := &multipartRecordingConstructor{}
|
||||||
|
longCaption := strings.Repeat("b", telegramCaptionLimit) + " trailing explanation"
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(url, "sendMessage"):
|
||||||
|
return successResponseWithMessageID(t, 301), nil
|
||||||
|
case strings.Contains(url, "sendMediaGroup"):
|
||||||
|
return successMediaGroupResponse(t, 302, 303), nil
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected API call: %s", url)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannelWithConstructor(t, caller, constructor)
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
ch.SetMediaStore(store)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
firstPath := filepath.Join(tmpDir, "first.png")
|
||||||
|
secondPath := filepath.Join(tmpDir, "second.png")
|
||||||
|
require.NoError(t, os.WriteFile(firstPath, []byte("first-image"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(secondPath, []byte("second-image"), 0o644))
|
||||||
|
|
||||||
|
firstRef, err := store.Store(firstPath, media.MediaMeta{Filename: "first.png", ContentType: "image/png"}, "scope-1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
secondRef, err := store.Store(
|
||||||
|
secondPath,
|
||||||
|
media.MediaMeta{Filename: "second.png", ContentType: "image/png"},
|
||||||
|
"scope-1",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Parts: []bus.MediaPart{
|
||||||
|
{Type: "image", Ref: firstRef, Caption: longCaption},
|
||||||
|
{Type: "image", Ref: secondRef},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"301", "302", "303"}, ids)
|
||||||
|
require.Len(t, caller.calls, 2)
|
||||||
|
assert.Contains(t, caller.calls[0].URL, "sendMessage")
|
||||||
|
assert.Contains(t, caller.calls[1].URL, "sendMediaGroup")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMedia_MultiGroupLongCaptionSendsTextBeforeGroups(t *testing.T) {
|
||||||
|
constructor := &multipartRecordingConstructor{}
|
||||||
|
longCaption := strings.Repeat("c", telegramCaptionLimit) + " overflow before second album"
|
||||||
|
callOrder := make([]string, 0, 3)
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(url, "sendMessage"):
|
||||||
|
callOrder = append(callOrder, "text")
|
||||||
|
return successResponseWithMessageID(t, 499), nil
|
||||||
|
case strings.Contains(url, "sendMediaGroup"):
|
||||||
|
callOrder = append(callOrder, "group")
|
||||||
|
if len(callOrder) == 2 {
|
||||||
|
return successMediaGroupResponse(t, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410), nil
|
||||||
|
}
|
||||||
|
if len(callOrder) == 3 {
|
||||||
|
return successMediaGroupResponse(t, 411, 412, 413, 414, 415), nil
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected sendMediaGroup order: %v", callOrder)
|
||||||
|
return nil, nil
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected API call: %s", url)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannelWithConstructor(t, caller, constructor)
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
ch.SetMediaStore(store)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
parts := make([]bus.MediaPart, 0, 15)
|
||||||
|
for i := 0; i < 15; i++ {
|
||||||
|
path := filepath.Join(tmpDir, "image-"+strconv.Itoa(i)+".png")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("img-"+strconv.Itoa(i)), 0o644))
|
||||||
|
ref, err := store.Store(
|
||||||
|
path,
|
||||||
|
media.MediaMeta{Filename: filepath.Base(path), ContentType: "image/png"},
|
||||||
|
"scope-1",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
part := bus.MediaPart{Type: "image", Ref: ref}
|
||||||
|
if i == 0 {
|
||||||
|
part.Caption = longCaption
|
||||||
|
}
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Parts: parts,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"499",
|
||||||
|
"401", "402", "403", "404", "405",
|
||||||
|
"406", "407", "408", "409", "410",
|
||||||
|
"411", "412", "413", "414", "415",
|
||||||
|
}, ids)
|
||||||
|
assert.Equal(t, []string{"text", "group", "group"}, callOrder)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSend_EmptyContent(t *testing.T) {
|
func TestSend_EmptyContent(t *testing.T) {
|
||||||
caller := &stubCaller{
|
caller := &stubCaller{
|
||||||
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
@@ -931,8 +1241,9 @@ func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) {
|
|||||||
inbound, ok := <-messageBus.InboundChan()
|
inbound, ok := <-messageBus.InboundChan()
|
||||||
require.True(t, ok, "expected inbound message")
|
require.True(t, ok, "expected inbound message")
|
||||||
|
|
||||||
// ChatID remains the parent chat; TopicID isolates the sub-conversation.
|
// ChatID includes the thread ID for forum topics so outbound
|
||||||
assert.Equal(t, "-1001234567890", inbound.ChatID)
|
// delivery resolves the correct topic without relying solely on TopicID fallback.
|
||||||
|
assert.Equal(t, "-1001234567890/42", inbound.ChatID)
|
||||||
assert.Equal(t, "group", inbound.Context.ChatType)
|
assert.Equal(t, "group", inbound.Context.ChatType)
|
||||||
assert.Equal(t, "42", inbound.Context.TopicID)
|
assert.Equal(t, "42", inbound.Context.TopicID)
|
||||||
}
|
}
|
||||||
@@ -1216,6 +1527,42 @@ func TestHandleMessage_EmptyContent_Ignored(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleMessage_LocationForwardedAsText(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &TelegramChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||||
|
chatIDs: make(map[string]int64),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &telego.Message{
|
||||||
|
MessageID: 3049,
|
||||||
|
Location: &telego.Location{
|
||||||
|
Latitude: 35.197713,
|
||||||
|
Longitude: 136.885705,
|
||||||
|
},
|
||||||
|
Chat: telego.Chat{
|
||||||
|
ID: 456,
|
||||||
|
Type: "private",
|
||||||
|
},
|
||||||
|
From: &telego.User{
|
||||||
|
ID: 789,
|
||||||
|
FirstName: "User",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ch.handleMessage(context.Background(), msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case inbound := <-messageBus.InboundChan():
|
||||||
|
assert.Equal(t, "[User location: lat=35.197713, lng=136.885705]", inbound.Content)
|
||||||
|
assert.Equal(t, "3049", inbound.Context.MessageID)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for location message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleMessage_MediaGroupCombinesCaptionMessages(t *testing.T) {
|
func TestHandleMessage_MediaGroupCombinesCaptionMessages(t *testing.T) {
|
||||||
messageBus, ch := newMediaGroupTestChannel(10 * time.Millisecond)
|
messageBus, ch := newMediaGroupTestChannel(10 * time.Millisecond)
|
||||||
base := testMediaGroupMessage("album-1")
|
base := testMediaGroupMessage("album-1")
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ func (c *WeComChannel) storeRemoteMedia(
|
|||||||
}
|
}
|
||||||
tmpPath := tmpFile.Name()
|
tmpPath := tmpFile.Name()
|
||||||
if _, writeErr := tmpFile.Write(data); writeErr != nil {
|
if _, writeErr := tmpFile.Write(data); writeErr != nil {
|
||||||
tmpFile.Close()
|
_ = tmpFile.Close()
|
||||||
_ = os.Remove(tmpPath)
|
_ = os.Remove(tmpPath)
|
||||||
return "", fmt.Errorf("write temp file: %w", writeErr)
|
return "", fmt.Errorf("write temp file: %w", writeErr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -319,3 +320,61 @@ func TestSelectInboundMediaItemFallsBackToRefMessage(t *testing.T) {
|
|||||||
t.Fatalf("selectInboundMediaItem().Type = %d, want %d", item.Type, MessageItemTypeImage)
|
t.Fatalf("selectInboundMediaItem().Type = %d, want %d", item.Type, MessageItemTypeImage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendUploadedMedia_SendsCaptionAsSeparateTextBeforeMedia(t *testing.T) {
|
||||||
|
var requests []SendMessageReq
|
||||||
|
ch := &WeixinChannel{
|
||||||
|
api: &ApiClient{
|
||||||
|
BaseURL: "https://ilinkai.weixin.qq.com/",
|
||||||
|
HttpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Path != "/ilink/bot/sendmessage" {
|
||||||
|
t.Fatalf("sendmessage path = %q, want /ilink/bot/sendmessage", r.URL.Path)
|
||||||
|
}
|
||||||
|
var req SendMessageReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatalf("decode sendmessage req: %v", err)
|
||||||
|
}
|
||||||
|
requests = append(requests, req)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(bytes.NewReader([]byte(`{"ret":0,"errcode":0}`))),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}, nil
|
||||||
|
})},
|
||||||
|
},
|
||||||
|
typingCache: make(map[string]typingTicketCacheEntry),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ch.sendUploadedMedia(
|
||||||
|
context.Background(),
|
||||||
|
"user-1",
|
||||||
|
"ctx-1",
|
||||||
|
"recipe translation",
|
||||||
|
UploadMediaTypeImage,
|
||||||
|
&uploadedFileInfo{
|
||||||
|
downloadParam: "download-token",
|
||||||
|
aesKeyHex: "31323334353637383930616263646566",
|
||||||
|
fileSize: 11,
|
||||||
|
cipherSize: 16,
|
||||||
|
filename: "photo.png",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sendUploadedMedia() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(requests) != 2 {
|
||||||
|
t.Fatalf("sendUploadedMedia() sent %d requests, want 2", len(requests))
|
||||||
|
}
|
||||||
|
if len(requests[0].Msg.ItemList) != 1 || requests[0].Msg.ItemList[0].Type != MessageItemTypeText {
|
||||||
|
t.Fatalf("first request item = %+v, want text item", requests[0].Msg.ItemList)
|
||||||
|
}
|
||||||
|
if got := requests[0].Msg.ItemList[0].TextItem.Text; got != "recipe translation" {
|
||||||
|
t.Fatalf("first request text = %q, want recipe translation", got)
|
||||||
|
}
|
||||||
|
if len(requests[1].Msg.ItemList) != 1 || requests[1].Msg.ItemList[0].Type != MessageItemTypeImage {
|
||||||
|
t.Fatalf("second request item = %+v, want image item", requests[1].Msg.ItemList)
|
||||||
|
}
|
||||||
|
if requests[1].Msg.ItemList[0].ImageItem == nil || requests[1].Msg.ItemList[0].ImageItem.Media == nil {
|
||||||
|
t.Fatalf("second request image media = %+v, want media ref", requests[1].Msg.ItemList[0].ImageItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user