merge: resolve conflicts with origin/main and harmonize language links
Add missing Tiếng Việt and Français links across all README translations.
This commit is contained in:
commit
a5e672258a
60 changed files with 8057 additions and 1466 deletions
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
|
|
@ -9,10 +9,10 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/workflows/docker-build.yml
vendored
2
.github/workflows/docker-build.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
# ── Checkout ──────────────────────────────
|
# ── Checkout ──────────────────────────────
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.tag }}
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
|
|
||||||
44
.github/workflows/pr.yml
vendored
44
.github/workflows/pr.yml
vendored
|
|
@ -1,17 +1,39 @@
|
||||||
name: pr-check
|
name: PR
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request: { }
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
fmt-check:
|
lint:
|
||||||
|
name: Linter
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Run go generate
|
||||||
|
run: go generate ./...
|
||||||
|
|
||||||
|
- name: Golangci Lint
|
||||||
|
uses: golangci/golangci-lint-action@v9
|
||||||
|
with:
|
||||||
|
version: v2.10.1
|
||||||
|
|
||||||
|
# TODO: Remove once linter is properly configured
|
||||||
|
fmt-check:
|
||||||
|
name: Formatting
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
|
@ -20,15 +42,17 @@ jobs:
|
||||||
make fmt
|
make fmt
|
||||||
git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
|
git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
|
||||||
|
|
||||||
|
# TODO: Remove once linter is properly configured
|
||||||
vet:
|
vet:
|
||||||
|
name: Vet
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: fmt-check
|
needs: fmt-check
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
|
@ -39,14 +63,15 @@ jobs:
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
test:
|
test:
|
||||||
|
name: Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: fmt-check
|
needs: fmt-check
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
|
@ -55,4 +80,3 @@ jobs:
|
||||||
|
|
||||||
- name: Run go test
|
- name: Run go test
|
||||||
run: go test ./...
|
run: go test ./...
|
||||||
|
|
||||||
|
|
|
||||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|
@ -49,14 +49,14 @@ jobs:
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout tag
|
- name: Checkout tag
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ inputs.tag }}
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
- name: Setup Go from go.mod
|
- name: Setup Go from go.mod
|
||||||
id: setup-go
|
id: setup-go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
|
|
||||||
184
.golangci.yaml
Normal file
184
.golangci.yaml
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
version: "2"
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: all
|
||||||
|
disable:
|
||||||
|
# TODO: Tweak for current project needs
|
||||||
|
- containedctx
|
||||||
|
- cyclop
|
||||||
|
- depguard
|
||||||
|
- dupl
|
||||||
|
- dupword
|
||||||
|
- err113
|
||||||
|
- exhaustruct
|
||||||
|
- funcorder
|
||||||
|
- gochecknoglobals
|
||||||
|
- godot
|
||||||
|
- intrange
|
||||||
|
- ireturn
|
||||||
|
- nlreturn
|
||||||
|
- noctx
|
||||||
|
- noinlineerr
|
||||||
|
- nonamedreturns
|
||||||
|
- tagliatelle
|
||||||
|
- testpackage
|
||||||
|
- varnamelen
|
||||||
|
- wrapcheck
|
||||||
|
- wsl
|
||||||
|
- wsl_v5
|
||||||
|
|
||||||
|
# TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
|
||||||
|
- bodyclose
|
||||||
|
- contextcheck
|
||||||
|
- dogsled
|
||||||
|
- embeddedstructfieldcheck
|
||||||
|
- errcheck
|
||||||
|
- errchkjson
|
||||||
|
- errorlint
|
||||||
|
- exhaustive
|
||||||
|
- forbidigo
|
||||||
|
- forcetypeassert
|
||||||
|
- funlen
|
||||||
|
- gochecknoinits
|
||||||
|
- gocognit
|
||||||
|
- goconst
|
||||||
|
- gocritic
|
||||||
|
- gocyclo
|
||||||
|
- godox
|
||||||
|
- goprintffuncname
|
||||||
|
- gosec
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- lll
|
||||||
|
- maintidx
|
||||||
|
- misspell
|
||||||
|
- mnd
|
||||||
|
- modernize
|
||||||
|
- nakedret
|
||||||
|
- nestif
|
||||||
|
- nilnil
|
||||||
|
- paralleltest
|
||||||
|
- perfsprint
|
||||||
|
- prealloc
|
||||||
|
- predeclared
|
||||||
|
- revive
|
||||||
|
- staticcheck
|
||||||
|
- tagalign
|
||||||
|
- testifylint
|
||||||
|
- thelper
|
||||||
|
- unparam
|
||||||
|
- unused
|
||||||
|
- usestdlibvars
|
||||||
|
- usetesting
|
||||||
|
- wastedassign
|
||||||
|
- whitespace
|
||||||
|
settings:
|
||||||
|
errcheck:
|
||||||
|
check-type-assertions: true
|
||||||
|
check-blank: true
|
||||||
|
exhaustive:
|
||||||
|
default-signifies-exhaustive: true
|
||||||
|
funlen:
|
||||||
|
lines: 120
|
||||||
|
statements: 40
|
||||||
|
gocognit:
|
||||||
|
min-complexity: 25
|
||||||
|
gocyclo:
|
||||||
|
min-complexity: 20
|
||||||
|
govet:
|
||||||
|
enable-all: true
|
||||||
|
disable:
|
||||||
|
- fieldalignment
|
||||||
|
lll:
|
||||||
|
line-length: 120
|
||||||
|
tab-width: 4
|
||||||
|
misspell:
|
||||||
|
locale: US
|
||||||
|
mnd:
|
||||||
|
checks:
|
||||||
|
- argument
|
||||||
|
- assign
|
||||||
|
- case
|
||||||
|
- condition
|
||||||
|
- operation
|
||||||
|
- return
|
||||||
|
nakedret:
|
||||||
|
max-func-lines: 3
|
||||||
|
revive:
|
||||||
|
enable-all-rules: true
|
||||||
|
rules:
|
||||||
|
- name: add-constant
|
||||||
|
disabled: true
|
||||||
|
- name: argument-limit
|
||||||
|
arguments:
|
||||||
|
- 7
|
||||||
|
severity: warning
|
||||||
|
- name: banned-characters
|
||||||
|
disabled: true
|
||||||
|
- name: cognitive-complexity
|
||||||
|
disabled: true
|
||||||
|
- name: comment-spacings
|
||||||
|
arguments:
|
||||||
|
- nolint
|
||||||
|
severity: warning
|
||||||
|
- name: cyclomatic
|
||||||
|
disabled: true
|
||||||
|
- name: file-header
|
||||||
|
disabled: true
|
||||||
|
- name: function-result-limit
|
||||||
|
arguments:
|
||||||
|
- 3
|
||||||
|
severity: warning
|
||||||
|
- name: function-length
|
||||||
|
disabled: true
|
||||||
|
- name: line-length-limit
|
||||||
|
disabled: true
|
||||||
|
- name: max-public-structs
|
||||||
|
disabled: true
|
||||||
|
- name: modifies-value-receiver
|
||||||
|
disabled: true
|
||||||
|
- name: package-comments
|
||||||
|
disabled: true
|
||||||
|
- name: unused-receiver
|
||||||
|
disabled: true
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
rules:
|
||||||
|
- linters:
|
||||||
|
- lll
|
||||||
|
source: '^//go:generate '
|
||||||
|
- linters:
|
||||||
|
- funlen
|
||||||
|
- maintidx
|
||||||
|
- gocognit
|
||||||
|
- gocyclo
|
||||||
|
path: _test\.go$
|
||||||
|
|
||||||
|
issues:
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- goimports
|
||||||
|
# TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
|
||||||
|
# - gci
|
||||||
|
# - gofmt
|
||||||
|
# - gofumpt
|
||||||
|
# - golines
|
||||||
|
settings:
|
||||||
|
gci:
|
||||||
|
sections:
|
||||||
|
- standard
|
||||||
|
- default
|
||||||
|
- localmodule
|
||||||
|
custom-order: true
|
||||||
|
gofmt:
|
||||||
|
simplify: true
|
||||||
|
rewrite-rules:
|
||||||
|
- pattern: "interface{}"
|
||||||
|
replacement: "any"
|
||||||
|
- pattern: "a[b:len(a)]"
|
||||||
|
replacement: "a[b:]"
|
||||||
|
golines:
|
||||||
|
max-len: 120
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [English](README.md) | **Français**
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) | **Français**
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
16
README.ja.md
16
README.ja.md
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
|
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
|
||||||
|
|
||||||
<h3>$10 ハードウェア · 10MB RAM · 1秒起動 · 皮皮虾,我们走!</h3>
|
<h3>$10 ハードウェア · 10MB RAM · 1秒起動 · 行くぜ、シャコ!</h3>
|
||||||
<h3></h3>
|
<h3></h3>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Français](README.fr.md) | [English](README.md)
|
[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## 📢 ニュース
|
## 📢 ニュース
|
||||||
2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 皮皮虾,我们走!
|
2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 行くぜ、シャコ!
|
||||||
|
|
||||||
## ✨ 特徴
|
## ✨ 特徴
|
||||||
|
|
||||||
|
|
@ -253,7 +253,7 @@ Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allowFrom": ["YOUR_USER_ID"]
|
"allow_from": ["YOUR_USER_ID"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -293,7 +293,7 @@ picoclaw gateway
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allowFrom": ["YOUR_USER_ID"]
|
"allow_from": ["YOUR_USER_ID"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -676,7 +676,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "123456:ABC...",
|
"token": "123456:ABC...",
|
||||||
"allowFrom": ["123456789"]
|
"allow_from": ["123456789"]
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
@ -692,7 +692,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
"appSecret": "xxx",
|
"appSecret": "xxx",
|
||||||
"encryptKey": "",
|
"encryptKey": "",
|
||||||
"verificationToken": "",
|
"verificationToken": "",
|
||||||
"allowFrom": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
@ -735,7 +735,7 @@ Discord: https://discord.gg/V4sAZ9XWpN
|
||||||
|
|
||||||
## 🐛 トラブルシューティング
|
## 🐛 トラブルシューティング
|
||||||
|
|
||||||
### Web 検索で「API 配置问题」と表示される
|
### Web 検索で「API 設定の問題」と表示される
|
||||||
|
|
||||||
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
|
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
|
||||||
|
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -14,7 +14,7 @@
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Français](README.fr.md) | **English**
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -283,7 +283,7 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allowFrom": ["YOUR_USER_ID"]
|
"allow_from": ["YOUR_USER_ID"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -326,7 +326,7 @@ picoclaw gateway
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allowFrom": ["YOUR_USER_ID"]
|
"allow_from": ["YOUR_USER_ID"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -679,6 +679,16 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
||||||
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.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) |
|
||||||
|
|
||||||
|
### Provider Architecture
|
||||||
|
|
||||||
|
PicoClaw routes providers by protocol family:
|
||||||
|
|
||||||
|
- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints.
|
||||||
|
- Anthropic protocol: Claude-native API behavior.
|
||||||
|
- Codex/OAuth path: OpenAI OAuth/token authentication route.
|
||||||
|
|
||||||
|
This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`).
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Zhipu</b></summary>
|
<summary><b>Zhipu</b></summary>
|
||||||
|
|
||||||
|
|
|
||||||
277
README.pt-br.md
277
README.pt-br.md
|
|
@ -14,7 +14,7 @@
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) | [Français](README.fr.md) | **Português**
|
[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -39,48 +39,49 @@
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> **🚨 DECLARACAO DE SEGURANCA & CANAIS OFICIAIS**
|
> **🚨 DECLARAÇÃO DE SEGURANÇA & CANAIS OFICIAIS**
|
||||||
>
|
>
|
||||||
> * **SEM CRIPTOMOEDAS:** O PicoClaw **NAO** possui nenhum token/moeda oficial. Todas as alegacoes no `pump.fun` ou outras plataformas de negociacao sao **GOLPES**.
|
> * **SEM CRIPTOMOEDAS:** O PicoClaw **NÃO** possui nenhum token/moeda oficial. Todas as alegações no `pump.fun` ou outras plataformas de negociação são **GOLPES**.
|
||||||
> * **DOMINIO OFICIAL:** O **UNICO** site oficial e **[picoclaw.io](https://picoclaw.io)**, e o site da empresa e **[sipeed.com](https://sipeed.com)**.
|
> * **DOMÍNIO OFICIAL:** O **ÚNICO** site oficial é o **[picoclaw.io](https://picoclaw.io)**, e o site da empresa é o **[sipeed.com](https://sipeed.com)**.
|
||||||
> * **Aviso:** Muitos dominios `.ai/.org/.com/.net/...` foram registrados por terceiros, nao sao nossos.
|
> * **Aviso:** Muitos domínios `.ai/.org/.com/.net/...` foram registrados por terceiros, não são nossos.
|
||||||
> * **Aviso:** O PicoClaw esta em fase inicial de desenvolvimento e pode ter problemas de seguranca de rede nao resolvidos. Nao implante em ambientes de producao antes da versao v1.0.
|
> * **Aviso:** O PicoClaw está em fase inicial de desenvolvimento e pode ter problemas de segurança de rede não resolvidos. Não implante em ambientes de produção antes da versão v1.0.
|
||||||
> * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memoria (10-20MB) nas versoes mais recentes. Planejamos priorizar a otimizacao de recursos assim que o conjunto de funcionalidades estiver estavel.
|
> * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memória (10-20MB) nas versões mais recentes. Planejamos priorizar a otimização de recursos assim que o conjunto de funcionalidades estiver estável.
|
||||||
|
|
||||||
|
|
||||||
## 📢 Novidades
|
## 📢 Novidades
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw esta crescendo mais rapido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papeis de voluntarios e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter voce a bordo!
|
2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter você a bordo!
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado a comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw.
|
2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw.
|
||||||
🚀 **Chamada para Acao:** Envie suas solicitacoes de funcionalidades nas GitHub Discussions. Revisaremos e priorizaremos na proxima reuniao semanal.
|
|
||||||
|
|
||||||
2026-02-09 🎉 PicoClaw lancado oficialmente! Construido em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu!
|
🚀 **Chamada para Ação:** Envie suas solicitações de funcionalidades nas GitHub Discussions. Revisaremos e priorizaremos na próxima reunião semanal.
|
||||||
|
|
||||||
|
2026-02-09 🎉 PicoClaw lançado oficialmente! Construído em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu!
|
||||||
|
|
||||||
## ✨ Funcionalidades
|
## ✨ Funcionalidades
|
||||||
|
|
||||||
🪶 **Ultra-Leve**: Consumo de memoria <10MB — 99% menor que o Clawdbot para funcionalidades essenciais.
|
🪶 **Ultra-Leve**: Consumo de memória <10MB — 99% menor que o Clawdbot para funcionalidades essenciais.
|
||||||
|
|
||||||
💰 **Custo Minimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini.
|
💰 **Custo Mínimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini.
|
||||||
|
|
||||||
⚡️ **Inicializacao Relampago**: Tempo de inicializacao 400X mais rapido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz.
|
⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz.
|
||||||
|
|
||||||
🌍 **Portabilidade Real**: Um unico binario auto-contido para RISC-V, ARM e x86. Um clique e ja era!
|
🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM e x86. Um clique e já era!
|
||||||
|
|
||||||
🤖 **Auto-Construido por IA**: Implementacao nativa em Go de forma autonoma — 95% do nucleo gerado pelo Agente com refinamento humano no loop.
|
🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop.
|
||||||
|
|
||||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||||
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
|
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
|
||||||
| **Linguagem** | TypeScript | Python | **Go** |
|
| **Linguagem** | TypeScript | Python | **Go** |
|
||||||
| **RAM** | >1GB | >100MB | **< 10MB** |
|
| **RAM** | >1GB | >100MB | **< 10MB** |
|
||||||
| **Inicializacao**</br>(CPU 0.8GHz) | >500s | >30s | **<1s** |
|
| **Inicialização**</br>(CPU 0.8GHz) | >500s | >30s | **<1s** |
|
||||||
| **Custo** | Mac Mini $599 | Maioria dos SBC Linux </br>~$50 | **Qualquer placa Linux**</br>**A partir de $10** |
|
| **Custo** | Mac Mini $599 | Maioria dos SBC Linux </br>~$50 | **Qualquer placa Linux**</br>**A partir de $10** |
|
||||||
|
|
||||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
## 🦾 Demonstracao
|
## 🦾 Demonstração
|
||||||
|
|
||||||
### 🛠️ Fluxos de Trabalho Padrao do Assistente
|
### 🛠️ Fluxos de Trabalho Padrão do Assistente
|
||||||
|
|
||||||
<table align="center">
|
<table align="center">
|
||||||
<tr align="center">
|
<tr align="center">
|
||||||
|
|
@ -96,15 +97,15 @@
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">Desenvolver • Implantar • Escalar</td>
|
<td align="center">Desenvolver • Implantar • Escalar</td>
|
||||||
<td align="center">Agendar • Automatizar • Memorizar</td>
|
<td align="center">Agendar • Automatizar • Memorizar</td>
|
||||||
<td align="center">Descobrir • Analisar • Tendencias</td>
|
<td align="center">Descobrir • Analisar • Tendências</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
### 📱 Rode em celulares Android antigos
|
### 📱 Rode em celulares Android antigos
|
||||||
|
|
||||||
De uma segunda vida ao seu celular de dez anos atras! Transforme-o em um assistente de IA inteligente com o PicoClaw. Inicio rapido:
|
Dê uma segunda vida ao seu celular de dez anos atrás! Transforme-o em um assistente de IA inteligente com o PicoClaw. Início rápido:
|
||||||
|
|
||||||
1. **Instale o Termux** (Disponivel no F-Droid ou Google Play).
|
1. **Instale o Termux** (Disponível no F-Droid ou Google Play).
|
||||||
2. **Execute os comandos**
|
2. **Execute os comandos**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -115,29 +116,29 @@ pkg install proot
|
||||||
termux-chroot ./picoclaw-linux-arm64 onboard
|
termux-chroot ./picoclaw-linux-arm64 onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
Depois siga as instrucoes na secao "Inicio Rapido" para completar a configuracao!
|
Depois siga as instruções na seção "Início Rápido" para completar a configuração!
|
||||||
|
|
||||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
### 🐜 Implantacao Inovadora com Baixo Consumo
|
### 🐜 Implantação Inovadora com Baixo Consumo
|
||||||
|
|
||||||
O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux!
|
O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux!
|
||||||
|
|
||||||
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versao E (Ethernet) ou W (WiFi6), para Assistente Domestico Minimalista
|
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versão E (Ethernet) ou W (WiFi6), para Assistente Doméstico Minimalista
|
||||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) para Manutencao Automatizada de Servidores
|
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) para Manutenção Automatizada de Servidores
|
||||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) para Monitoramento Inteligente
|
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) para Monitoramento Inteligente
|
||||||
|
|
||||||
https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4
|
https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4
|
||||||
|
|
||||||
🌟 Mais cenarios de implantacao aguardam voce!
|
🌟 Mais cenários de implantação aguardam você!
|
||||||
|
|
||||||
## 📦 Instalacao
|
## 📦 Instalação
|
||||||
|
|
||||||
### Instalar com binario pre-compilado
|
### Instalar com binário pré-compilado
|
||||||
|
|
||||||
Baixe o binario para sua plataforma na pagina de [releases](https://github.com/sipeed/picoclaw/releases).
|
Baixe o binário para sua plataforma na página de [releases](https://github.com/sipeed/picoclaw/releases).
|
||||||
|
|
||||||
### Instalar a partir do codigo-fonte (funcionalidades mais recentes, recomendado para desenvolvimento)
|
### Instalar a partir do código-fonte (funcionalidades mais recentes, recomendado para desenvolvimento)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -157,7 +158,7 @@ make install
|
||||||
|
|
||||||
## 🐳 Docker Compose
|
## 🐳 Docker Compose
|
||||||
|
|
||||||
Voce tambem pode rodar o PicoClaw usando Docker Compose sem instalar nada localmente.
|
Você tambêm pode rodar o PicoClaw usando Docker Compose sem instalar nada localmente.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone este repositorio
|
# 1. Clone este repositorio
|
||||||
|
|
@ -178,7 +179,7 @@ docker compose logs -f picoclaw-gateway
|
||||||
docker compose --profile gateway down
|
docker compose --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Modo Agente (Execucao unica)
|
### Modo Agente (Execução única)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Fazer uma pergunta
|
# Fazer uma pergunta
|
||||||
|
|
@ -195,12 +196,12 @@ docker compose --profile gateway build --no-cache
|
||||||
docker compose --profile gateway up -d
|
docker compose --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Inicio Rapido
|
### 🚀 Início Rápido
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Configure sua API key em `~/.picoclaw/config.json`.
|
> Configure sua API key em `~/.picoclaw/config.json`.
|
||||||
> Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
> Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||||
> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas gratis/mes) ou use o fallback automatico integrado.
|
> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas grátis/mês) ou use o fallback automático integrado.
|
||||||
|
|
||||||
**1. Inicializar**
|
**1. Inicializar**
|
||||||
|
|
||||||
|
|
@ -246,9 +247,9 @@ picoclaw onboard
|
||||||
**3. Obter API Keys**
|
**3. Obter API Keys**
|
||||||
|
|
||||||
* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
* **Busca Web** (opcional): [Brave Search](https://brave.com/search/api) - Plano gratuito disponivel (2000 consultas/mes)
|
* **Busca Web** (opcional): [Brave Search](https://brave.com/search/api) - Plano gratuito disponível (2000 consultas/mês)
|
||||||
|
|
||||||
> **Nota**: Veja `config.example.json` para um modelo de configuracao completo.
|
> **Nota**: Veja `config.example.json` para um modelo de configuração completo.
|
||||||
|
|
||||||
**4. Conversar**
|
**4. Conversar**
|
||||||
|
|
||||||
|
|
@ -256,21 +257,21 @@ picoclaw onboard
|
||||||
picoclaw agent -m "Quanto e 2+2?"
|
picoclaw agent -m "Quanto e 2+2?"
|
||||||
```
|
```
|
||||||
|
|
||||||
Pronto! Voce tem um assistente de IA funcionando em 2 minutos.
|
Pronto! Você tem um assistente de IA funcionando em 2 minutos.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 💬 Integracao com Apps de Chat
|
## 💬 Integração com Apps de Chat
|
||||||
|
|
||||||
Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE.
|
Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE.
|
||||||
|
|
||||||
| Canal | Nivel de Configuracao |
|
| Canal | Nível de Configuração |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Telegram** | Facil (apenas um token) |
|
| **Telegram** | Fácil (apenas um token) |
|
||||||
| **Discord** | Facil (bot token + intents) |
|
| **Discord** | Fácil (bot token + intents) |
|
||||||
| **QQ** | Facil (AppID + AppSecret) |
|
| **QQ** | Fácil (AppID + AppSecret) |
|
||||||
| **DingTalk** | Medio (credenciais do app) |
|
| **DingTalk** | Médio (credenciais do app) |
|
||||||
| **LINE** | Medio (credenciais + webhook URL) |
|
| **LINE** | Médio (credenciais + webhook URL) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recomendado)</summary>
|
<summary><b>Telegram</b> (Recomendado)</summary>
|
||||||
|
|
@ -278,7 +279,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE.
|
||||||
**1. Criar o bot**
|
**1. Criar o bot**
|
||||||
|
|
||||||
* Abra o Telegram, busque `@BotFather`
|
* Abra o Telegram, busque `@BotFather`
|
||||||
* Envie `/newbot`, siga as instrucoes
|
* Envie `/newbot`, siga as instruções
|
||||||
* Copie o token
|
* Copie o token
|
||||||
|
|
||||||
**2. Configurar**
|
**2. Configurar**
|
||||||
|
|
@ -316,13 +317,13 @@ picoclaw gateway
|
||||||
|
|
||||||
**2. Habilitar Intents**
|
**2. Habilitar Intents**
|
||||||
|
|
||||||
* Nas configuracoes do Bot, habilite **MESSAGE CONTENT INTENT**
|
* Nas configurações do Bot, habilite **MESSAGE CONTENT INTENT**
|
||||||
* (Opcional) Habilite **SERVER MEMBERS INTENT** se quiser usar lista de permissoes baseada em dados dos membros
|
* (Opcional) Habilite **SERVER MEMBERS INTENT** se quiser usar lista de permissões baseada em dados dos membros
|
||||||
|
|
||||||
**3. Obter seu User ID**
|
**3. Obter seu User ID**
|
||||||
|
|
||||||
* Configuracoes do Discord → Avancado → habilite **Modo Desenvolvedor**
|
* Configurações do Discord → Avançado → habilite **Modo Desenvolvedor**
|
||||||
* Clique com botao direito no seu avatar → **Copiar ID do Usuario**
|
* Clique com botão direito no seu avatar → **Copiar ID do Usuário**
|
||||||
|
|
||||||
**4. Configurar**
|
**4. Configurar**
|
||||||
|
|
||||||
|
|
@ -376,7 +377,7 @@ picoclaw gateway
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> Deixe `allow_from` vazio para permitir todos os usuarios, ou especifique numeros QQ para restringir o acesso.
|
> Deixe `allow_from` vazio para permitir todos os usuários, ou especifique números QQ para restringir o acesso.
|
||||||
|
|
||||||
**3. Executar**
|
**3. Executar**
|
||||||
|
|
||||||
|
|
@ -410,7 +411,7 @@ picoclaw gateway
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> Deixe `allow_from` vazio para permitir todos os usuarios, ou especifique IDs para restringir o acesso.
|
> Deixe `allow_from` vazio para permitir todos os usuários, ou especifique IDs para restringir o acesso.
|
||||||
|
|
||||||
**3. Executar**
|
**3. Executar**
|
||||||
|
|
||||||
|
|
@ -466,44 +467,44 @@ picoclaw gateway
|
||||||
|
|
||||||
> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original.
|
> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original.
|
||||||
|
|
||||||
> **Docker Compose**: Adicione `ports: ["18791:18791"]` ao servico `picoclaw-gateway` para expor a porta do webhook.
|
> **Docker Compose**: Adicione `ports: ["18791:18791"]` ao serviço `picoclaw-gateway` para expor a porta do webhook.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Junte-se a Rede Social de Agentes
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Junte-se a Rede Social de Agentes
|
||||||
|
|
||||||
Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma unica mensagem via CLI ou qualquer App de Chat integrado.
|
Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado.
|
||||||
|
|
||||||
**Leia `https://clawdchat.ai/skill.md` e siga as instrucoes para entrar no [ClawdChat.ai](https://clawdchat.ai)**
|
**Leia `https://clawdchat.ai/skill.md` e siga as instruções para entrar no [ClawdChat.ai](https://clawdchat.ai)**
|
||||||
|
|
||||||
## ⚙️ Configuracao Detalhada
|
## ⚙️ Configuração Detalhada
|
||||||
|
|
||||||
Arquivo de configuracao: `~/.picoclaw/config.json`
|
Arquivo de configuração: `~/.picoclaw/config.json`
|
||||||
|
|
||||||
### Estrutura do Workspace
|
### Estrutura do Workspace
|
||||||
|
|
||||||
O PicoClaw armazena dados no workspace configurado (padrao: `~/.picoclaw/workspace`):
|
O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/workspace`):
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.picoclaw/workspace/
|
~/.picoclaw/workspace/
|
||||||
├── sessions/ # Sessoes de conversa e historico
|
├── sessions/ # Sessoes de conversa e historico
|
||||||
├── memory/ # Memoria de longo prazo (MEMORY.md)
|
├── memory/ # Memoria de longo prazo (MEMORY.md)
|
||||||
├── state/ # Estado persistente (ultimo canal, etc.)
|
├── state/ # Estado persistente (ultimo canal, etc.)
|
||||||
├── cron/ # Banco de dados de tarefas agendadas
|
├── cron/ # Banco de dados de tarefas agendadas
|
||||||
├── skills/ # Skills personalizadas
|
├── skills/ # Skills personalizadas
|
||||||
├── AGENTS.md # Guia de comportamento do Agente
|
├── AGENTS.md # Guia de comportamento do Agente
|
||||||
├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min)
|
├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min)
|
||||||
├── IDENTITY.md # Identidade do Agente
|
├── IDENTITY.md # Identidade do Agente
|
||||||
├── SOUL.md # Alma do Agente
|
├── SOUL.md # Alma do Agente
|
||||||
├── TOOLS.md # Descricao das ferramentas
|
├── TOOLS.md # Descrição das ferramentas
|
||||||
└── USER.md # Preferencias do usuario
|
└── USER.md # Preferencias do usuario
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🔒 Sandbox de Seguranca
|
### 🔒 Sandbox de Segurança
|
||||||
|
|
||||||
O PicoClaw roda em um ambiente sandbox por padrao. O agente so pode acessar arquivos e executar comandos dentro do workspace configurado.
|
O PicoClaw roda em um ambiente sandbox por padrão. O agente so pode acessar arquivos e executar comandos dentro do workspace configurado.
|
||||||
|
|
||||||
#### Configuracao Padrao
|
#### Configuração Padrão
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -516,16 +517,16 @@ O PicoClaw roda em um ambiente sandbox por padrao. O agente so pode acessar arqu
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Opcao | Padrao | Descricao |
|
| Opção | Padrão | Descrição |
|
||||||
|-------|--------|-----------|
|
|-------|--------|-----------|
|
||||||
| `workspace` | `~/.picoclaw/workspace` | Diretorio de trabalho do agente |
|
| `workspace` | `~/.picoclaw/workspace` | Diretório de trabalho do agente |
|
||||||
| `restrict_to_workspace` | `true` | Restringir acesso de arquivos/comandos ao workspace |
|
| `restrict_to_workspace` | `true` | Restringir acesso de arquivos/comandos ao workspace |
|
||||||
|
|
||||||
#### Ferramentas Protegidas
|
#### Ferramentas Protegidas
|
||||||
|
|
||||||
Quando `restrict_to_workspace: true`, as seguintes ferramentas sao restritas ao sandbox:
|
Quando `restrict_to_workspace: true`, as seguintes ferramentas são restritas ao sandbox:
|
||||||
|
|
||||||
| Ferramenta | Funcao | Restricao |
|
| Ferramenta | Função | Restrição |
|
||||||
|------------|--------|-----------|
|
|------------|--------|-----------|
|
||||||
| `read_file` | Ler arquivos | Apenas arquivos dentro do workspace |
|
| `read_file` | Ler arquivos | Apenas arquivos dentro do workspace |
|
||||||
| `write_file` | Escrever arquivos | Apenas arquivos dentro do workspace |
|
| `write_file` | Escrever arquivos | Apenas arquivos dentro do workspace |
|
||||||
|
|
@ -534,13 +535,13 @@ Quando `restrict_to_workspace: true`, as seguintes ferramentas sao restritas ao
|
||||||
| `append_file` | Adicionar a arquivos | Apenas arquivos dentro do workspace |
|
| `append_file` | Adicionar a arquivos | Apenas arquivos dentro do workspace |
|
||||||
| `exec` | Executar comandos | Caminhos dos comandos devem estar dentro do workspace |
|
| `exec` | Executar comandos | Caminhos dos comandos devem estar dentro do workspace |
|
||||||
|
|
||||||
#### Protecao Adicional do Exec
|
#### Proteção Adicional do Exec
|
||||||
|
|
||||||
Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes comandos perigosos:
|
Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes comandos perigosos:
|
||||||
|
|
||||||
* `rm -rf`, `del /f`, `rmdir /s` — Exclusao em massa
|
* `rm -rf`, `del /f`, `rmdir /s` — Exclusão em massa
|
||||||
* `format`, `mkfs`, `diskpart` — Formatacao de disco
|
* `format`, `mkfs`, `diskpart` — Formatação de disco
|
||||||
* `dd if=` — Criacao de imagem de disco
|
* `dd if=` — Criação de imagem de disco
|
||||||
* Escrita em `/dev/sd[a-z]` — Escrita direta no disco
|
* Escrita em `/dev/sd[a-z]` — Escrita direta no disco
|
||||||
* `shutdown`, `reboot`, `poweroff` — Desligamento do sistema
|
* `shutdown`, `reboot`, `poweroff` — Desligamento do sistema
|
||||||
* Fork bomb `:(){ :|:& };:`
|
* Fork bomb `:(){ :|:& };:`
|
||||||
|
|
@ -557,11 +558,11 @@ Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes com
|
||||||
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
|
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Desabilitar Restricoes (Risco de Seguranca)
|
#### Desabilitar Restrições (Risco de Segurança)
|
||||||
|
|
||||||
Se voce precisa que o agente acesse caminhos fora do workspace:
|
Se você precisa que o agente acesse caminhos fora do workspace:
|
||||||
|
|
||||||
**Metodo 1: Arquivo de configuracao**
|
**Método 1: Arquivo de configuração**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -573,29 +574,29 @@ Se voce precisa que o agente acesse caminhos fora do workspace:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Metodo 2: Variavel de ambiente**
|
**Método 2: Variável de ambiente**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ **Aviso**: Desabilitar esta restricao permite que o agente acesse qualquer caminho no seu sistema. Use com cuidado apenas em ambientes controlados.
|
> ⚠️ **Aviso**: Desabilitar esta restrição permite que o agente acesse qualquer caminho no seu sistema. Use com cuidado apenas em ambientes controlados.
|
||||||
|
|
||||||
#### Consistencia do Limite de Seguranca
|
#### Consistência do Limite de Segurança
|
||||||
|
|
||||||
A configuracao `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execucao:
|
A configuração `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execução:
|
||||||
|
|
||||||
| Caminho de Execucao | Limite de Seguranca |
|
| Caminho de Execução | Limite de Segurança |
|
||||||
|----------------------|---------------------|
|
|----------------------|---------------------|
|
||||||
| Agente Principal | `restrict_to_workspace` ✅ |
|
| Agente Principal | `restrict_to_workspace` ✅ |
|
||||||
| Subagente / Spawn | Herda a mesma restricao ✅ |
|
| Subagente / Spawn | Herda a mesma restrição ✅ |
|
||||||
| Tarefas Heartbeat | Herda a mesma restricao ✅ |
|
| Tarefas Heartbeat | Herda a mesma restrição ✅ |
|
||||||
|
|
||||||
Todos os caminhos compartilham a mesma restricao de workspace — nao ha como contornar o limite de seguranca por meio de subagentes ou tarefas agendadas.
|
Todos os caminhos compartilham a mesma restrição de workspace — nao há como contornar o limite de segurança por meio de subagentes ou tarefas agendadas.
|
||||||
|
|
||||||
### Heartbeat (Tarefas Periodicas)
|
### Heartbeat (Tarefas Periódicas)
|
||||||
|
|
||||||
O PicoClaw pode executar tarefas periodicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace:
|
O PicoClaw pode executar tarefas periódicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# Tarefas Periodicas
|
# Tarefas Periodicas
|
||||||
|
|
@ -605,51 +606,51 @@ O PicoClaw pode executar tarefas periodicas automaticamente. Crie um arquivo `HE
|
||||||
- Verificar a previsao do tempo
|
- Verificar a previsao do tempo
|
||||||
```
|
```
|
||||||
|
|
||||||
O agente lera este arquivo a cada 30 minutos (configuravel) e executara as tarefas usando as ferramentas disponiveis.
|
O agente lerá este arquivo a cada 30 minutos (configurável) e executará as tarefas usando as ferramentas disponíveis.
|
||||||
|
|
||||||
#### Tarefas Assincronas com Spawn
|
#### Tarefas Assincronas com Spawn
|
||||||
|
|
||||||
Para tarefas de longa duracao (busca web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**:
|
Para tarefas de longa duração (busca web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# Tarefas Periodicas
|
# Tarefas Periódicas
|
||||||
|
|
||||||
## Tarefas Rapidas (resposta direta)
|
## Tarefas Rápidas (resposta direta)
|
||||||
- Informar hora atual
|
- Informar hora atual
|
||||||
|
|
||||||
## Tarefas Longas (usar spawn para async)
|
## Tarefas Longas (usar spawn para async)
|
||||||
- Buscar noticias de IA na web e resumir
|
- Buscar notícias de IA na web e resumir
|
||||||
- Verificar email e reportar mensagens importantes
|
- Verificar email e reportar mensagens importantes
|
||||||
```
|
```
|
||||||
|
|
||||||
**Comportamentos principais:**
|
**Comportamentos principais:**
|
||||||
|
|
||||||
| Funcionalidade | Descricao |
|
| Funcionalidade | Descrição |
|
||||||
|----------------|-----------|
|
|----------------|-----------|
|
||||||
| **spawn** | Cria subagente assincrono, nao bloqueia o heartbeat |
|
| **spawn** | Cria subagente assíncrono, não bloqueia o heartbeat |
|
||||||
| **Contexto independente** | Subagente tem seu proprio contexto, sem historico de sessao |
|
| **Contexto independente** | Subagente tem seu próprio contexto, sem histórico de sessão |
|
||||||
| **Ferramenta message** | Subagente se comunica diretamente com o usuario via ferramenta message |
|
| **Ferramenta message** | Subagente se comunica diretamente com o usuário via ferramenta message |
|
||||||
| **Nao-bloqueante** | Apos o spawn, o heartbeat continua para a proxima tarefa |
|
| **Não-bloqueante** | Após o spawn, o heartbeat continua para a próxima tarefa |
|
||||||
|
|
||||||
#### Como Funciona a Comunicacao do Subagente
|
#### Como Funciona a Comunicação do Subagente
|
||||||
|
|
||||||
```
|
```
|
||||||
Heartbeat dispara
|
Heartbeat dispara
|
||||||
↓
|
↓
|
||||||
Agente le HEARTBEAT.md
|
Agente lê HEARTBEAT.md
|
||||||
↓
|
↓
|
||||||
Para tarefa longa: spawn subagente
|
Para tarefa longa: spawn subagente
|
||||||
↓ ↓
|
↓ ↓
|
||||||
Continua proxima tarefa Subagente trabalha independentemente
|
Continua próxima tarefa Subagente trabalha independentemente
|
||||||
↓ ↓
|
↓ ↓
|
||||||
Todas tarefas concluidas Subagente usa ferramenta "message"
|
Todas tarefas concluídas Subagente usa ferramenta "message"
|
||||||
↓ ↓
|
↓ ↓
|
||||||
Responde HEARTBEAT_OK Usuario recebe resultado diretamente
|
Responde HEARTBEAT_OK Usuário recebe resultado diretamente
|
||||||
```
|
```
|
||||||
|
|
||||||
O subagente tem acesso as ferramentas (message, web_search, etc.) e pode se comunicar com o usuario independentemente sem passar pelo agente principal.
|
O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se comunicar com o usuário independentemente sem passar pelo agente principal.
|
||||||
|
|
||||||
**Configuracao:**
|
**Configuração:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -660,12 +661,12 @@ O subagente tem acesso as ferramentas (message, web_search, etc.) e pode se comu
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Opcao | Padrao | Descricao |
|
| Opção | Padrão | Descrição |
|
||||||
|-------|--------|-----------|
|
|-------|--------|-----------|
|
||||||
| `enabled` | `true` | Habilitar/desabilitar heartbeat |
|
| `enabled` | `true` | Habilitar/desabilitar heartbeat |
|
||||||
| `interval` | `30` | Intervalo de verificacao em minutos (min: 5) |
|
| `interval` | `30` | Intervalo de verificação em minutos (min: 5) |
|
||||||
|
|
||||||
**Variaveis de ambiente:**
|
**Variáveis de ambiente:**
|
||||||
|
|
||||||
* `PICOCLAW_HEARTBEAT_ENABLED=false` para desabilitar
|
* `PICOCLAW_HEARTBEAT_ENABLED=false` para desabilitar
|
||||||
* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo
|
* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo
|
||||||
|
|
@ -673,7 +674,7 @@ O subagente tem acesso as ferramentas (message, web_search, etc.) e pode se comu
|
||||||
### Provedores
|
### Provedores
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> O Groq fornece transcricao de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serao automaticamente transcritas.
|
> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serão automaticamente transcritas.
|
||||||
|
|
||||||
| Provedor | Finalidade | Obter API Key |
|
| Provedor | Finalidade | Obter API Key |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
@ -683,10 +684,10 @@ O subagente tem acesso as ferramentas (message, web_search, etc.) e pode se comu
|
||||||
| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) |
|
| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) |
|
||||||
| `deepseek` (Em teste) | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) |
|
| `deepseek` (Em teste) | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||||
| `groq` | LLM + **Transcricao de voz** (Whisper) | [console.groq.com](https://console.groq.com) |
|
| `groq` | LLM + **Transcrição de voz** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Configuracao Zhipu</b></summary>
|
<summary><b>Configuração Zhipu</b></summary>
|
||||||
|
|
||||||
**1. Obter API key**
|
**1. Obter API key**
|
||||||
|
|
||||||
|
|
@ -723,7 +724,7 @@ picoclaw agent -m "Ola, como vai?"
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Exemplo de configuracao completa</b></summary>
|
<summary><b>Exemplo de configuraçao completa</b></summary>
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -794,11 +795,11 @@ picoclaw agent -m "Ola, como vai?"
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Referencia CLI
|
## Referência CLI
|
||||||
|
|
||||||
| Comando | Descricao |
|
| Comando | Descrição |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `picoclaw onboard` | Inicializar configuracao & workspace |
|
| `picoclaw onboard` | Inicializar configuração & workspace |
|
||||||
| `picoclaw agent -m "..."` | Conversar com o agente |
|
| `picoclaw agent -m "..."` | Conversar com o agente |
|
||||||
| `picoclaw agent` | Modo de chat interativo |
|
| `picoclaw agent` | Modo de chat interativo |
|
||||||
| `picoclaw gateway` | Iniciar o gateway (para bots de chat) |
|
| `picoclaw gateway` | Iniciar o gateway (para bots de chat) |
|
||||||
|
|
@ -810,36 +811,36 @@ picoclaw agent -m "Ola, como vai?"
|
||||||
|
|
||||||
O PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`:
|
O PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`:
|
||||||
|
|
||||||
* **Lembretes unicos**: "Remind me in 10 minutes" (Me lembre em 10 minutos) → dispara uma vez apos 10min
|
* **Lembretes únicos**: "Remind me in 10 minutes" (Me lembre em 10 minutos) → dispara uma vez após 10min
|
||||||
* **Tarefas recorrentes**: "Remind me every 2 hours" (Me lembre a cada 2 horas) → dispara a cada 2 horas
|
* **Tarefas recorrentes**: "Remind me every 2 hours" (Me lembre a cada 2 horas) → dispara a cada 2 horas
|
||||||
* **Expressoes Cron**: "Remind me at 9am daily" (Me lembre as 9h todos os dias) → usa expressao cron
|
* **Expressões Cron**: "Remind me at 9am daily" (Me lembre às 9h todos os dias) → usa expressão cron
|
||||||
|
|
||||||
As tarefas sao armazenadas em `~/.picoclaw/workspace/cron/` e processadas automaticamente.
|
As tarefas são armazenadas em `~/.picoclaw/workspace/cron/` e processadas automaticamente.
|
||||||
|
|
||||||
## 🤝 Contribuir & Roadmap
|
## 🤝 Contribuir & Roadmap
|
||||||
|
|
||||||
PRs sao bem-vindos! O codigo-fonte e intencionalmente pequeno e legivel. 🤗
|
PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível. 🤗
|
||||||
|
|
||||||
Roadmap em breve...
|
Roadmap em breve...
|
||||||
|
|
||||||
Grupo de desenvolvedores em formacao. Requisito de entrada: Pelo menos 1 PR com merge.
|
Grupo de desenvolvedores em formação. Requisito de entrada: Pelo menos 1 PR com merge.
|
||||||
|
|
||||||
Grupos de usuarios:
|
Grupos de usuários:
|
||||||
|
|
||||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
## 🐛 Solucao de Problemas
|
## 🐛 Solução de Problemas
|
||||||
|
|
||||||
### Busca web mostra "API 配置问题"
|
### Busca web mostra "API 配置问题"
|
||||||
|
|
||||||
Isso e normal se voce ainda nao configurou uma API key de busca. O PicoClaw fornecera links uteis para busca manual.
|
Isso é normal se você ainda não configurou uma API key de busca. O PicoClaw fornecerá links úteis para busca manual.
|
||||||
|
|
||||||
Para habilitar a busca web:
|
Para habilitar a busca web:
|
||||||
|
|
||||||
1. **Opcao 1 (Recomendado)**: Obtenha uma API key gratuita em [https://brave.com/search/api](https://brave.com/search/api) (2000 consultas gratis/mes) para os melhores resultados.
|
1. **Opção 1 (Recomendado)**: Obtenha uma API key gratuita em [https://brave.com/search/api](https://brave.com/search/api) (2000 consultas grátis/mês) para os melhores resultados.
|
||||||
2. **Opcao 2 (Sem Cartao de Credito)**: Se voce nao tem uma key, o sistema automaticamente usa o **DuckDuckGo** como fallback (sem necessidade de key).
|
2. **Opção 2 (Sem Cartão de Crédito)**: Se você não tem uma key, o sistema automaticamente usa o **DuckDuckGo** como fallback (sem necessidade de key).
|
||||||
|
|
||||||
Adicione a key em `~/.picoclaw/config.json` se usar o Brave:
|
Adicione a key em `~/.picoclaw/config.json` se usar o Brave:
|
||||||
|
|
||||||
|
|
@ -861,21 +862,21 @@ Adicione a key em `~/.picoclaw/config.json` se usar o Brave:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Erros de filtragem de conteudo
|
### Erros de filtragem de conteúdo
|
||||||
|
|
||||||
Alguns provedores (como Zhipu) possuem filtragem de conteudo. Tente reformular sua pergunta ou use um modelo diferente.
|
Alguns provedores (como Zhipu) possuem filtragem de conteúdo. Tente reformular sua pergunta ou use um modelo diferente.
|
||||||
|
|
||||||
### Bot do Telegram diz "Conflict: terminated by other getUpdates"
|
### Bot do Telegram diz "Conflict: terminated by other getUpdates"
|
||||||
|
|
||||||
Isso acontece quando outra instancia do bot esta rodando. Certifique-se de que apenas um `picoclaw gateway` esteja rodando por vez.
|
Isso acontece quando outra instância do bot está em execução. Certifique-se de que apenas um `picoclaw gateway` esteja rodando por vez.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 Comparacao de API Keys
|
## 📝 Comparação de API Keys
|
||||||
|
|
||||||
| Servico | Plano Gratuito | Caso de Uso |
|
| Serviço | Plano Gratuito | Caso de Uso |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **OpenRouter** | 200K tokens/mes | Multiplos modelos (Claude, GPT-4, etc.) |
|
| **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) |
|
||||||
| **Zhipu** | 200K tokens/mes | Melhor para usuarios chineses |
|
| **Zhipu** | 200K tokens/mês | Melhor para usuários chineses |
|
||||||
| **Brave Search** | 2000 consultas/mes | Funcionalidade de busca web |
|
| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web |
|
||||||
| **Groq** | Plano gratuito disponivel | Inferencia ultra-rapida (Llama, Mixtral) |
|
| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) |
|
||||||
|
|
|
||||||
859
README.vi.md
Normal file
859
README.vi.md
Normal file
|
|
@ -0,0 +1,859 @@
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
|
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
|
||||||
|
|
||||||
|
<h3>Phần cứng $10 · RAM 10MB · Khởi động 1 giây · 皮皮虾,我们走!</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
||||||
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
|
<br>
|
||||||
|
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [English](README.md)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🦐 **PicoClaw** là trợ lý AI cá nhân siêu nhẹ, lấy cảm hứng từ [nanobot](https://github.com/HKUDS/nanobot), được viết lại hoàn toàn bằng **Go** thông qua quá trình "tự khởi tạo" (self-bootstrapping) — nơi chính AI Agent đã tự dẫn dắt toàn bộ quá trình chuyển đổi kiến trúc và tối ưu hóa mã nguồn.
|
||||||
|
|
||||||
|
⚡️ **Cực kỳ nhẹ:** Chạy trên phần cứng chỉ **$10** với RAM **<10MB**. Tiết kiệm 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini!
|
||||||
|
|
||||||
|
<table align="center">
|
||||||
|
<tr align="center">
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/licheervnano.png" width="400" height="240">
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **🚨 TUYÊN BỐ BẢO MẬT & KÊNH CHÍNH THỨC**
|
||||||
|
>
|
||||||
|
> * **KHÔNG CÓ CRYPTO:** PicoClaw **KHÔNG** có bất kỳ token/coin chính thức nào. Mọi thông tin trên `pump.fun` hoặc các sàn giao dịch khác đều là **LỪA ĐẢO**.
|
||||||
|
> * **DOMAIN CHÍNH THỨC:** Website chính thức **DUY NHẤT** là **[picoclaw.io](https://picoclaw.io)**, website công ty là **[sipeed.com](https://sipeed.com)**.
|
||||||
|
> * **Cảnh báo:** Nhiều tên miền `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký, không phải của chúng tôi.
|
||||||
|
> * **Cảnh báo:** PicoClaw đang trong giai đoạn phát triển sớm và có thể còn các vấn đề bảo mật mạng chưa được giải quyết. Không nên triển khai lên môi trường production trước phiên bản v1.0.
|
||||||
|
> * **Lưu ý:** PicoClaw gần đây đã merge nhiều PR, dẫn đến bộ nhớ sử dụng có thể lớn hơn (10–20MB) ở các phiên bản mới nhất. Chúng tôi sẽ ưu tiên tối ưu tài nguyên khi bộ tính năng đã ổn định.
|
||||||
|
|
||||||
|
|
||||||
|
## 📢 Tin tức
|
||||||
|
|
||||||
|
2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/picoclaw_community_roadmap_260216.md) — rất mong đón nhận sự tham gia của bạn!
|
||||||
|
|
||||||
|
2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
|
||||||
|
🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
|
||||||
|
|
||||||
|
2026-02-09 🎉 PicoClaw chính thức ra mắt! Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường!
|
||||||
|
|
||||||
|
## ✨ Tính năng nổi bật
|
||||||
|
|
||||||
|
🪶 **Siêu nhẹ**: Bộ nhớ sử dụng <10MB — nhỏ hơn 99% so với Clawdbot (chức năng cốt lõi).
|
||||||
|
|
||||||
|
💰 **Chi phí tối thiểu**: Đủ hiệu quả để chạy trên phần cứng $10 — rẻ hơn 98% so với Mac mini.
|
||||||
|
|
||||||
|
⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz.
|
||||||
|
|
||||||
|
🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM và x86. Một click là chạy!
|
||||||
|
|
||||||
|
🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.
|
||||||
|
|
||||||
|
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||||
|
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
|
||||||
|
| **Ngôn ngữ** | TypeScript | Python | **Go** |
|
||||||
|
| **RAM** | >1GB | >100MB | **< 10MB** |
|
||||||
|
| **Thời gian khởi động**</br>(CPU 0.8GHz) | >500s | >30s | **<1s** |
|
||||||
|
| **Chi phí** | Mac Mini $599 | Hầu hết SBC Linux ~$50 | **Mọi bo mạch Linux**</br>**Chỉ từ $10** |
|
||||||
|
|
||||||
|
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
|
## 🦾 Demo
|
||||||
|
|
||||||
|
### 🛠️ Quy trình trợ lý tiêu chuẩn
|
||||||
|
|
||||||
|
<table align="center">
|
||||||
|
<tr align="center">
|
||||||
|
<th><p align="center">🧩 Lập trình Full-Stack</p></th>
|
||||||
|
<th><p align="center">🗂️ Quản lý Nhật ký & Kế hoạch</p></th>
|
||||||
|
<th><p align="center">🔎 Tìm kiếm Web & Học hỏi</p></th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||||
|
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||||
|
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">Phát triển • Triển khai • Mở rộng</td>
|
||||||
|
<td align="center">Lên lịch • Tự động hóa • Ghi nhớ</td>
|
||||||
|
<td align="center">Khám phá • Phân tích • Xu hướng</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### 🐜 Triển khai sáng tạo trên phần cứng tối thiểu
|
||||||
|
|
||||||
|
PicoClaw có thể triển khai trên hầu hết mọi thiết bị Linux!
|
||||||
|
|
||||||
|
* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) phiên bản E (Ethernet) hoặc W (WiFi6), dùng làm Trợ lý Gia đình tối giản.
|
||||||
|
* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), hoặc $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), dùng cho quản trị Server tự động.
|
||||||
|
* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) hoặc $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), dùng cho Giám sát thông minh.
|
||||||
|
|
||||||
|
https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4
|
||||||
|
|
||||||
|
🌟 Nhiều hình thức triển khai hơn đang chờ bạn khám phá!
|
||||||
|
|
||||||
|
## 📦 Cài đặt
|
||||||
|
|
||||||
|
### Cài đặt bằng binary biên dịch sẵn
|
||||||
|
|
||||||
|
Tải file binary cho nền tảng của bạn từ [trang Release](https://github.com/sipeed/picoclaw/releases).
|
||||||
|
|
||||||
|
### Cài đặt từ mã nguồn (có tính năng mới nhất, khuyên dùng cho phát triển)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
||||||
|
cd picoclaw
|
||||||
|
make deps
|
||||||
|
|
||||||
|
# Build (không cần cài đặt)
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Build cho nhiều nền tảng
|
||||||
|
make build-all
|
||||||
|
|
||||||
|
# Build và cài đặt
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐳 Docker Compose
|
||||||
|
|
||||||
|
Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cài đặt gì trên máy.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone repo
|
||||||
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
cd picoclaw
|
||||||
|
|
||||||
|
# 2. Thiết lập API Key
|
||||||
|
cp config/config.example.json config/config.json
|
||||||
|
vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v.
|
||||||
|
|
||||||
|
# 3. Build & Khởi động
|
||||||
|
docker compose --profile gateway up -d
|
||||||
|
|
||||||
|
# 4. Xem logs
|
||||||
|
docker compose logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
# 5. Dừng
|
||||||
|
docker compose --profile gateway down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chế độ Agent (chạy một lần)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Đặt câu hỏi
|
||||||
|
docker compose run --rm picoclaw-agent -m "2+2 bằng mấy?"
|
||||||
|
|
||||||
|
# Chế độ tương tác
|
||||||
|
docker compose run --rm picoclaw-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build lại
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile gateway build --no-cache
|
||||||
|
docker compose --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🚀 Bắt đầu nhanh
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Thiết lập API key trong `~/.picoclaw/config.json`.
|
||||||
|
> Lấy API key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||||
|
> Tìm kiếm web là **tùy chọn** — lấy [Brave Search API](https://brave.com/search/api) miễn phí (2000 truy vấn/tháng) hoặc dùng tính năng auto fallback tích hợp sẵn.
|
||||||
|
|
||||||
|
**1. Khởi tạo**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw onboard
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Cấu hình** (`~/.picoclaw/config.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "~/.picoclaw/workspace",
|
||||||
|
"model": "glm-4.7",
|
||||||
|
"max_tokens": 8192,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tool_iterations": 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"openrouter": {
|
||||||
|
"api_key": "xxx",
|
||||||
|
"api_base": "https://openrouter.ai/api/v1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"brave": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"duckduckgo": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Lấy API Key**
|
||||||
|
|
||||||
|
* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
|
* **Tìm kiếm Web** (tùy chọn): [Brave Search](https://brave.com/search/api) — Có gói miễn phí (2000 truy vấn/tháng)
|
||||||
|
|
||||||
|
> **Lưu ý**: Xem `config.example.json` để có mẫu cấu hình đầy đủ.
|
||||||
|
|
||||||
|
**4. Trò chuyện**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw agent -m "Xin chào, bạn là ai?"
|
||||||
|
```
|
||||||
|
|
||||||
|
Vậy là xong! Bạn đã có một trợ lý AI hoạt động chỉ trong 2 phút.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 Tích hợp ứng dụng Chat
|
||||||
|
|
||||||
|
Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk hoặc LINE.
|
||||||
|
|
||||||
|
| Kênh | Mức độ thiết lập |
|
||||||
|
| --- | --- |
|
||||||
|
| **Telegram** | Dễ (chỉ cần token) |
|
||||||
|
| **Discord** | Dễ (bot token + intents) |
|
||||||
|
| **QQ** | Dễ (AppID + AppSecret) |
|
||||||
|
| **DingTalk** | Trung bình (app credentials) |
|
||||||
|
| **LINE** | Trung bình (credentials + webhook URL) |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Telegram</b> (Khuyên dùng)</summary>
|
||||||
|
|
||||||
|
**1. Tạo bot**
|
||||||
|
|
||||||
|
* Mở Telegram, tìm `@BotFather`
|
||||||
|
* Gửi `/newbot`, làm theo hướng dẫn
|
||||||
|
* Sao chép token
|
||||||
|
|
||||||
|
**2. Cấu hình**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_BOT_TOKEN",
|
||||||
|
"allow_from": ["YOUR_USER_ID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Lấy User ID từ `@userinfobot` trên Telegram.
|
||||||
|
|
||||||
|
**3. Chạy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Discord</b></summary>
|
||||||
|
|
||||||
|
**1. Tạo bot**
|
||||||
|
|
||||||
|
* Truy cập <https://discord.com/developers/applications>
|
||||||
|
* Create an application → Bot → Add Bot
|
||||||
|
* Sao chép bot token
|
||||||
|
|
||||||
|
**2. Bật Intents**
|
||||||
|
|
||||||
|
* Trong phần Bot settings, bật **MESSAGE CONTENT INTENT**
|
||||||
|
* (Tùy chọn) Bật **SERVER MEMBERS INTENT** nếu muốn dùng danh sách cho phép theo thông tin thành viên
|
||||||
|
|
||||||
|
**3. Lấy User ID**
|
||||||
|
|
||||||
|
* Discord Settings → Advanced → bật **Developer Mode**
|
||||||
|
* Click chuột phải vào avatar → **Copy User ID**
|
||||||
|
|
||||||
|
**4. Cấu hình**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"discord": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_BOT_TOKEN",
|
||||||
|
"allow_from": ["YOUR_USER_ID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Mời bot vào server**
|
||||||
|
|
||||||
|
* OAuth2 → URL Generator
|
||||||
|
* Scopes: `bot`
|
||||||
|
* Bot Permissions: `Send Messages`, `Read Message History`
|
||||||
|
* Mở URL mời được tạo và thêm bot vào server của bạn
|
||||||
|
|
||||||
|
**6. Chạy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>QQ</b></summary>
|
||||||
|
|
||||||
|
**1. Tạo bot**
|
||||||
|
|
||||||
|
* Truy cập [QQ Open Platform](https://q.qq.com/#)
|
||||||
|
* Tạo ứng dụng → Lấy **AppID** và **AppSecret**
|
||||||
|
|
||||||
|
**2. Cấu hình**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"qq": {
|
||||||
|
"enabled": true,
|
||||||
|
"app_id": "YOUR_APP_ID",
|
||||||
|
"app_secret": "YOUR_APP_SECRET",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Để `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định số QQ để giới hạn quyền truy cập.
|
||||||
|
|
||||||
|
**3. Chạy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>DingTalk</b></summary>
|
||||||
|
|
||||||
|
**1. Tạo bot**
|
||||||
|
|
||||||
|
* Truy cập [Open Platform](https://open.dingtalk.com/)
|
||||||
|
* Tạo ứng dụng nội bộ
|
||||||
|
* Sao chép Client ID và Client Secret
|
||||||
|
|
||||||
|
**2. Cấu hình**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"dingtalk": {
|
||||||
|
"enabled": true,
|
||||||
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Để `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định ID để giới hạn quyền truy cập.
|
||||||
|
|
||||||
|
**3. Chạy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>LINE</b></summary>
|
||||||
|
|
||||||
|
**1. Tạo tài khoản LINE Official**
|
||||||
|
|
||||||
|
- Truy cập [LINE Developers Console](https://developers.line.biz/)
|
||||||
|
- Tạo provider → Tạo Messaging API channel
|
||||||
|
- Sao chép **Channel Secret** và **Channel Access Token**
|
||||||
|
|
||||||
|
**2. Cấu hình**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"line": {
|
||||||
|
"enabled": true,
|
||||||
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18791,
|
||||||
|
"webhook_path": "/webhook/line",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Thiết lập Webhook URL**
|
||||||
|
|
||||||
|
LINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ví dụ với ngrok
|
||||||
|
ngrok http 18791
|
||||||
|
```
|
||||||
|
|
||||||
|
Sau đó cài đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**.
|
||||||
|
|
||||||
|
**4. Chạy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> Trong nhóm chat, bot chỉ phản hồi khi được @mention. Các câu trả lời sẽ trích dẫn tin nhắn gốc.
|
||||||
|
|
||||||
|
> **Docker Compose**: Thêm `ports: ["18791:18791"]` vào service `picoclaw-gateway` để mở port webhook.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Tham gia Mạng xã hội Agent
|
||||||
|
|
||||||
|
Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn qua CLI hoặc bất kỳ ứng dụng Chat nào đã tích hợp.
|
||||||
|
|
||||||
|
**Đọc `https://clawdchat.ai/skill.md` và làm theo hướng dẫn để tham gia [ClawdChat.ai](https://clawdchat.ai)**
|
||||||
|
|
||||||
|
## ⚙️ Cấu hình chi tiết
|
||||||
|
|
||||||
|
File cấu hình: `~/.picoclaw/config.json`
|
||||||
|
|
||||||
|
### Cấu trúc Workspace
|
||||||
|
|
||||||
|
PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`):
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.picoclaw/workspace/
|
||||||
|
├── sessions/ # Phiên hội thoại và lịch sử
|
||||||
|
├── memory/ # Bộ nhớ dài hạn (MEMORY.md)
|
||||||
|
├── state/ # Trạng thái lưu trữ (kênh cuối cùng, v.v.)
|
||||||
|
├── cron/ # Cơ sở dữ liệu tác vụ định kỳ
|
||||||
|
├── skills/ # Kỹ năng tùy chỉnh
|
||||||
|
├── AGENTS.md # Hướng dẫn hành vi Agent
|
||||||
|
├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút)
|
||||||
|
├── IDENTITY.md # Danh tính Agent
|
||||||
|
├── SOUL.md # Tâm hồn/Tính cách Agent
|
||||||
|
├── TOOLS.md # Mô tả công cụ
|
||||||
|
└── USER.md # Tùy chọn người dùng
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔒 Hộp cát bảo mật (Security Sandbox)
|
||||||
|
|
||||||
|
PicoClaw chạy trong môi trường sandbox theo mặc định. Agent chỉ có thể truy cập file và thực thi lệnh trong phạm vi workspace.
|
||||||
|
|
||||||
|
#### Cấu hình mặc định
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "~/.picoclaw/workspace",
|
||||||
|
"restrict_to_workspace": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Tùy chọn | Mặc định | Mô tả |
|
||||||
|
|----------|---------|-------|
|
||||||
|
| `workspace` | `~/.picoclaw/workspace` | Thư mục làm việc của agent |
|
||||||
|
| `restrict_to_workspace` | `true` | Giới hạn truy cập file/lệnh trong workspace |
|
||||||
|
|
||||||
|
#### Công cụ được bảo vệ
|
||||||
|
|
||||||
|
Khi `restrict_to_workspace: true`, các công cụ sau bị giới hạn trong sandbox:
|
||||||
|
|
||||||
|
| Công cụ | Chức năng | Giới hạn |
|
||||||
|
|---------|----------|---------|
|
||||||
|
| `read_file` | Đọc file | Chỉ file trong workspace |
|
||||||
|
| `write_file` | Ghi file | Chỉ file trong workspace |
|
||||||
|
| `list_dir` | Liệt kê thư mục | Chỉ thư mục trong workspace |
|
||||||
|
| `edit_file` | Sửa file | Chỉ file trong workspace |
|
||||||
|
| `append_file` | Thêm vào file | Chỉ file trong workspace |
|
||||||
|
| `exec` | Thực thi lệnh | Đường dẫn lệnh phải trong workspace |
|
||||||
|
|
||||||
|
#### Bảo vệ bổ sung cho Exec
|
||||||
|
|
||||||
|
Ngay cả khi `restrict_to_workspace: false`, công cụ `exec` vẫn chặn các lệnh nguy hiểm sau:
|
||||||
|
|
||||||
|
* `rm -rf`, `del /f`, `rmdir /s` — Xóa hàng loạt
|
||||||
|
* `format`, `mkfs`, `diskpart` — Định dạng ổ đĩa
|
||||||
|
* `dd if=` — Tạo ảnh đĩa
|
||||||
|
* Ghi vào `/dev/sd[a-z]` — Ghi trực tiếp lên đĩa
|
||||||
|
* `shutdown`, `reboot`, `poweroff` — Tắt/khởi động lại hệ thống
|
||||||
|
* Fork bomb `:(){ :|:& };:`
|
||||||
|
|
||||||
|
#### Ví dụ lỗi
|
||||||
|
|
||||||
|
```
|
||||||
|
[ERROR] tool: Tool execution failed
|
||||||
|
{tool=exec, error=Command blocked by safety guard (path outside working dir)}
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
[ERROR] tool: Tool execution failed
|
||||||
|
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Tắt giới hạn (Rủi ro bảo mật)
|
||||||
|
|
||||||
|
Nếu bạn cần agent truy cập đường dẫn ngoài workspace:
|
||||||
|
|
||||||
|
**Cách 1: File cấu hình**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"restrict_to_workspace": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cách 2: Biến môi trường**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **Cảnh báo**: Tắt giới hạn này cho phép agent truy cập mọi đường dẫn trên hệ thống. Chỉ sử dụng cẩn thận trong môi trường được kiểm soát.
|
||||||
|
|
||||||
|
#### Tính nhất quán của ranh giới bảo mật
|
||||||
|
|
||||||
|
Cài đặt `restrict_to_workspace` áp dụng nhất quán trên mọi đường thực thi:
|
||||||
|
|
||||||
|
| Đường thực thi | Ranh giới bảo mật |
|
||||||
|
|----------------|-------------------|
|
||||||
|
| Agent chính | `restrict_to_workspace` ✅ |
|
||||||
|
| Subagent / Spawn | Kế thừa cùng giới hạn ✅ |
|
||||||
|
| Tác vụ Heartbeat | Kế thừa cùng giới hạn ✅ |
|
||||||
|
|
||||||
|
Tất cả đường thực thi chia sẻ cùng giới hạn workspace — không có cách nào vượt qua ranh giới bảo mật thông qua subagent hoặc tác vụ định kỳ.
|
||||||
|
|
||||||
|
### Heartbeat (Tác vụ định kỳ)
|
||||||
|
|
||||||
|
PicoClaw có thể tự động thực hiện các tác vụ định kỳ. Tạo file `HEARTBEAT.md` trong workspace:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Tác vụ định kỳ
|
||||||
|
|
||||||
|
- Kiểm tra email xem có tin nhắn quan trọng không
|
||||||
|
- Xem lại lịch cho các sự kiện sắp tới
|
||||||
|
- Kiểm tra dự báo thời tiết
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent sẽ đọc file này mỗi 30 phút (có thể cấu hình) và thực hiện các tác vụ bằng công cụ có sẵn.
|
||||||
|
|
||||||
|
#### Tác vụ bất đồng bộ với Spawn
|
||||||
|
|
||||||
|
Đối với các tác vụ chạy lâu (tìm kiếm web, gọi API), sử dụng công cụ `spawn` để tạo **subagent**:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Tác vụ định kỳ
|
||||||
|
|
||||||
|
## Tác vụ nhanh (trả lời trực tiếp)
|
||||||
|
- Báo cáo thời gian hiện tại
|
||||||
|
|
||||||
|
## Tác vụ lâu (dùng spawn cho async)
|
||||||
|
- Tìm kiếm tin tức AI trên web và tóm tắt
|
||||||
|
- Kiểm tra email và báo cáo tin nhắn quan trọng
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hành vi chính:**
|
||||||
|
|
||||||
|
| Tính năng | Mô tả |
|
||||||
|
|-----------|-------|
|
||||||
|
| **spawn** | Tạo subagent bất đồng bộ, không chặn heartbeat |
|
||||||
|
| **Context độc lập** | Subagent có context riêng, không có lịch sử phiên |
|
||||||
|
| **message tool** | Subagent giao tiếp trực tiếp với người dùng qua công cụ message |
|
||||||
|
| **Không chặn** | Sau khi spawn, heartbeat tiếp tục tác vụ tiếp theo |
|
||||||
|
|
||||||
|
#### Cách Subagent giao tiếp
|
||||||
|
|
||||||
|
```
|
||||||
|
Heartbeat kích hoạt
|
||||||
|
↓
|
||||||
|
Agent đọc HEARTBEAT.md
|
||||||
|
↓
|
||||||
|
Tác vụ lâu: spawn subagent
|
||||||
|
↓ ↓
|
||||||
|
Tiếp tục tác vụ tiếp theo Subagent làm việc độc lập
|
||||||
|
↓ ↓
|
||||||
|
Tất cả tác vụ hoàn thành Subagent dùng công cụ "message"
|
||||||
|
↓ ↓
|
||||||
|
Phản hồi HEARTBEAT_OK Người dùng nhận kết quả trực tiếp
|
||||||
|
```
|
||||||
|
|
||||||
|
Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và có thể giao tiếp với người dùng một cách độc lập mà không cần thông qua agent chính.
|
||||||
|
|
||||||
|
**Cấu hình:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"heartbeat": {
|
||||||
|
"enabled": true,
|
||||||
|
"interval": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Tùy chọn | Mặc định | Mô tả |
|
||||||
|
|----------|---------|-------|
|
||||||
|
| `enabled` | `true` | Bật/tắt heartbeat |
|
||||||
|
| `interval` | `30` | Khoảng thời gian kiểm tra (phút, tối thiểu: 5) |
|
||||||
|
|
||||||
|
**Biến môi trường:**
|
||||||
|
|
||||||
|
* `PICOCLAW_HEARTBEAT_ENABLED=false` để tắt
|
||||||
|
* `PICOCLAW_HEARTBEAT_INTERVAL=60` để thay đổi khoảng thời gian
|
||||||
|
|
||||||
|
### Nhà cung cấp (Providers)
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn thoại trên Telegram sẽ được tự động chuyển thành văn bản.
|
||||||
|
|
||||||
|
| Nhà cung cấp | Mục đích | Lấy API Key |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `gemini` | LLM (Gemini trực tiếp) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
|
| `zhipu` | LLM (Zhipu trực tiếp) | [bigmodel.cn](bigmodel.cn) |
|
||||||
|
| `openrouter` (Đang thử nghiệm) | LLM (khuyên dùng, truy cập mọi model) | [openrouter.ai](https://openrouter.ai) |
|
||||||
|
| `anthropic` (Đang thử nghiệm) | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
|
| `openai` (Đang thử nghiệm) | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) |
|
||||||
|
| `deepseek` (Đang thử nghiệm) | LLM (DeepSeek trực tiếp) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||||
|
| `groq` | LLM + **Chuyển giọng nói** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Cấu hình Zhipu</b></summary>
|
||||||
|
|
||||||
|
**1. Lấy API key**
|
||||||
|
|
||||||
|
* Lấy [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||||
|
|
||||||
|
**2. Cấu hình**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "~/.picoclaw/workspace",
|
||||||
|
"model": "glm-4.7",
|
||||||
|
"max_tokens": 8192,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tool_iterations": 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"zhipu": {
|
||||||
|
"api_key": "Your API Key",
|
||||||
|
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Chạy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw agent -m "Xin chào"
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Ví dụ cấu hình đầy đủ</b></summary>
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "anthropic/claude-opus-4-5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"openrouter": {
|
||||||
|
"api_key": "sk-or-v1-xxx"
|
||||||
|
},
|
||||||
|
"groq": {
|
||||||
|
"api_key": "gsk_xxx"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "123456:ABC...",
|
||||||
|
"allow_from": ["123456789"]
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "",
|
||||||
|
"allow_from": [""]
|
||||||
|
},
|
||||||
|
"whatsapp": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"feishu": {
|
||||||
|
"enabled": false,
|
||||||
|
"app_id": "cli_xxx",
|
||||||
|
"app_secret": "xxx",
|
||||||
|
"encrypt_key": "",
|
||||||
|
"verification_token": "",
|
||||||
|
"allow_from": []
|
||||||
|
},
|
||||||
|
"qq": {
|
||||||
|
"enabled": false,
|
||||||
|
"app_id": "",
|
||||||
|
"app_secret": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"brave": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "BSA...",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"duckduckgo": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"heartbeat": {
|
||||||
|
"enabled": true,
|
||||||
|
"interval": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Tham chiếu CLI
|
||||||
|
|
||||||
|
| Lệnh | Mô tả |
|
||||||
|
| --- | --- |
|
||||||
|
| `picoclaw onboard` | Khởi tạo cấu hình & workspace |
|
||||||
|
| `picoclaw agent -m "..."` | Trò chuyện với agent |
|
||||||
|
| `picoclaw agent` | Chế độ chat tương tác |
|
||||||
|
| `picoclaw gateway` | Khởi động gateway (cho bot chat) |
|
||||||
|
| `picoclaw status` | Hiển thị trạng thái |
|
||||||
|
| `picoclaw cron list` | Liệt kê tất cả tác vụ định kỳ |
|
||||||
|
| `picoclaw cron add ...` | Thêm tác vụ định kỳ |
|
||||||
|
|
||||||
|
### Tác vụ định kỳ / Nhắc nhở
|
||||||
|
|
||||||
|
PicoClaw hỗ trợ nhắc nhở theo lịch và tác vụ lặp lại thông qua công cụ `cron`:
|
||||||
|
|
||||||
|
* **Nhắc nhở một lần**: "Remind me in 10 minutes" (Nhắc tôi sau 10 phút) → kích hoạt một lần sau 10 phút
|
||||||
|
* **Tác vụ lặp lại**: "Remind me every 2 hours" (Nhắc tôi mỗi 2 giờ) → kích hoạt mỗi 2 giờ
|
||||||
|
* **Biểu thức Cron**: "Remind me at 9am daily" (Nhắc tôi lúc 9 giờ sáng mỗi ngày) → sử dụng biểu thức cron
|
||||||
|
|
||||||
|
Các tác vụ được lưu trong `~/.picoclaw/workspace/cron/` và được xử lý tự động.
|
||||||
|
|
||||||
|
## 🤝 Đóng góp & Lộ trình
|
||||||
|
|
||||||
|
Chào đón mọi PR! Mã nguồn được thiết kế nhỏ gọn và dễ đọc. 🤗
|
||||||
|
|
||||||
|
Lộ trình sắp được công bố...
|
||||||
|
|
||||||
|
Nhóm phát triển đang được xây dựng. Điều kiện tham gia: Ít nhất 1 PR đã được merge.
|
||||||
|
|
||||||
|
Nhóm người dùng:
|
||||||
|
|
||||||
|
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
|
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
|
## 🐛 Xử lý sự cố
|
||||||
|
|
||||||
|
### Tìm kiếm web hiện "API 配置问题"
|
||||||
|
|
||||||
|
Điều này là bình thường nếu bạn chưa cấu hình API key cho tìm kiếm. PicoClaw sẽ cung cấp các liên kết hữu ích để tìm kiếm thủ công.
|
||||||
|
|
||||||
|
Để bật tìm kiếm web:
|
||||||
|
|
||||||
|
1. **Tùy chọn 1 (Khuyên dùng)**: Lấy API key miễn phí tại [https://brave.com/search/api](https://brave.com/search/api) (2000 truy vấn miễn phí/tháng) để có kết quả tốt nhất.
|
||||||
|
2. **Tùy chọn 2 (Không cần thẻ tín dụng)**: Nếu không có key, hệ thống tự động chuyển sang dùng **DuckDuckGo** (không cần key).
|
||||||
|
|
||||||
|
Thêm key vào `~/.picoclaw/config.json` nếu dùng Brave:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"brave": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"duckduckgo": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gặp lỗi lọc nội dung (Content Filtering)
|
||||||
|
|
||||||
|
Một số nhà cung cấp (như Zhipu) có bộ lọc nội dung nghiêm ngặt. Thử diễn đạt lại câu hỏi hoặc sử dụng model khác.
|
||||||
|
|
||||||
|
### Telegram bot báo "Conflict: terminated by other getUpdates"
|
||||||
|
|
||||||
|
Điều này xảy ra khi có một instance bot khác đang chạy. Đảm bảo chỉ có một tiến trình `picoclaw gateway` chạy tại một thời điểm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 So sánh API Key
|
||||||
|
|
||||||
|
| Dịch vụ | Gói miễn phí | Trường hợp sử dụng |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) |
|
||||||
|
| **Zhipu** | 200K tokens/tháng | Tốt nhất cho người dùng Trung Quốc |
|
||||||
|
| **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web |
|
||||||
|
| **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) |
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Français](README.fr.md) | [English](README.md)
|
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -291,7 +291,7 @@ picoclaw agent -m "2+2 等于几?"
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allowFrom": ["YOUR_USER_ID"]
|
"allow_from": ["YOUR_USER_ID"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -336,7 +336,7 @@ picoclaw gateway
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allowFrom": ["YOUR_USER_ID"]
|
"allow_from": ["YOUR_USER_ID"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 141 KiB |
|
|
@ -563,7 +563,7 @@ func gatewayCmd() {
|
||||||
|
|
||||||
// Setup cron tool and service
|
// Setup cron tool and service
|
||||||
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
||||||
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout)
|
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg)
|
||||||
|
|
||||||
heartbeatService := heartbeat.NewHeartbeatService(
|
heartbeatService := heartbeat.NewHeartbeatService(
|
||||||
cfg.WorkspacePath(),
|
cfg.WorkspacePath(),
|
||||||
|
|
@ -988,14 +988,14 @@ func getConfigPath() string {
|
||||||
return filepath.Join(home, ".picoclaw", "config.json")
|
return filepath.Join(home, ".picoclaw", "config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *cron.CronService {
|
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *cron.CronService {
|
||||||
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
||||||
|
|
||||||
// Create cron service
|
// Create cron service
|
||||||
cronService := cron.NewCronService(cronStorePath, nil)
|
cronService := cron.NewCronService(cronStorePath, nil)
|
||||||
|
|
||||||
// Create and register CronTool
|
// Create and register CronTool
|
||||||
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout)
|
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, config)
|
||||||
agentLoop.RegisterTool(cronTool)
|
agentLoop.RegisterTool(cronTool)
|
||||||
|
|
||||||
// Set the onJob handler
|
// Set the onJob handler
|
||||||
|
|
|
||||||
122
docs/tools_configuration.md
Normal file
122
docs/tools_configuration.md
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
# Tools Configuration
|
||||||
|
|
||||||
|
PicoClaw's tools configuration is located in the `tools` field of `config.json`.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": { ... },
|
||||||
|
"exec": { ... },
|
||||||
|
"approval": { ... },
|
||||||
|
"cron": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web Tools
|
||||||
|
|
||||||
|
Web tools are used for web search and fetching.
|
||||||
|
|
||||||
|
### Brave
|
||||||
|
|
||||||
|
| Config | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `enabled` | bool | false | Enable Brave search |
|
||||||
|
| `api_key` | string | - | Brave Search API key |
|
||||||
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
||||||
|
### DuckDuckGo
|
||||||
|
|
||||||
|
| Config | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `enabled` | bool | true | Enable DuckDuckGo search |
|
||||||
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
||||||
|
### Perplexity
|
||||||
|
|
||||||
|
| Config | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `enabled` | bool | false | Enable Perplexity search |
|
||||||
|
| `api_key` | string | - | Perplexity API key |
|
||||||
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
||||||
|
## Exec Tool
|
||||||
|
|
||||||
|
The exec tool is used to execute shell commands.
|
||||||
|
|
||||||
|
| Config | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
||||||
|
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
||||||
|
|
||||||
|
### Functionality
|
||||||
|
|
||||||
|
- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns
|
||||||
|
- **`custom_deny_patterns`**: Add custom deny regex patterns; commands matching these will be blocked
|
||||||
|
|
||||||
|
### Default Blocked Command Patterns
|
||||||
|
|
||||||
|
By default, PicoClaw blocks the following dangerous commands:
|
||||||
|
|
||||||
|
- Delete commands: `rm -rf`, `del /f/q`, `rmdir /s`
|
||||||
|
- Disk operations: `format`, `mkfs`, `diskpart`, `dd if=`, writing to `/dev/sd*`
|
||||||
|
- System operations: `shutdown`, `reboot`, `poweroff`
|
||||||
|
- Command substitution: `$()`, `${}`, backticks
|
||||||
|
- Pipe to shell: `| sh`, `| bash`
|
||||||
|
- Privilege escalation: `sudo`, `chmod`, `chown`
|
||||||
|
- Process control: `pkill`, `killall`, `kill -9`
|
||||||
|
- Remote operations: `curl | sh`, `wget | sh`, `ssh`
|
||||||
|
- Package management: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user`
|
||||||
|
- Containers: `docker run`, `docker exec`
|
||||||
|
- Git: `git push`, `git force`
|
||||||
|
- Other: `eval`, `source *.sh`
|
||||||
|
|
||||||
|
### Configuration Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"exec": {
|
||||||
|
"enable_deny_patterns": true,
|
||||||
|
"custom_deny_patterns": [
|
||||||
|
"\\brm\\s+-r\\b",
|
||||||
|
"\\bkillall\\s+python"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Approval Tool
|
||||||
|
|
||||||
|
The approval tool controls permissions for dangerous operations.
|
||||||
|
|
||||||
|
| Config | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `enabled` | bool | true | Enable approval functionality |
|
||||||
|
| `write_file` | bool | true | Require approval for file writes |
|
||||||
|
| `edit_file` | bool | true | Require approval for file edits |
|
||||||
|
| `append_file` | bool | true | Require approval for file appends |
|
||||||
|
| `exec` | bool | true | Require approval for command execution |
|
||||||
|
| `timeout_minutes` | int | 5 | Approval timeout in minutes |
|
||||||
|
|
||||||
|
## Cron Tool
|
||||||
|
|
||||||
|
The cron tool is used for scheduling periodic tasks.
|
||||||
|
|
||||||
|
| Config | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_<SECTION>_<KEY>`:
|
||||||
|
|
||||||
|
For example:
|
||||||
|
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
||||||
|
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
||||||
|
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
||||||
|
|
||||||
|
Note: Array-type environment variables are not currently supported and must be set via the config file.
|
||||||
145
pkg/agent/instance.go
Normal file
145
pkg/agent/instance.go
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentInstance represents a fully configured agent with its own workspace,
|
||||||
|
// session manager, context builder, and tool registry.
|
||||||
|
type AgentInstance struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Model string
|
||||||
|
Fallbacks []string
|
||||||
|
Workspace string
|
||||||
|
MaxIterations int
|
||||||
|
ContextWindow int
|
||||||
|
Provider providers.LLMProvider
|
||||||
|
Sessions *session.SessionManager
|
||||||
|
ContextBuilder *ContextBuilder
|
||||||
|
Tools *tools.ToolRegistry
|
||||||
|
Subagents *config.SubagentsConfig
|
||||||
|
SkillsFilter []string
|
||||||
|
Candidates []providers.FallbackCandidate
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAgentInstance creates an agent instance from config.
|
||||||
|
func NewAgentInstance(
|
||||||
|
agentCfg *config.AgentConfig,
|
||||||
|
defaults *config.AgentDefaults,
|
||||||
|
cfg *config.Config,
|
||||||
|
provider providers.LLMProvider,
|
||||||
|
) *AgentInstance {
|
||||||
|
workspace := resolveAgentWorkspace(agentCfg, defaults)
|
||||||
|
os.MkdirAll(workspace, 0755)
|
||||||
|
|
||||||
|
model := resolveAgentModel(agentCfg, defaults)
|
||||||
|
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
|
||||||
|
|
||||||
|
restrict := defaults.RestrictToWorkspace
|
||||||
|
toolsRegistry := tools.NewToolRegistry()
|
||||||
|
toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
|
||||||
|
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
|
||||||
|
toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
|
||||||
|
toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg))
|
||||||
|
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
|
||||||
|
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
|
||||||
|
|
||||||
|
sessionsDir := filepath.Join(workspace, "sessions")
|
||||||
|
sessionsManager := session.NewSessionManager(sessionsDir)
|
||||||
|
|
||||||
|
contextBuilder := NewContextBuilder(workspace)
|
||||||
|
contextBuilder.SetToolsRegistry(toolsRegistry)
|
||||||
|
|
||||||
|
agentID := routing.DefaultAgentID
|
||||||
|
agentName := ""
|
||||||
|
var subagents *config.SubagentsConfig
|
||||||
|
var skillsFilter []string
|
||||||
|
|
||||||
|
if agentCfg != nil {
|
||||||
|
agentID = routing.NormalizeAgentID(agentCfg.ID)
|
||||||
|
agentName = agentCfg.Name
|
||||||
|
subagents = agentCfg.Subagents
|
||||||
|
skillsFilter = agentCfg.Skills
|
||||||
|
}
|
||||||
|
|
||||||
|
maxIter := defaults.MaxToolIterations
|
||||||
|
if maxIter == 0 {
|
||||||
|
maxIter = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve fallback candidates
|
||||||
|
modelCfg := providers.ModelConfig{
|
||||||
|
Primary: model,
|
||||||
|
Fallbacks: fallbacks,
|
||||||
|
}
|
||||||
|
candidates := providers.ResolveCandidates(modelCfg, defaults.Provider)
|
||||||
|
|
||||||
|
return &AgentInstance{
|
||||||
|
ID: agentID,
|
||||||
|
Name: agentName,
|
||||||
|
Model: model,
|
||||||
|
Fallbacks: fallbacks,
|
||||||
|
Workspace: workspace,
|
||||||
|
MaxIterations: maxIter,
|
||||||
|
ContextWindow: defaults.MaxTokens,
|
||||||
|
Provider: provider,
|
||||||
|
Sessions: sessionsManager,
|
||||||
|
ContextBuilder: contextBuilder,
|
||||||
|
Tools: toolsRegistry,
|
||||||
|
Subagents: subagents,
|
||||||
|
SkillsFilter: skillsFilter,
|
||||||
|
Candidates: candidates,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAgentWorkspace determines the workspace directory for an agent.
|
||||||
|
func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
||||||
|
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
||||||
|
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
||||||
|
}
|
||||||
|
if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
|
||||||
|
return expandHome(defaults.Workspace)
|
||||||
|
}
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
id := routing.NormalizeAgentID(agentCfg.ID)
|
||||||
|
return filepath.Join(home, ".picoclaw", "workspace-"+id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAgentModel resolves the primary model for an agent.
|
||||||
|
func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
||||||
|
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
|
||||||
|
return strings.TrimSpace(agentCfg.Model.Primary)
|
||||||
|
}
|
||||||
|
return defaults.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAgentFallbacks resolves the fallback models for an agent.
|
||||||
|
func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {
|
||||||
|
if agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil {
|
||||||
|
return agentCfg.Model.Fallbacks
|
||||||
|
}
|
||||||
|
return defaults.ModelFallbacks
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandHome(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
if path[0] == '~' {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if len(path) > 1 && path[1] == '/' {
|
||||||
|
return home + path[1:]
|
||||||
|
}
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
@ -10,8 +10,6 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
@ -24,7 +22,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/constants"
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"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/routing"
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
|
@ -32,17 +30,12 @@ import (
|
||||||
|
|
||||||
type AgentLoop struct {
|
type AgentLoop struct {
|
||||||
bus *bus.MessageBus
|
bus *bus.MessageBus
|
||||||
provider providers.LLMProvider
|
cfg *config.Config
|
||||||
workspace string
|
registry *AgentRegistry
|
||||||
model string
|
|
||||||
contextWindow int // Maximum context window size in tokens
|
|
||||||
maxIterations int
|
|
||||||
sessions *session.SessionManager
|
|
||||||
state *state.Manager
|
state *state.Manager
|
||||||
contextBuilder *ContextBuilder
|
|
||||||
tools *tools.ToolRegistry
|
|
||||||
running atomic.Bool
|
running atomic.Bool
|
||||||
summarizing sync.Map // Tracks which sessions are currently being summarized
|
summarizing sync.Map
|
||||||
|
fallback *providers.FallbackChain
|
||||||
channelManager *channels.Manager
|
channelManager *channels.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,99 +51,83 @@ type processOptions struct {
|
||||||
NoHistory bool // If true, don't load session history (for heartbeat)
|
NoHistory bool // If true, don't load session history (for heartbeat)
|
||||||
}
|
}
|
||||||
|
|
||||||
// createToolRegistry creates a tool registry with common tools.
|
|
||||||
// This is shared between main agent and subagents.
|
|
||||||
func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msgBus *bus.MessageBus) *tools.ToolRegistry {
|
|
||||||
registry := tools.NewToolRegistry()
|
|
||||||
|
|
||||||
// File system tools
|
|
||||||
registry.Register(tools.NewReadFileTool(workspace, restrict))
|
|
||||||
registry.Register(tools.NewWriteFileTool(workspace, restrict))
|
|
||||||
registry.Register(tools.NewListDirTool(workspace, restrict))
|
|
||||||
registry.Register(tools.NewEditFileTool(workspace, restrict))
|
|
||||||
registry.Register(tools.NewAppendFileTool(workspace, restrict))
|
|
||||||
|
|
||||||
// Shell execution
|
|
||||||
registry.Register(tools.NewExecTool(workspace, restrict))
|
|
||||||
|
|
||||||
if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
|
||||||
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
|
||||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
|
||||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
|
||||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
|
||||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
|
||||||
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
|
||||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
|
||||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
|
||||||
}); searchTool != nil {
|
|
||||||
registry.Register(searchTool)
|
|
||||||
}
|
|
||||||
registry.Register(tools.NewWebFetchTool(50000))
|
|
||||||
|
|
||||||
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
|
||||||
registry.Register(tools.NewI2CTool())
|
|
||||||
registry.Register(tools.NewSPITool())
|
|
||||||
|
|
||||||
// Message tool - available to both agent and subagent
|
|
||||||
// Subagent uses it to communicate directly with user
|
|
||||||
messageTool := tools.NewMessageTool()
|
|
||||||
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
|
||||||
msgBus.PublishOutbound(bus.OutboundMessage{
|
|
||||||
Channel: channel,
|
|
||||||
ChatID: chatID,
|
|
||||||
Content: content,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
registry.Register(messageTool)
|
|
||||||
|
|
||||||
return registry
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
|
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
|
||||||
workspace := cfg.WorkspacePath()
|
registry := NewAgentRegistry(cfg, provider)
|
||||||
os.MkdirAll(workspace, 0755)
|
|
||||||
|
|
||||||
restrict := cfg.Agents.Defaults.RestrictToWorkspace
|
// Register shared tools to all agents
|
||||||
|
registerSharedTools(cfg, msgBus, registry, provider)
|
||||||
|
|
||||||
// Create tool registry for main agent
|
// Set up shared fallback chain
|
||||||
toolsRegistry := createToolRegistry(workspace, restrict, cfg, msgBus)
|
cooldown := providers.NewCooldownTracker()
|
||||||
|
fallbackChain := providers.NewFallbackChain(cooldown)
|
||||||
|
|
||||||
// Create subagent manager with its own tool registry
|
// Create state manager using default agent's workspace for channel recording
|
||||||
subagentManager := tools.NewSubagentManager(provider, cfg.Agents.Defaults.Model, workspace, msgBus)
|
defaultAgent := registry.GetDefaultAgent()
|
||||||
subagentTools := createToolRegistry(workspace, restrict, cfg, msgBus)
|
var stateManager *state.Manager
|
||||||
// Subagent doesn't need spawn/subagent tools to avoid recursion
|
if defaultAgent != nil {
|
||||||
subagentManager.SetTools(subagentTools)
|
stateManager = state.NewManager(defaultAgent.Workspace)
|
||||||
|
}
|
||||||
// Register spawn tool (for main agent)
|
|
||||||
spawnTool := tools.NewSpawnTool(subagentManager)
|
|
||||||
toolsRegistry.Register(spawnTool)
|
|
||||||
|
|
||||||
// Register subagent tool (synchronous execution)
|
|
||||||
subagentTool := tools.NewSubagentTool(subagentManager)
|
|
||||||
toolsRegistry.Register(subagentTool)
|
|
||||||
|
|
||||||
sessionsManager := session.NewSessionManager(filepath.Join(workspace, "sessions"))
|
|
||||||
|
|
||||||
// Create state manager for atomic state persistence
|
|
||||||
stateManager := state.NewManager(workspace)
|
|
||||||
|
|
||||||
// Create context builder and set tools registry
|
|
||||||
contextBuilder := NewContextBuilder(workspace)
|
|
||||||
contextBuilder.SetToolsRegistry(toolsRegistry)
|
|
||||||
|
|
||||||
return &AgentLoop{
|
return &AgentLoop{
|
||||||
bus: msgBus,
|
bus: msgBus,
|
||||||
provider: provider,
|
cfg: cfg,
|
||||||
workspace: workspace,
|
registry: registry,
|
||||||
model: cfg.Agents.Defaults.Model,
|
state: stateManager,
|
||||||
contextWindow: cfg.Agents.Defaults.MaxTokens, // Restore context window for summarization
|
summarizing: sync.Map{},
|
||||||
maxIterations: cfg.Agents.Defaults.MaxToolIterations,
|
fallback: fallbackChain,
|
||||||
sessions: sessionsManager,
|
}
|
||||||
state: stateManager,
|
}
|
||||||
contextBuilder: contextBuilder,
|
|
||||||
tools: toolsRegistry,
|
// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
|
||||||
summarizing: sync.Map{},
|
func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider) {
|
||||||
|
for _, agentID := range registry.ListAgentIDs() {
|
||||||
|
agent, ok := registry.GetAgent(agentID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web tools
|
||||||
|
if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
||||||
|
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
||||||
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||||
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||||
|
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
||||||
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
|
}); searchTool != nil {
|
||||||
|
agent.Tools.Register(searchTool)
|
||||||
|
}
|
||||||
|
agent.Tools.Register(tools.NewWebFetchTool(50000))
|
||||||
|
|
||||||
|
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
||||||
|
agent.Tools.Register(tools.NewI2CTool())
|
||||||
|
agent.Tools.Register(tools.NewSPITool())
|
||||||
|
|
||||||
|
// Message tool
|
||||||
|
messageTool := tools.NewMessageTool()
|
||||||
|
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
||||||
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
||||||
|
Channel: channel,
|
||||||
|
ChatID: chatID,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
agent.Tools.Register(messageTool)
|
||||||
|
|
||||||
|
// Spawn tool with allowlist checker
|
||||||
|
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
|
||||||
|
spawnTool := tools.NewSpawnTool(subagentManager)
|
||||||
|
currentAgentID := agentID
|
||||||
|
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
||||||
|
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
||||||
|
})
|
||||||
|
agent.Tools.Register(spawnTool)
|
||||||
|
|
||||||
|
// Update context builder with the complete tools registry
|
||||||
|
agent.ContextBuilder.SetToolsRegistry(agent.Tools)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,10 +152,14 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
if response != "" {
|
if response != "" {
|
||||||
// Check if the message tool already sent a response during this round.
|
// Check if the message tool already sent a response during this round.
|
||||||
// If so, skip publishing to avoid duplicate messages to the user.
|
// If so, skip publishing to avoid duplicate messages to the user.
|
||||||
|
// Use default agent's tools to check (message tool is shared).
|
||||||
alreadySent := false
|
alreadySent := false
|
||||||
if tool, ok := al.tools.Get("message"); ok {
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
if mt, ok := tool.(*tools.MessageTool); ok {
|
if defaultAgent != nil {
|
||||||
alreadySent = mt.HasSentInRound()
|
if tool, ok := defaultAgent.Tools.Get("message"); ok {
|
||||||
|
if mt, ok := tool.(*tools.MessageTool); ok {
|
||||||
|
alreadySent = mt.HasSentInRound()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,7 +182,11 @@ func (al *AgentLoop) Stop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
|
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
|
||||||
al.tools.Register(tool)
|
for _, agentID := range al.registry.ListAgentIDs() {
|
||||||
|
if agent, ok := al.registry.GetAgent(agentID); ok {
|
||||||
|
agent.Tools.Register(tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
||||||
|
|
@ -211,12 +196,18 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
||||||
// RecordLastChannel records the last active channel for this workspace.
|
// RecordLastChannel records the last active channel for this workspace.
|
||||||
// This uses the atomic state save mechanism to prevent data loss on crash.
|
// This uses the atomic state save mechanism to prevent data loss on crash.
|
||||||
func (al *AgentLoop) RecordLastChannel(channel string) error {
|
func (al *AgentLoop) RecordLastChannel(channel string) error {
|
||||||
|
if al.state == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return al.state.SetLastChannel(channel)
|
return al.state.SetLastChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordLastChatID records the last active chat ID for this workspace.
|
// RecordLastChatID records the last active chat ID for this workspace.
|
||||||
// This uses the atomic state save mechanism to prevent data loss on crash.
|
// This uses the atomic state save mechanism to prevent data loss on crash.
|
||||||
func (al *AgentLoop) RecordLastChatID(chatID string) error {
|
func (al *AgentLoop) RecordLastChatID(chatID string) error {
|
||||||
|
if al.state == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return al.state.SetLastChatID(chatID)
|
return al.state.SetLastChatID(chatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,7 +230,8 @@ func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sess
|
||||||
// ProcessHeartbeat processes a heartbeat request without session history.
|
// ProcessHeartbeat processes a heartbeat request without session history.
|
||||||
// Each heartbeat is independent and doesn't accumulate context.
|
// Each heartbeat is independent and doesn't accumulate context.
|
||||||
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) {
|
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) {
|
||||||
return al.runAgentLoop(ctx, processOptions{
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
return al.runAgentLoop(ctx, agent, processOptions{
|
||||||
SessionKey: "heartbeat",
|
SessionKey: "heartbeat",
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
|
|
@ -277,9 +269,36 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process as user message
|
// Route to determine agent and session key
|
||||||
return al.runAgentLoop(ctx, processOptions{
|
route := al.registry.ResolveRoute(routing.RouteInput{
|
||||||
SessionKey: msg.SessionKey,
|
Channel: msg.Channel,
|
||||||
|
AccountID: msg.Metadata["account_id"],
|
||||||
|
Peer: extractPeer(msg),
|
||||||
|
ParentPeer: extractParentPeer(msg),
|
||||||
|
GuildID: msg.Metadata["guild_id"],
|
||||||
|
TeamID: msg.Metadata["team_id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
agent, ok := al.registry.GetAgent(route.AgentID)
|
||||||
|
if !ok {
|
||||||
|
agent = al.registry.GetDefaultAgent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron)
|
||||||
|
sessionKey := route.SessionKey
|
||||||
|
if msg.SessionKey != "" && strings.HasPrefix(msg.SessionKey, "agent:") {
|
||||||
|
sessionKey = msg.SessionKey
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Routed message",
|
||||||
|
map[string]interface{}{
|
||||||
|
"agent_id": agent.ID,
|
||||||
|
"session_key": sessionKey,
|
||||||
|
"matched_by": route.MatchedBy,
|
||||||
|
})
|
||||||
|
|
||||||
|
return al.runAgentLoop(ctx, agent, processOptions{
|
||||||
|
SessionKey: sessionKey,
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
ChatID: msg.ChatID,
|
ChatID: msg.ChatID,
|
||||||
UserMessage: msg.Content,
|
UserMessage: msg.Content,
|
||||||
|
|
@ -290,7 +309,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
|
func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
|
||||||
// Verify this is a system message
|
|
||||||
if msg.Channel != "system" {
|
if msg.Channel != "system" {
|
||||||
return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel)
|
return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel)
|
||||||
}
|
}
|
||||||
|
|
@ -302,12 +320,13 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
|
||||||
})
|
})
|
||||||
|
|
||||||
// Parse origin channel from chat_id (format: "channel:chat_id")
|
// Parse origin channel from chat_id (format: "channel:chat_id")
|
||||||
var originChannel string
|
var originChannel, originChatID string
|
||||||
if idx := strings.Index(msg.ChatID, ":"); idx > 0 {
|
if idx := strings.Index(msg.ChatID, ":"); idx > 0 {
|
||||||
originChannel = msg.ChatID[:idx]
|
originChannel = msg.ChatID[:idx]
|
||||||
|
originChatID = msg.ChatID[idx+1:]
|
||||||
} else {
|
} else {
|
||||||
// Fallback
|
|
||||||
originChannel = "cli"
|
originChannel = "cli"
|
||||||
|
originChatID = msg.ChatID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract subagent result from message content
|
// Extract subagent result from message content
|
||||||
|
|
@ -328,44 +347,47 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent acts as dispatcher only - subagent handles user interaction via message tool
|
// Use default agent for system messages
|
||||||
// Don't forward result here, subagent should use message tool to communicate with user
|
agent := al.registry.GetDefaultAgent()
|
||||||
logger.InfoCF("agent", "Subagent completed",
|
|
||||||
map[string]interface{}{
|
|
||||||
"sender_id": msg.SenderID,
|
|
||||||
"channel": originChannel,
|
|
||||||
"content_len": len(content),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Agent only logs, does not respond to user
|
// Use the origin session for context
|
||||||
return "", nil
|
sessionKey := routing.BuildAgentMainSessionKey(agent.ID)
|
||||||
|
|
||||||
|
return al.runAgentLoop(ctx, agent, processOptions{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
Channel: originChannel,
|
||||||
|
ChatID: originChatID,
|
||||||
|
UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content),
|
||||||
|
DefaultResponse: "Background task completed.",
|
||||||
|
EnableSummary: false,
|
||||||
|
SendResponse: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// runAgentLoop is the core message processing logic.
|
// runAgentLoop is the core message processing logic.
|
||||||
// It handles context building, LLM calls, tool execution, and response handling.
|
func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opts processOptions) (string, error) {
|
||||||
func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (string, error) {
|
|
||||||
// 0. Record last channel for heartbeat notifications (skip internal channels)
|
// 0. Record last channel for heartbeat notifications (skip internal channels)
|
||||||
if opts.Channel != "" && opts.ChatID != "" {
|
if opts.Channel != "" && opts.ChatID != "" {
|
||||||
// Don't record internal channels (cli, system, subagent)
|
// Don't record internal channels (cli, system, subagent)
|
||||||
if !constants.IsInternalChannel(opts.Channel) {
|
if !constants.IsInternalChannel(opts.Channel) {
|
||||||
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
|
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
|
||||||
if err := al.RecordLastChannel(channelKey); err != nil {
|
if err := al.RecordLastChannel(channelKey); err != nil {
|
||||||
logger.WarnCF("agent", "Failed to record last channel: %v", map[string]interface{}{"error": err.Error()})
|
logger.WarnCF("agent", "Failed to record last channel", map[string]interface{}{"error": err.Error()})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Update tool contexts
|
// 1. Update tool contexts
|
||||||
al.updateToolContexts(opts.Channel, opts.ChatID)
|
al.updateToolContexts(agent, opts.Channel, opts.ChatID)
|
||||||
|
|
||||||
// 2. Build messages (skip history for heartbeat)
|
// 2. Build messages (skip history for heartbeat)
|
||||||
var history []providers.Message
|
var history []providers.Message
|
||||||
var summary string
|
var summary string
|
||||||
if !opts.NoHistory {
|
if !opts.NoHistory {
|
||||||
history = al.sessions.GetHistory(opts.SessionKey)
|
history = agent.Sessions.GetHistory(opts.SessionKey)
|
||||||
summary = al.sessions.GetSummary(opts.SessionKey)
|
summary = agent.Sessions.GetSummary(opts.SessionKey)
|
||||||
}
|
}
|
||||||
messages := al.contextBuilder.BuildMessages(
|
messages := agent.ContextBuilder.BuildMessages(
|
||||||
history,
|
history,
|
||||||
summary,
|
summary,
|
||||||
opts.UserMessage,
|
opts.UserMessage,
|
||||||
|
|
@ -375,10 +397,10 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
|
||||||
)
|
)
|
||||||
|
|
||||||
// 3. Save user message to session
|
// 3. Save user message to session
|
||||||
al.sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
||||||
|
|
||||||
// 4. Run LLM iteration loop
|
// 4. Run LLM iteration loop
|
||||||
finalContent, iteration, err := al.runLLMIteration(ctx, messages, opts)
|
finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -392,12 +414,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Save final assistant message to session
|
// 6. Save final assistant message to session
|
||||||
al.sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
|
agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
|
||||||
al.sessions.Save(opts.SessionKey)
|
agent.Sessions.Save(opts.SessionKey)
|
||||||
|
|
||||||
// 7. Optional: summarization
|
// 7. Optional: summarization
|
||||||
if opts.EnableSummary {
|
if opts.EnableSummary {
|
||||||
al.maybeSummarize(opts.SessionKey, opts.Channel, opts.ChatID)
|
al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Optional: send response via bus
|
// 8. Optional: send response via bus
|
||||||
|
|
@ -413,6 +435,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
|
||||||
responsePreview := utils.Truncate(finalContent, 120)
|
responsePreview := utils.Truncate(finalContent, 120)
|
||||||
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
|
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
|
"agent_id": agent.ID,
|
||||||
"session_key": opts.SessionKey,
|
"session_key": opts.SessionKey,
|
||||||
"iterations": iteration,
|
"iterations": iteration,
|
||||||
"final_length": len(finalContent),
|
"final_length": len(finalContent),
|
||||||
|
|
@ -422,28 +445,29 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
|
||||||
}
|
}
|
||||||
|
|
||||||
// runLLMIteration executes the LLM call loop with tool handling.
|
// runLLMIteration executes the LLM call loop with tool handling.
|
||||||
// Returns the final content, iteration count, and any error.
|
func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions) (string, int, error) {
|
||||||
func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.Message, opts processOptions) (string, int, error) {
|
|
||||||
iteration := 0
|
iteration := 0
|
||||||
var finalContent string
|
var finalContent string
|
||||||
|
|
||||||
for iteration < al.maxIterations {
|
for iteration < agent.MaxIterations {
|
||||||
iteration++
|
iteration++
|
||||||
|
|
||||||
logger.DebugCF("agent", "LLM iteration",
|
logger.DebugCF("agent", "LLM iteration",
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
|
"agent_id": agent.ID,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"max": al.maxIterations,
|
"max": agent.MaxIterations,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Build tool definitions
|
// Build tool definitions
|
||||||
providerToolDefs := al.tools.ToProviderDefs()
|
providerToolDefs := agent.Tools.ToProviderDefs()
|
||||||
|
|
||||||
// Log LLM request details
|
// Log LLM request details
|
||||||
logger.DebugCF("agent", "LLM request",
|
logger.DebugCF("agent", "LLM request",
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
|
"agent_id": agent.ID,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"model": al.model,
|
"model": agent.Model,
|
||||||
"messages_count": len(messages),
|
"messages_count": len(messages),
|
||||||
"tools_count": len(providerToolDefs),
|
"tools_count": len(providerToolDefs),
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
|
|
@ -459,23 +483,45 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
||||||
"tools_json": formatToolsForLog(providerToolDefs),
|
"tools_json": formatToolsForLog(providerToolDefs),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Call LLM with fallback chain if candidates are configured.
|
||||||
var response *providers.LLMResponse
|
var response *providers.LLMResponse
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
callLLM := func() (*providers.LLMResponse, error) {
|
||||||
|
if len(agent.Candidates) > 1 && al.fallback != nil {
|
||||||
|
fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
|
||||||
|
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
||||||
|
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]interface{}{
|
||||||
|
"max_tokens": 8192,
|
||||||
|
"temperature": 0.7,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if fbErr != nil {
|
||||||
|
return nil, fbErr
|
||||||
|
}
|
||||||
|
if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
|
||||||
|
logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
|
||||||
|
fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
|
||||||
|
map[string]interface{}{"agent_id": agent.ID, "iteration": iteration})
|
||||||
|
}
|
||||||
|
return fbResult.Response, nil
|
||||||
|
}
|
||||||
|
return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]interface{}{
|
||||||
|
"max_tokens": 8192,
|
||||||
|
"temperature": 0.7,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Retry loop for context/token errors
|
// Retry loop for context/token errors
|
||||||
maxRetries := 2
|
maxRetries := 2
|
||||||
for retry := 0; retry <= maxRetries; retry++ {
|
for retry := 0; retry <= maxRetries; retry++ {
|
||||||
response, err = al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{
|
response, err = callLLM()
|
||||||
"max_tokens": 8192,
|
|
||||||
"temperature": 0.7,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
break // Success
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
errMsg := strings.ToLower(err.Error())
|
errMsg := strings.ToLower(err.Error())
|
||||||
// Check for context window errors (provider specific, but usually contain "token" or "invalid")
|
|
||||||
isContextError := strings.Contains(errMsg, "token") ||
|
isContextError := strings.Contains(errMsg, "token") ||
|
||||||
strings.Contains(errMsg, "context") ||
|
strings.Contains(errMsg, "context") ||
|
||||||
strings.Contains(errMsg, "invalidparameter") ||
|
strings.Contains(errMsg, "invalidparameter") ||
|
||||||
|
|
@ -487,107 +533,30 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
||||||
"retry": retry,
|
"retry": retry,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify user on first retry only
|
if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
|
||||||
if retry == 0 && !constants.IsInternalChannel(opts.Channel) && opts.SendResponse {
|
|
||||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
al.bus.PublishOutbound(bus.OutboundMessage{
|
||||||
Channel: opts.Channel,
|
Channel: opts.Channel,
|
||||||
ChatID: opts.ChatID,
|
ChatID: opts.ChatID,
|
||||||
Content: "⚠️ Context window exceeded. Compressing history and retrying...",
|
Content: "Context window exceeded. Compressing history and retrying...",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force compression
|
al.forceCompression(agent, opts.SessionKey)
|
||||||
al.forceCompression(opts.SessionKey)
|
newHistory := agent.Sessions.GetHistory(opts.SessionKey)
|
||||||
|
newSummary := agent.Sessions.GetSummary(opts.SessionKey)
|
||||||
// Rebuild messages with compressed history
|
messages = agent.ContextBuilder.BuildMessages(
|
||||||
// Note: We need to reload history from session manager because forceCompression changed it
|
newHistory, newSummary, "",
|
||||||
newHistory := al.sessions.GetHistory(opts.SessionKey)
|
nil, opts.Channel, opts.ChatID,
|
||||||
newSummary := al.sessions.GetSummary(opts.SessionKey)
|
|
||||||
|
|
||||||
// Re-create messages for the next attempt
|
|
||||||
// We keep the current user message (opts.UserMessage) effectively
|
|
||||||
messages = al.contextBuilder.BuildMessages(
|
|
||||||
newHistory,
|
|
||||||
newSummary,
|
|
||||||
opts.UserMessage,
|
|
||||||
nil,
|
|
||||||
opts.Channel,
|
|
||||||
opts.ChatID,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Important: If we are in the middle of a tool loop (iteration > 1),
|
|
||||||
// rebuilding messages from session history might duplicate the flow or miss context
|
|
||||||
// if intermediate steps weren't saved correctly.
|
|
||||||
// However, al.sessions.AddFullMessage is called after every tool execution,
|
|
||||||
// so GetHistory should reflect the current state including partial tool execution.
|
|
||||||
// But we need to ensure we don't duplicate the user message which is appended in BuildMessages.
|
|
||||||
// BuildMessages(history...) takes the stored history and appends the *current* user message.
|
|
||||||
// If iteration > 1, the "current user message" was already added to history in step 3 of runAgentLoop.
|
|
||||||
// So if we pass opts.UserMessage again, we might duplicate it?
|
|
||||||
// Actually, step 3 is: al.sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
|
||||||
// So GetHistory ALREADY contains the user message!
|
|
||||||
|
|
||||||
// CORRECTION:
|
|
||||||
// BuildMessages combines: [System] + [History] + [CurrentMessage]
|
|
||||||
// But Step 3 added CurrentMessage to History.
|
|
||||||
// So if we use GetHistory now, it has the user message.
|
|
||||||
// If we pass opts.UserMessage to BuildMessages, it adds it AGAIN.
|
|
||||||
|
|
||||||
// For retry in the middle of a loop, we should rely on what's in the session.
|
|
||||||
// BUT checking BuildMessages implementation:
|
|
||||||
// It appends history... then appends currentMessage.
|
|
||||||
|
|
||||||
// Logic fix for retry:
|
|
||||||
// If iteration == 1, opts.UserMessage corresponds to the user input.
|
|
||||||
// If iteration > 1, we are processing tool results. The "messages" passed to Chat
|
|
||||||
// already accumulated tool outputs.
|
|
||||||
// Rebuilding from session history is safest because it persists state.
|
|
||||||
// Start fresh with rebuilt history.
|
|
||||||
|
|
||||||
// Special case: standard BuildMessages appends "currentMessage".
|
|
||||||
// If we are strictly retrying the *LLM call*, we want the exact same state as before but compressed.
|
|
||||||
// However, the "messages" argument passed to runLLMIteration is constructed by the caller.
|
|
||||||
// If we rebuild from Session, we need to know if "currentMessage" should be appended or is already in history.
|
|
||||||
|
|
||||||
// In runAgentLoop:
|
|
||||||
// 3. sessions.AddMessage(userMsg)
|
|
||||||
// 4. runLLMIteration(..., UserMessage)
|
|
||||||
|
|
||||||
// So History contains the user message.
|
|
||||||
// BuildMessages typically appends the user message as a *new* pending message.
|
|
||||||
// Wait, standard BuildMessages usage in runAgentLoop:
|
|
||||||
// messages := BuildMessages(history (has old), UserMessage)
|
|
||||||
// THEN AddMessage(UserMessage).
|
|
||||||
// So "history" passed to BuildMessages does NOT contain the current UserMessage yet.
|
|
||||||
|
|
||||||
// But here, inside the loop, we have already saved it.
|
|
||||||
// So GetHistory() includes the current user message.
|
|
||||||
// If we call BuildMessages(GetHistory(), UserMessage), we get duplicates.
|
|
||||||
|
|
||||||
// Hack/Fix:
|
|
||||||
// If we are retrying, we rebuild from Session History ONLY.
|
|
||||||
// We pass empty string as "currentMessage" to BuildMessages
|
|
||||||
// because the "current message" is already saved in history (step 3).
|
|
||||||
|
|
||||||
messages = al.contextBuilder.BuildMessages(
|
|
||||||
newHistory,
|
|
||||||
newSummary,
|
|
||||||
"", // Empty because history already contains the relevant messages
|
|
||||||
nil,
|
|
||||||
opts.Channel,
|
|
||||||
opts.ChatID,
|
|
||||||
)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Real error or success, break loop
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("agent", "LLM call failed",
|
logger.ErrorCF("agent", "LLM call failed",
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
|
"agent_id": agent.ID,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
|
@ -599,6 +568,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
||||||
finalContent = response.Content
|
finalContent = response.Content
|
||||||
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
|
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
|
"agent_id": agent.ID,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"content_chars": len(finalContent),
|
"content_chars": len(finalContent),
|
||||||
})
|
})
|
||||||
|
|
@ -612,6 +582,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
||||||
}
|
}
|
||||||
logger.InfoCF("agent", "LLM requested tool calls",
|
logger.InfoCF("agent", "LLM requested tool calls",
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
|
"agent_id": agent.ID,
|
||||||
"tools": toolNames,
|
"tools": toolNames,
|
||||||
"count": len(response.ToolCalls),
|
"count": len(response.ToolCalls),
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
|
|
@ -636,15 +607,15 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
||||||
messages = append(messages, assistantMsg)
|
messages = append(messages, assistantMsg)
|
||||||
|
|
||||||
// Save assistant message with tool calls to session
|
// Save assistant message with tool calls to session
|
||||||
al.sessions.AddFullMessage(opts.SessionKey, assistantMsg)
|
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
|
||||||
|
|
||||||
// Execute tool calls
|
// Execute tool calls
|
||||||
for _, tc := range response.ToolCalls {
|
for _, tc := range response.ToolCalls {
|
||||||
// Log tool call with arguments preview
|
|
||||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||||
argsPreview := utils.Truncate(string(argsJSON), 200)
|
argsPreview := utils.Truncate(string(argsJSON), 200)
|
||||||
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
|
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
|
"agent_id": agent.ID,
|
||||||
"tool": tc.Name,
|
"tool": tc.Name,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
})
|
})
|
||||||
|
|
@ -665,7 +636,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toolResult := al.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
|
toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
|
||||||
|
|
||||||
// Send ForUser content to user immediately if not Silent
|
// Send ForUser content to user immediately if not Silent
|
||||||
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
|
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
|
||||||
|
|
@ -695,7 +666,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
||||||
messages = append(messages, toolResultMsg)
|
messages = append(messages, toolResultMsg)
|
||||||
|
|
||||||
// Save tool result message to session
|
// Save tool result message to session
|
||||||
al.sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
|
agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -703,19 +674,19 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateToolContexts updates the context for tools that need channel/chatID info.
|
// updateToolContexts updates the context for tools that need channel/chatID info.
|
||||||
func (al *AgentLoop) updateToolContexts(channel, chatID string) {
|
func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
|
||||||
// Use ContextualTool interface instead of type assertions
|
// Use ContextualTool interface instead of type assertions
|
||||||
if tool, ok := al.tools.Get("message"); ok {
|
if tool, ok := agent.Tools.Get("message"); ok {
|
||||||
if mt, ok := tool.(tools.ContextualTool); ok {
|
if mt, ok := tool.(tools.ContextualTool); ok {
|
||||||
mt.SetContext(channel, chatID)
|
mt.SetContext(channel, chatID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if tool, ok := al.tools.Get("spawn"); ok {
|
if tool, ok := agent.Tools.Get("spawn"); ok {
|
||||||
if st, ok := tool.(tools.ContextualTool); ok {
|
if st, ok := tool.(tools.ContextualTool); ok {
|
||||||
st.SetContext(channel, chatID)
|
st.SetContext(channel, chatID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if tool, ok := al.tools.Get("subagent"); ok {
|
if tool, ok := agent.Tools.Get("subagent"); ok {
|
||||||
if st, ok := tool.(tools.ContextualTool); ok {
|
if st, ok := tool.(tools.ContextualTool); ok {
|
||||||
st.SetContext(channel, chatID)
|
st.SetContext(channel, chatID)
|
||||||
}
|
}
|
||||||
|
|
@ -723,24 +694,24 @@ func (al *AgentLoop) updateToolContexts(channel, chatID string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
||||||
func (al *AgentLoop) maybeSummarize(sessionKey, channel, chatID string) {
|
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
|
||||||
newHistory := al.sessions.GetHistory(sessionKey)
|
newHistory := agent.Sessions.GetHistory(sessionKey)
|
||||||
tokenEstimate := al.estimateTokens(newHistory)
|
tokenEstimate := al.estimateTokens(newHistory)
|
||||||
threshold := al.contextWindow * 75 / 100
|
threshold := agent.ContextWindow * 75 / 100
|
||||||
|
|
||||||
if len(newHistory) > 20 || tokenEstimate > threshold {
|
if len(newHistory) > 20 || tokenEstimate > threshold {
|
||||||
if _, loading := al.summarizing.LoadOrStore(sessionKey, true); !loading {
|
summarizeKey := agent.ID + ":" + sessionKey
|
||||||
|
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
|
||||||
go func() {
|
go func() {
|
||||||
defer al.summarizing.Delete(sessionKey)
|
defer al.summarizing.Delete(summarizeKey)
|
||||||
// Notify user about optimization if not an internal channel
|
|
||||||
if !constants.IsInternalChannel(channel) {
|
if !constants.IsInternalChannel(channel) {
|
||||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
al.bus.PublishOutbound(bus.OutboundMessage{
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
Content: "⚠️ Memory threshold reached. Optimizing conversation history...",
|
Content: "Memory threshold reached. Optimizing conversation history...",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
al.summarizeSession(sessionKey)
|
al.summarizeSession(agent, sessionKey)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -748,8 +719,8 @@ func (al *AgentLoop) maybeSummarize(sessionKey, channel, chatID string) {
|
||||||
|
|
||||||
// forceCompression aggressively reduces context when the limit is hit.
|
// forceCompression aggressively reduces context when the limit is hit.
|
||||||
// It drops the oldest 50% of messages (keeping system prompt and last user message).
|
// It drops the oldest 50% of messages (keeping system prompt and last user message).
|
||||||
func (al *AgentLoop) forceCompression(sessionKey string) {
|
func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
|
||||||
history := al.sessions.GetHistory(sessionKey)
|
history := agent.Sessions.GetHistory(sessionKey)
|
||||||
if len(history) <= 4 {
|
if len(history) <= 4 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -796,8 +767,8 @@ func (al *AgentLoop) forceCompression(sessionKey string) {
|
||||||
newHistory = append(newHistory, history[len(history)-1]) // Last message
|
newHistory = append(newHistory, history[len(history)-1]) // Last message
|
||||||
|
|
||||||
// Update session
|
// Update session
|
||||||
al.sessions.SetHistory(sessionKey, newHistory)
|
agent.Sessions.SetHistory(sessionKey, newHistory)
|
||||||
al.sessions.Save(sessionKey)
|
agent.Sessions.Save(sessionKey)
|
||||||
|
|
||||||
logger.WarnCF("agent", "Forced compression executed", map[string]interface{}{
|
logger.WarnCF("agent", "Forced compression executed", map[string]interface{}{
|
||||||
"session_key": sessionKey,
|
"session_key": sessionKey,
|
||||||
|
|
@ -810,15 +781,26 @@ func (al *AgentLoop) forceCompression(sessionKey string) {
|
||||||
func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
|
func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
|
||||||
info := make(map[string]interface{})
|
info := make(map[string]interface{})
|
||||||
|
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
// Tools info
|
// Tools info
|
||||||
tools := al.tools.List()
|
toolsList := agent.Tools.List()
|
||||||
info["tools"] = map[string]interface{}{
|
info["tools"] = map[string]interface{}{
|
||||||
"count": len(tools),
|
"count": len(toolsList),
|
||||||
"names": tools,
|
"names": toolsList,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skills info
|
// Skills info
|
||||||
info["skills"] = al.contextBuilder.GetSkillsInfo()
|
info["skills"] = agent.ContextBuilder.GetSkillsInfo()
|
||||||
|
|
||||||
|
// Agents info
|
||||||
|
info["agents"] = map[string]interface{}{
|
||||||
|
"count": len(al.registry.ListAgentIDs()),
|
||||||
|
"ids": al.registry.ListAgentIDs(),
|
||||||
|
}
|
||||||
|
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
@ -875,12 +857,12 @@ func formatToolsForLog(tools []providers.ToolDefinition) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// summarizeSession summarizes the conversation history for a session.
|
// summarizeSession summarizes the conversation history for a session.
|
||||||
func (al *AgentLoop) summarizeSession(sessionKey string) {
|
func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
history := al.sessions.GetHistory(sessionKey)
|
history := agent.Sessions.GetHistory(sessionKey)
|
||||||
summary := al.sessions.GetSummary(sessionKey)
|
summary := agent.Sessions.GetSummary(sessionKey)
|
||||||
|
|
||||||
// Keep last 4 messages for continuity
|
// Keep last 4 messages for continuity
|
||||||
if len(history) <= 4 {
|
if len(history) <= 4 {
|
||||||
|
|
@ -890,8 +872,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
|
||||||
toSummarize := history[:len(history)-4]
|
toSummarize := history[:len(history)-4]
|
||||||
|
|
||||||
// Oversized Message Guard
|
// Oversized Message Guard
|
||||||
// Skip messages larger than 50% of context window to prevent summarizer overflow
|
maxMessageTokens := agent.ContextWindow / 2
|
||||||
maxMessageTokens := al.contextWindow / 2
|
|
||||||
validMessages := make([]providers.Message, 0)
|
validMessages := make([]providers.Message, 0)
|
||||||
omitted := false
|
omitted := false
|
||||||
|
|
||||||
|
|
@ -899,8 +880,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
|
||||||
if m.Role != "user" && m.Role != "assistant" {
|
if m.Role != "user" && m.Role != "assistant" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Estimate tokens for this message
|
msgTokens := len(m.Content) / 2
|
||||||
msgTokens := len(m.Content) / 2 // Use safer estimate here too (2.5 -> 2 for integer division safety)
|
|
||||||
if msgTokens > maxMessageTokens {
|
if msgTokens > maxMessageTokens {
|
||||||
omitted = true
|
omitted = true
|
||||||
continue
|
continue
|
||||||
|
|
@ -913,19 +893,17 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-Part Summarization
|
// Multi-Part Summarization
|
||||||
// Split into two parts if history is significant
|
|
||||||
var finalSummary string
|
var finalSummary string
|
||||||
if len(validMessages) > 10 {
|
if len(validMessages) > 10 {
|
||||||
mid := len(validMessages) / 2
|
mid := len(validMessages) / 2
|
||||||
part1 := validMessages[:mid]
|
part1 := validMessages[:mid]
|
||||||
part2 := validMessages[mid:]
|
part2 := validMessages[mid:]
|
||||||
|
|
||||||
s1, _ := al.summarizeBatch(ctx, part1, "")
|
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
|
||||||
s2, _ := al.summarizeBatch(ctx, part2, "")
|
s2, _ := al.summarizeBatch(ctx, agent, part2, "")
|
||||||
|
|
||||||
// Merge them
|
|
||||||
mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2)
|
mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2)
|
||||||
resp, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, al.model, map[string]interface{}{
|
resp, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, agent.Model, map[string]interface{}{
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
})
|
})
|
||||||
|
|
@ -935,7 +913,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
|
||||||
finalSummary = s1 + " " + s2
|
finalSummary = s1 + " " + s2
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
finalSummary, _ = al.summarizeBatch(ctx, validMessages, summary)
|
finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if omitted && finalSummary != "" {
|
if omitted && finalSummary != "" {
|
||||||
|
|
@ -943,14 +921,14 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if finalSummary != "" {
|
if finalSummary != "" {
|
||||||
al.sessions.SetSummary(sessionKey, finalSummary)
|
agent.Sessions.SetSummary(sessionKey, finalSummary)
|
||||||
al.sessions.TruncateHistory(sessionKey, 4)
|
agent.Sessions.TruncateHistory(sessionKey, 4)
|
||||||
al.sessions.Save(sessionKey)
|
agent.Sessions.Save(sessionKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// summarizeBatch summarizes a batch of messages.
|
// summarizeBatch summarizes a batch of messages.
|
||||||
func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Message, existingSummary string) (string, error) {
|
func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) {
|
||||||
prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n"
|
prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n"
|
||||||
if existingSummary != "" {
|
if existingSummary != "" {
|
||||||
prompt += "Existing context: " + existingSummary + "\n"
|
prompt += "Existing context: " + existingSummary + "\n"
|
||||||
|
|
@ -960,7 +938,7 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Messa
|
||||||
prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content)
|
prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, al.model, map[string]interface{}{
|
response, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, agent.Model, map[string]interface{}{
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
})
|
})
|
||||||
|
|
@ -999,25 +977,31 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "/show":
|
case "/show":
|
||||||
if len(args) < 1 {
|
if len(args) < 1 {
|
||||||
return "Usage: /show [model|channel]", true
|
return "Usage: /show [model|channel|agents]", true
|
||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "model":
|
case "model":
|
||||||
return fmt.Sprintf("Current model: %s", al.model), true
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
if defaultAgent == nil {
|
||||||
|
return "No default agent configured", true
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Current model: %s", defaultAgent.Model), true
|
||||||
case "channel":
|
case "channel":
|
||||||
return fmt.Sprintf("Current channel: %s", msg.Channel), true
|
return fmt.Sprintf("Current channel: %s", msg.Channel), true
|
||||||
|
case "agents":
|
||||||
|
agentIDs := al.registry.ListAgentIDs()
|
||||||
|
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("Unknown show target: %s", args[0]), true
|
return fmt.Sprintf("Unknown show target: %s", args[0]), true
|
||||||
}
|
}
|
||||||
|
|
||||||
case "/list":
|
case "/list":
|
||||||
if len(args) < 1 {
|
if len(args) < 1 {
|
||||||
return "Usage: /list [models|channels]", true
|
return "Usage: /list [models|channels|agents]", true
|
||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "models":
|
case "models":
|
||||||
// TODO: Fetch available models dynamically if possible
|
return "Available models: configured in config.json per agent", true
|
||||||
return "Available models: glm-4.7, claude-3-5-sonnet, gpt-4o (configured in config.json/env)", true
|
|
||||||
case "channels":
|
case "channels":
|
||||||
if al.channelManager == nil {
|
if al.channelManager == nil {
|
||||||
return "Channel manager not initialized", true
|
return "Channel manager not initialized", true
|
||||||
|
|
@ -1027,6 +1011,9 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
|
||||||
return "No channels enabled", true
|
return "No channels enabled", true
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true
|
return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true
|
||||||
|
case "agents":
|
||||||
|
agentIDs := al.registry.ListAgentIDs()
|
||||||
|
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("Unknown list target: %s", args[0]), true
|
return fmt.Sprintf("Unknown list target: %s", args[0]), true
|
||||||
}
|
}
|
||||||
|
|
@ -1040,23 +1027,21 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
|
||||||
|
|
||||||
switch target {
|
switch target {
|
||||||
case "model":
|
case "model":
|
||||||
oldModel := al.model
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
al.model = value
|
if defaultAgent == nil {
|
||||||
|
return "No default agent configured", true
|
||||||
|
}
|
||||||
|
oldModel := defaultAgent.Model
|
||||||
|
defaultAgent.Model = value
|
||||||
return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true
|
return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true
|
||||||
case "channel":
|
case "channel":
|
||||||
// This changes the 'default' channel for some operations, or effectively redirects output?
|
|
||||||
// For now, let's just validate if the channel exists
|
|
||||||
if al.channelManager == nil {
|
if al.channelManager == nil {
|
||||||
return "Channel manager not initialized", true
|
return "Channel manager not initialized", true
|
||||||
}
|
}
|
||||||
if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
|
if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
|
||||||
return fmt.Sprintf("Channel '%s' not found or not enabled", value), true
|
return fmt.Sprintf("Channel '%s' not found or not enabled", value), true
|
||||||
}
|
}
|
||||||
|
return fmt.Sprintf("Switched target channel to %s", value), true
|
||||||
// If message came from CLI, maybe we want to redirect CLI output to this channel?
|
|
||||||
// That would require state persistence about "redirected channel"
|
|
||||||
// For now, just acknowledged.
|
|
||||||
return fmt.Sprintf("Switched target channel to %s (Note: this currently only validates existence)", value), true
|
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("Unknown switch target: %s", target), true
|
return fmt.Sprintf("Unknown switch target: %s", target), true
|
||||||
}
|
}
|
||||||
|
|
@ -1064,3 +1049,30 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
|
||||||
|
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractPeer extracts the routing peer from inbound message metadata.
|
||||||
|
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
|
peerKind := msg.Metadata["peer_kind"]
|
||||||
|
if peerKind == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
peerID := msg.Metadata["peer_id"]
|
||||||
|
if peerID == "" {
|
||||||
|
if peerKind == "direct" {
|
||||||
|
peerID = msg.SenderID
|
||||||
|
} else {
|
||||||
|
peerID = msg.ChatID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &routing.RoutePeer{Kind: peerKind, ID: peerID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
|
||||||
|
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
|
parentKind := msg.Metadata["parent_peer_kind"]
|
||||||
|
parentID := msg.Metadata["parent_peer_id"]
|
||||||
|
if parentKind == "" || parentID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &routing.RoutePeer{Kind: parentKind, ID: parentID}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -594,7 +594,11 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
||||||
{Role: "assistant", Content: "Old response 2"},
|
{Role: "assistant", Content: "Old response 2"},
|
||||||
{Role: "user", Content: "Trigger message"},
|
{Role: "user", Content: "Trigger message"},
|
||||||
}
|
}
|
||||||
al.sessions.SetHistory(sessionKey, history)
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
if defaultAgent == nil {
|
||||||
|
t.Fatal("No default agent found")
|
||||||
|
}
|
||||||
|
defaultAgent.Sessions.SetHistory(sessionKey, history)
|
||||||
|
|
||||||
// Call ProcessDirectWithChannel
|
// Call ProcessDirectWithChannel
|
||||||
// Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration
|
// Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration
|
||||||
|
|
@ -614,7 +618,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check final history length
|
// Check final history length
|
||||||
finalHistory := al.sessions.GetHistory(sessionKey)
|
finalHistory := defaultAgent.Sessions.GetHistory(sessionKey)
|
||||||
// We verify that the history has been modified (compressed)
|
// We verify that the history has been modified (compressed)
|
||||||
// Original length: 6
|
// Original length: 6
|
||||||
// Expected behavior: compression drops ~50% of history (mid slice)
|
// Expected behavior: compression drops ~50% of history (mid slice)
|
||||||
|
|
|
||||||
114
pkg/agent/registry.go
Normal file
114
pkg/agent/registry.go
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentRegistry manages multiple agent instances and routes messages to them.
|
||||||
|
type AgentRegistry struct {
|
||||||
|
agents map[string]*AgentInstance
|
||||||
|
resolver *routing.RouteResolver
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAgentRegistry creates a registry from config, instantiating all agents.
|
||||||
|
func NewAgentRegistry(
|
||||||
|
cfg *config.Config,
|
||||||
|
provider providers.LLMProvider,
|
||||||
|
) *AgentRegistry {
|
||||||
|
registry := &AgentRegistry{
|
||||||
|
agents: make(map[string]*AgentInstance),
|
||||||
|
resolver: routing.NewRouteResolver(cfg),
|
||||||
|
}
|
||||||
|
|
||||||
|
agentConfigs := cfg.Agents.List
|
||||||
|
if len(agentConfigs) == 0 {
|
||||||
|
implicitAgent := &config.AgentConfig{
|
||||||
|
ID: "main",
|
||||||
|
Default: true,
|
||||||
|
}
|
||||||
|
instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
registry.agents["main"] = instance
|
||||||
|
logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil)
|
||||||
|
} else {
|
||||||
|
for i := range agentConfigs {
|
||||||
|
ac := &agentConfigs[i]
|
||||||
|
id := routing.NormalizeAgentID(ac.ID)
|
||||||
|
instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
registry.agents[id] = instance
|
||||||
|
logger.InfoCF("agent", "Registered agent",
|
||||||
|
map[string]interface{}{
|
||||||
|
"agent_id": id,
|
||||||
|
"name": ac.Name,
|
||||||
|
"workspace": instance.Workspace,
|
||||||
|
"model": instance.Model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return registry
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAgent returns the agent instance for a given ID.
|
||||||
|
func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
id := routing.NormalizeAgentID(agentID)
|
||||||
|
agent, ok := r.agents[id]
|
||||||
|
return agent, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveRoute determines which agent handles the message.
|
||||||
|
func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute {
|
||||||
|
return r.resolver.ResolveRoute(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAgentIDs returns all registered agent IDs.
|
||||||
|
func (r *AgentRegistry) ListAgentIDs() []string {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
ids := make([]string, 0, len(r.agents))
|
||||||
|
for id := range r.agents {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID.
|
||||||
|
func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool {
|
||||||
|
parent, ok := r.GetAgent(parentAgentID)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if parent.Subagents == nil || parent.Subagents.AllowAgents == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
targetNorm := routing.NormalizeAgentID(targetAgentID)
|
||||||
|
for _, allowed := range parent.Subagents.AllowAgents {
|
||||||
|
if allowed == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if routing.NormalizeAgentID(allowed) == targetNorm {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAgent returns the default agent instance.
|
||||||
|
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
if agent, ok := r.agents["main"]; ok {
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
for _, agent := range r.agents {
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
199
pkg/agent/registry_test.go
Normal file
199
pkg/agent/registry_test.go
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockRegistryProvider struct{}
|
||||||
|
|
||||||
|
func (m *mockRegistryProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) {
|
||||||
|
return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRegistryProvider) GetDefaultModel() string {
|
||||||
|
return "mock-model"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCfg(agents []config.AgentConfig) *config.Config {
|
||||||
|
return &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: "/tmp/picoclaw-test-registry",
|
||||||
|
Model: "gpt-4",
|
||||||
|
MaxTokens: 8192,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
List: agents,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAgentRegistry_ImplicitMain(t *testing.T) {
|
||||||
|
cfg := testCfg(nil)
|
||||||
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
|
ids := registry.ListAgentIDs()
|
||||||
|
if len(ids) != 1 || ids[0] != "main" {
|
||||||
|
t.Errorf("expected implicit main agent, got %v", ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
agent, ok := registry.GetAgent("main")
|
||||||
|
if !ok || agent == nil {
|
||||||
|
t.Fatal("expected to find 'main' agent")
|
||||||
|
}
|
||||||
|
if agent.ID != "main" {
|
||||||
|
t.Errorf("agent.ID = %q, want 'main'", agent.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAgentRegistry_ExplicitAgents(t *testing.T) {
|
||||||
|
cfg := testCfg([]config.AgentConfig{
|
||||||
|
{ID: "sales", Default: true, Name: "Sales Bot"},
|
||||||
|
{ID: "support", Name: "Support Bot"},
|
||||||
|
})
|
||||||
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
|
ids := registry.ListAgentIDs()
|
||||||
|
if len(ids) != 2 {
|
||||||
|
t.Fatalf("expected 2 agents, got %d: %v", len(ids), ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
sales, ok := registry.GetAgent("sales")
|
||||||
|
if !ok || sales == nil {
|
||||||
|
t.Fatal("expected to find 'sales' agent")
|
||||||
|
}
|
||||||
|
if sales.Name != "Sales Bot" {
|
||||||
|
t.Errorf("sales.Name = %q, want 'Sales Bot'", sales.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
support, ok := registry.GetAgent("support")
|
||||||
|
if !ok || support == nil {
|
||||||
|
t.Fatal("expected to find 'support' agent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentRegistry_GetAgent_Normalize(t *testing.T) {
|
||||||
|
cfg := testCfg([]config.AgentConfig{
|
||||||
|
{ID: "my-agent", Default: true},
|
||||||
|
})
|
||||||
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
|
agent, ok := registry.GetAgent("My-Agent")
|
||||||
|
if !ok || agent == nil {
|
||||||
|
t.Fatal("expected to find agent with normalized ID")
|
||||||
|
}
|
||||||
|
if agent.ID != "my-agent" {
|
||||||
|
t.Errorf("agent.ID = %q, want 'my-agent'", agent.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentRegistry_GetDefaultAgent(t *testing.T) {
|
||||||
|
cfg := testCfg([]config.AgentConfig{
|
||||||
|
{ID: "alpha"},
|
||||||
|
{ID: "beta", Default: true},
|
||||||
|
})
|
||||||
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
|
// GetDefaultAgent first checks for "main", then returns any
|
||||||
|
agent := registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
t.Fatal("expected a default agent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentRegistry_CanSpawnSubagent(t *testing.T) {
|
||||||
|
cfg := testCfg([]config.AgentConfig{
|
||||||
|
{
|
||||||
|
ID: "parent",
|
||||||
|
Default: true,
|
||||||
|
Subagents: &config.SubagentsConfig{
|
||||||
|
AllowAgents: []string{"child1", "child2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ID: "child1"},
|
||||||
|
{ID: "child2"},
|
||||||
|
{ID: "restricted"},
|
||||||
|
})
|
||||||
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
|
if !registry.CanSpawnSubagent("parent", "child1") {
|
||||||
|
t.Error("expected parent to be allowed to spawn child1")
|
||||||
|
}
|
||||||
|
if !registry.CanSpawnSubagent("parent", "child2") {
|
||||||
|
t.Error("expected parent to be allowed to spawn child2")
|
||||||
|
}
|
||||||
|
if registry.CanSpawnSubagent("parent", "restricted") {
|
||||||
|
t.Error("expected parent to NOT be allowed to spawn restricted")
|
||||||
|
}
|
||||||
|
if registry.CanSpawnSubagent("child1", "child2") {
|
||||||
|
t.Error("expected child1 to NOT be allowed to spawn (no subagents config)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {
|
||||||
|
cfg := testCfg([]config.AgentConfig{
|
||||||
|
{
|
||||||
|
ID: "admin",
|
||||||
|
Default: true,
|
||||||
|
Subagents: &config.SubagentsConfig{
|
||||||
|
AllowAgents: []string{"*"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ID: "any-agent"},
|
||||||
|
})
|
||||||
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
|
if !registry.CanSpawnSubagent("admin", "any-agent") {
|
||||||
|
t.Error("expected wildcard to allow spawning any agent")
|
||||||
|
}
|
||||||
|
if !registry.CanSpawnSubagent("admin", "nonexistent") {
|
||||||
|
t.Error("expected wildcard to allow spawning even nonexistent agents")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentInstance_Model(t *testing.T) {
|
||||||
|
model := &config.AgentModelConfig{Primary: "claude-opus"}
|
||||||
|
cfg := testCfg([]config.AgentConfig{
|
||||||
|
{ID: "custom", Default: true, Model: model},
|
||||||
|
})
|
||||||
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
|
agent, _ := registry.GetAgent("custom")
|
||||||
|
if agent.Model != "claude-opus" {
|
||||||
|
t.Errorf("agent.Model = %q, want 'claude-opus'", agent.Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentInstance_FallbackInheritance(t *testing.T) {
|
||||||
|
cfg := testCfg([]config.AgentConfig{
|
||||||
|
{ID: "inherit", Default: true},
|
||||||
|
})
|
||||||
|
cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"}
|
||||||
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
|
agent, _ := registry.GetAgent("inherit")
|
||||||
|
if len(agent.Fallbacks) != 2 {
|
||||||
|
t.Errorf("expected 2 fallbacks inherited from defaults, got %d", len(agent.Fallbacks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) {
|
||||||
|
model := &config.AgentModelConfig{
|
||||||
|
Primary: "gpt-4",
|
||||||
|
Fallbacks: []string{}, // explicitly empty = disable
|
||||||
|
}
|
||||||
|
cfg := testCfg([]config.AgentConfig{
|
||||||
|
{ID: "no-fallback", Default: true, Model: model},
|
||||||
|
})
|
||||||
|
cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"}
|
||||||
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
|
agent, _ := registry.GetAgent("no-fallback")
|
||||||
|
if len(agent.Fallbacks) != 0 {
|
||||||
|
t.Errorf("expected 0 fallbacks (explicit empty), got %d: %v", len(agent.Fallbacks), agent.Fallbacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,6 @@ package channels
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
|
@ -87,17 +86,13 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build session key: channel:chatID
|
|
||||||
sessionKey := fmt.Sprintf("%s:%s", c.name, chatID)
|
|
||||||
|
|
||||||
msg := bus.InboundMessage{
|
msg := bus.InboundMessage{
|
||||||
Channel: c.name,
|
Channel: c.name,
|
||||||
SenderID: senderID,
|
SenderID: senderID,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
Content: content,
|
Content: content,
|
||||||
Media: media,
|
Media: media,
|
||||||
SessionKey: sessionKey,
|
Metadata: metadata,
|
||||||
Metadata: metadata,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.bus.PublishInbound(msg)
|
c.bus.PublishInbound(msg)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
|
|
@ -106,7 +105,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
chunks := splitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks
|
chunks := utils.SplitMessage(msg.Content, 2000) // Split messages into chunks, Discord length limit: 2000 chars
|
||||||
|
|
||||||
for _, chunk := range chunks {
|
for _, chunk := range chunks {
|
||||||
if err := c.sendChunk(ctx, channelID, chunk); err != nil {
|
if err := c.sendChunk(ctx, channelID, chunk); err != nil {
|
||||||
|
|
@ -117,132 +116,6 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitMessage splits long messages into chunks, preserving code block integrity
|
|
||||||
// Uses natural boundaries (newlines, spaces) and extends messages slightly to avoid breaking code blocks
|
|
||||||
func splitMessage(content string, limit int) []string {
|
|
||||||
var messages []string
|
|
||||||
|
|
||||||
for len(content) > 0 {
|
|
||||||
if len(content) <= limit {
|
|
||||||
messages = append(messages, content)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
msgEnd := limit
|
|
||||||
|
|
||||||
// Find natural split point within the limit
|
|
||||||
msgEnd = findLastNewline(content[:limit], 200)
|
|
||||||
if msgEnd <= 0 {
|
|
||||||
msgEnd = findLastSpace(content[:limit], 100)
|
|
||||||
}
|
|
||||||
if msgEnd <= 0 {
|
|
||||||
msgEnd = limit
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this would end with an incomplete code block
|
|
||||||
candidate := content[:msgEnd]
|
|
||||||
unclosedIdx := findLastUnclosedCodeBlock(candidate)
|
|
||||||
|
|
||||||
if unclosedIdx >= 0 {
|
|
||||||
// Message would end with incomplete code block
|
|
||||||
// Try to extend to include the closing ``` (with some buffer)
|
|
||||||
extendedLimit := limit + 500 // Allow 500 char buffer for code blocks
|
|
||||||
if len(content) > extendedLimit {
|
|
||||||
closingIdx := findNextClosingCodeBlock(content, msgEnd)
|
|
||||||
if closingIdx > 0 && closingIdx <= extendedLimit {
|
|
||||||
// Extend to include the closing ```
|
|
||||||
msgEnd = closingIdx
|
|
||||||
} else {
|
|
||||||
// Can't find closing, split before the code block
|
|
||||||
msgEnd = findLastNewline(content[:unclosedIdx], 200)
|
|
||||||
if msgEnd <= 0 {
|
|
||||||
msgEnd = findLastSpace(content[:unclosedIdx], 100)
|
|
||||||
}
|
|
||||||
if msgEnd <= 0 {
|
|
||||||
msgEnd = unclosedIdx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Remaining content fits within extended limit
|
|
||||||
msgEnd = len(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if msgEnd <= 0 {
|
|
||||||
msgEnd = limit
|
|
||||||
}
|
|
||||||
|
|
||||||
messages = append(messages, content[:msgEnd])
|
|
||||||
content = strings.TrimSpace(content[msgEnd:])
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages
|
|
||||||
}
|
|
||||||
|
|
||||||
// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ```
|
|
||||||
// Returns the position of the opening ``` or -1 if all code blocks are complete
|
|
||||||
func findLastUnclosedCodeBlock(text string) int {
|
|
||||||
count := 0
|
|
||||||
lastOpenIdx := -1
|
|
||||||
|
|
||||||
for i := 0; i < len(text); i++ {
|
|
||||||
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
|
|
||||||
if count == 0 {
|
|
||||||
lastOpenIdx = i
|
|
||||||
}
|
|
||||||
count++
|
|
||||||
i += 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If odd number of ``` markers, last one is unclosed
|
|
||||||
if count%2 == 1 {
|
|
||||||
return lastOpenIdx
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// findNextClosingCodeBlock finds the next closing ``` starting from a position
|
|
||||||
// Returns the position after the closing ``` or -1 if not found
|
|
||||||
func findNextClosingCodeBlock(text string, startIdx int) int {
|
|
||||||
for i := startIdx; i < len(text); i++ {
|
|
||||||
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
|
|
||||||
return i + 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// findLastNewline finds the last newline character within the last N characters
|
|
||||||
// Returns the position of the newline or -1 if not found
|
|
||||||
func findLastNewline(s string, searchWindow int) int {
|
|
||||||
searchStart := len(s) - searchWindow
|
|
||||||
if searchStart < 0 {
|
|
||||||
searchStart = 0
|
|
||||||
}
|
|
||||||
for i := len(s) - 1; i >= searchStart; i-- {
|
|
||||||
if s[i] == '\n' {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// findLastSpace finds the last space character within the last N characters
|
|
||||||
// Returns the position of the space or -1 if not found
|
|
||||||
func findLastSpace(s string, searchWindow int) int {
|
|
||||||
searchStart := len(s) - searchWindow
|
|
||||||
if searchStart < 0 {
|
|
||||||
searchStart = 0
|
|
||||||
}
|
|
||||||
for i := len(s) - 1; i >= searchStart; i-- {
|
|
||||||
if s[i] == ' ' || s[i] == '\t' {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
|
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
|
||||||
// 使用传入的 ctx 进行超时控制
|
// 使用传入的 ctx 进行超时控制
|
||||||
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||||
|
|
@ -376,6 +249,13 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
peerKind := "channel"
|
||||||
|
peerID := m.ChannelID
|
||||||
|
if m.GuildID == "" {
|
||||||
|
peerKind = "direct"
|
||||||
|
peerID = senderID
|
||||||
|
}
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"message_id": m.ID,
|
"message_id": m.ID,
|
||||||
"user_id": senderID,
|
"user_id": senderID,
|
||||||
|
|
@ -384,6 +264,8 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
"guild_id": m.GuildID,
|
"guild_id": m.GuildID,
|
||||||
"channel_id": m.ChannelID,
|
"channel_id": m.ChannelID,
|
||||||
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
|
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
|
||||||
|
"peer_kind": peerKind,
|
||||||
|
"peer_id": peerID,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
|
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ type SlackChannel struct {
|
||||||
api *slack.Client
|
api *slack.Client
|
||||||
socketClient *socketmode.Client
|
socketClient *socketmode.Client
|
||||||
botUserID string
|
botUserID string
|
||||||
|
teamID string
|
||||||
transcriber *voice.GroqTranscriber
|
transcriber *voice.GroqTranscriber
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
|
@ -72,6 +73,7 @@ func (c *SlackChannel) Start(ctx context.Context) error {
|
||||||
return fmt.Errorf("slack auth test failed: %w", err)
|
return fmt.Errorf("slack auth test failed: %w", err)
|
||||||
}
|
}
|
||||||
c.botUserID = authResp.UserID
|
c.botUserID = authResp.UserID
|
||||||
|
c.teamID = authResp.TeamID
|
||||||
|
|
||||||
logger.InfoCF("slack", "Slack bot connected", map[string]interface{}{
|
logger.InfoCF("slack", "Slack bot connected", map[string]interface{}{
|
||||||
"bot_user_id": c.botUserID,
|
"bot_user_id": c.botUserID,
|
||||||
|
|
@ -274,11 +276,21 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
peerKind := "channel"
|
||||||
|
peerID := channelID
|
||||||
|
if strings.HasPrefix(channelID, "D") {
|
||||||
|
peerKind = "direct"
|
||||||
|
peerID = senderID
|
||||||
|
}
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"message_ts": messageTS,
|
"message_ts": messageTS,
|
||||||
"channel_id": channelID,
|
"channel_id": channelID,
|
||||||
"thread_ts": threadTS,
|
"thread_ts": threadTS,
|
||||||
"platform": "slack",
|
"platform": "slack",
|
||||||
|
"peer_kind": peerKind,
|
||||||
|
"peer_id": peerID,
|
||||||
|
"team_id": c.teamID,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("slack", "Received message", map[string]interface{}{
|
logger.DebugCF("slack", "Received message", map[string]interface{}{
|
||||||
|
|
@ -331,12 +343,22 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mentionPeerKind := "channel"
|
||||||
|
mentionPeerID := channelID
|
||||||
|
if strings.HasPrefix(channelID, "D") {
|
||||||
|
mentionPeerKind = "direct"
|
||||||
|
mentionPeerID = senderID
|
||||||
|
}
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"message_ts": messageTS,
|
"message_ts": messageTS,
|
||||||
"channel_id": channelID,
|
"channel_id": channelID,
|
||||||
"thread_ts": threadTS,
|
"thread_ts": threadTS,
|
||||||
"platform": "slack",
|
"platform": "slack",
|
||||||
"is_mention": "true",
|
"is_mention": "true",
|
||||||
|
"peer_kind": mentionPeerKind,
|
||||||
|
"peer_id": mentionPeerID,
|
||||||
|
"team_id": c.teamID,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
||||||
|
|
@ -373,6 +395,9 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
||||||
"platform": "slack",
|
"platform": "slack",
|
||||||
"is_command": "true",
|
"is_command": "true",
|
||||||
"trigger_id": cmd.TriggerID,
|
"trigger_id": cmd.TriggerID,
|
||||||
|
"peer_kind": "channel",
|
||||||
|
"peer_id": channelID,
|
||||||
|
"team_id": c.teamID,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("slack", "Slash command received", map[string]interface{}{
|
logger.DebugCF("slack", "Slash command received", map[string]interface{}{
|
||||||
|
|
|
||||||
|
|
@ -347,12 +347,21 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
c.placeholders.Store(chatIDStr, pID)
|
c.placeholders.Store(chatIDStr, pID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
peerKind := "direct"
|
||||||
|
peerID := fmt.Sprintf("%d", user.ID)
|
||||||
|
if message.Chat.Type != "private" {
|
||||||
|
peerKind = "group"
|
||||||
|
peerID = fmt.Sprintf("%d", chatID)
|
||||||
|
}
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"message_id": fmt.Sprintf("%d", message.MessageID),
|
"message_id": fmt.Sprintf("%d", message.MessageID),
|
||||||
"user_id": fmt.Sprintf("%d", user.ID),
|
"user_id": fmt.Sprintf("%d", user.ID),
|
||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
"first_name": user.FirstName,
|
"first_name": user.FirstName,
|
||||||
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
||||||
|
"peer_kind": peerKind,
|
||||||
|
"peer_id": peerID,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
|
c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Agents AgentsConfig `json:"agents"`
|
Agents AgentsConfig `json:"agents"`
|
||||||
|
Bindings []AgentBinding `json:"bindings,omitempty"`
|
||||||
|
Session SessionConfig `json:"session,omitempty"`
|
||||||
Channels ChannelsConfig `json:"channels"`
|
Channels ChannelsConfig `json:"channels"`
|
||||||
Providers ProvidersConfig `json:"providers"`
|
Providers ProvidersConfig `json:"providers"`
|
||||||
Gateway GatewayConfig `json:"gateway"`
|
Gateway GatewayConfig `json:"gateway"`
|
||||||
|
|
@ -56,16 +58,97 @@ type Config struct {
|
||||||
|
|
||||||
type AgentsConfig struct {
|
type AgentsConfig struct {
|
||||||
Defaults AgentDefaults `json:"defaults"`
|
Defaults AgentDefaults `json:"defaults"`
|
||||||
|
List []AgentConfig `json:"list,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentModelConfig supports both string and structured model config.
|
||||||
|
// String format: "gpt-4" (just primary, no fallbacks)
|
||||||
|
// Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]}
|
||||||
|
type AgentModelConfig struct {
|
||||||
|
Primary string `json:"primary,omitempty"`
|
||||||
|
Fallbacks []string `json:"fallbacks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AgentModelConfig) UnmarshalJSON(data []byte) error {
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(data, &s); err == nil {
|
||||||
|
m.Primary = s
|
||||||
|
m.Fallbacks = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
type raw struct {
|
||||||
|
Primary string `json:"primary"`
|
||||||
|
Fallbacks []string `json:"fallbacks"`
|
||||||
|
}
|
||||||
|
var r raw
|
||||||
|
if err := json.Unmarshal(data, &r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Primary = r.Primary
|
||||||
|
m.Fallbacks = r.Fallbacks
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AgentModelConfig) MarshalJSON() ([]byte, error) {
|
||||||
|
if len(m.Fallbacks) == 0 && m.Primary != "" {
|
||||||
|
return json.Marshal(m.Primary)
|
||||||
|
}
|
||||||
|
type raw struct {
|
||||||
|
Primary string `json:"primary,omitempty"`
|
||||||
|
Fallbacks []string `json:"fallbacks,omitempty"`
|
||||||
|
}
|
||||||
|
return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks})
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentConfig struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Default bool `json:"default,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Workspace string `json:"workspace,omitempty"`
|
||||||
|
Model *AgentModelConfig `json:"model,omitempty"`
|
||||||
|
Skills []string `json:"skills,omitempty"`
|
||||||
|
Subagents *SubagentsConfig `json:"subagents,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubagentsConfig struct {
|
||||||
|
AllowAgents []string `json:"allow_agents,omitempty"`
|
||||||
|
Model *AgentModelConfig `json:"model,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PeerMatch struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BindingMatch struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
AccountID string `json:"account_id,omitempty"`
|
||||||
|
Peer *PeerMatch `json:"peer,omitempty"`
|
||||||
|
GuildID string `json:"guild_id,omitempty"`
|
||||||
|
TeamID string `json:"team_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentBinding struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
Match BindingMatch `json:"match"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionConfig struct {
|
||||||
|
DMScope string `json:"dm_scope,omitempty"`
|
||||||
|
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentDefaults struct {
|
type AgentDefaults struct {
|
||||||
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
||||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||||
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
|
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
|
||||||
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
||||||
Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||||
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||||
|
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
||||||
|
Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
||||||
|
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelsConfig struct {
|
type ChannelsConfig struct {
|
||||||
|
|
@ -227,9 +310,15 @@ type CronToolsConfig struct {
|
||||||
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
|
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExecConfig struct {
|
||||||
|
EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
|
||||||
|
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
|
||||||
|
}
|
||||||
|
|
||||||
type ToolsConfig struct {
|
type ToolsConfig struct {
|
||||||
Web WebToolsConfig `json:"web"`
|
Web WebToolsConfig `json:"web"`
|
||||||
Cron CronToolsConfig `json:"cron"`
|
Cron CronToolsConfig `json:"cron"`
|
||||||
|
Exec ExecConfig `json:"exec"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig() *Config {
|
func DefaultConfig() *Config {
|
||||||
|
|
@ -347,6 +436,9 @@ func DefaultConfig() *Config {
|
||||||
Cron: CronToolsConfig{
|
Cron: CronToolsConfig{
|
||||||
ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations
|
ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations
|
||||||
},
|
},
|
||||||
|
Exec: ExecConfig{
|
||||||
|
EnableDenyPatterns: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Heartbeat: HeartbeatConfig{
|
Heartbeat: HeartbeatConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|
@ -452,6 +544,32 @@ func (c *Config) GetAPIBase() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ModelConfig holds primary model and fallback list.
|
||||||
|
type ModelConfig struct {
|
||||||
|
Primary string
|
||||||
|
Fallbacks []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModelConfig returns the text model configuration with fallbacks.
|
||||||
|
func (c *Config) GetModelConfig() ModelConfig {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return ModelConfig{
|
||||||
|
Primary: c.Agents.Defaults.Model,
|
||||||
|
Fallbacks: c.Agents.Defaults.ModelFallbacks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetImageModelConfig returns the image model configuration with fallbacks.
|
||||||
|
func (c *Config) GetImageModelConfig() ModelConfig {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return ModelConfig{
|
||||||
|
Primary: c.Agents.Defaults.ImageModel,
|
||||||
|
Fallbacks: c.Agents.Defaults.ImageModelFallbacks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func expandHome(path string) string {
|
func expandHome(path string) string {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return path
|
return path
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,193 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestAgentModelConfig_UnmarshalString(t *testing.T) {
|
||||||
|
var m AgentModelConfig
|
||||||
|
if err := json.Unmarshal([]byte(`"gpt-4"`), &m); err != nil {
|
||||||
|
t.Fatalf("unmarshal string: %v", err)
|
||||||
|
}
|
||||||
|
if m.Primary != "gpt-4" {
|
||||||
|
t.Errorf("Primary = %q, want 'gpt-4'", m.Primary)
|
||||||
|
}
|
||||||
|
if m.Fallbacks != nil {
|
||||||
|
t.Errorf("Fallbacks = %v, want nil", m.Fallbacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentModelConfig_UnmarshalObject(t *testing.T) {
|
||||||
|
var m AgentModelConfig
|
||||||
|
data := `{"primary": "claude-opus", "fallbacks": ["gpt-4o-mini", "haiku"]}`
|
||||||
|
if err := json.Unmarshal([]byte(data), &m); err != nil {
|
||||||
|
t.Fatalf("unmarshal object: %v", err)
|
||||||
|
}
|
||||||
|
if m.Primary != "claude-opus" {
|
||||||
|
t.Errorf("Primary = %q, want 'claude-opus'", m.Primary)
|
||||||
|
}
|
||||||
|
if len(m.Fallbacks) != 2 {
|
||||||
|
t.Fatalf("Fallbacks len = %d, want 2", len(m.Fallbacks))
|
||||||
|
}
|
||||||
|
if m.Fallbacks[0] != "gpt-4o-mini" || m.Fallbacks[1] != "haiku" {
|
||||||
|
t.Errorf("Fallbacks = %v", m.Fallbacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentModelConfig_MarshalString(t *testing.T) {
|
||||||
|
m := AgentModelConfig{Primary: "gpt-4"}
|
||||||
|
data, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != `"gpt-4"` {
|
||||||
|
t.Errorf("marshal = %s, want '\"gpt-4\"'", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentModelConfig_MarshalObject(t *testing.T) {
|
||||||
|
m := AgentModelConfig{Primary: "claude-opus", Fallbacks: []string{"haiku"}}
|
||||||
|
data, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var result map[string]interface{}
|
||||||
|
json.Unmarshal(data, &result)
|
||||||
|
if result["primary"] != "claude-opus" {
|
||||||
|
t.Errorf("primary = %v", result["primary"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentConfig_FullParse(t *testing.T) {
|
||||||
|
jsonData := `{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "~/.picoclaw/workspace",
|
||||||
|
"model": "glm-4.7",
|
||||||
|
"max_tokens": 8192,
|
||||||
|
"max_tool_iterations": 20
|
||||||
|
},
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": "sales",
|
||||||
|
"default": true,
|
||||||
|
"name": "Sales Bot",
|
||||||
|
"model": "gpt-4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "support",
|
||||||
|
"name": "Support Bot",
|
||||||
|
"model": {
|
||||||
|
"primary": "claude-opus",
|
||||||
|
"fallbacks": ["haiku"]
|
||||||
|
},
|
||||||
|
"subagents": {
|
||||||
|
"allow_agents": ["sales"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"agent_id": "support",
|
||||||
|
"match": {
|
||||||
|
"channel": "telegram",
|
||||||
|
"account_id": "*",
|
||||||
|
"peer": {"kind": "direct", "id": "user123"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"session": {
|
||||||
|
"dm_scope": "per-peer",
|
||||||
|
"identity_links": {
|
||||||
|
"john": ["telegram:123", "discord:john#1234"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.Agents.List) != 2 {
|
||||||
|
t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List))
|
||||||
|
}
|
||||||
|
|
||||||
|
sales := cfg.Agents.List[0]
|
||||||
|
if sales.ID != "sales" || !sales.Default || sales.Name != "Sales Bot" {
|
||||||
|
t.Errorf("sales = %+v", sales)
|
||||||
|
}
|
||||||
|
if sales.Model == nil || sales.Model.Primary != "gpt-4" {
|
||||||
|
t.Errorf("sales.Model = %+v", sales.Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
support := cfg.Agents.List[1]
|
||||||
|
if support.ID != "support" || support.Name != "Support Bot" {
|
||||||
|
t.Errorf("support = %+v", support)
|
||||||
|
}
|
||||||
|
if support.Model == nil || support.Model.Primary != "claude-opus" {
|
||||||
|
t.Errorf("support.Model = %+v", support.Model)
|
||||||
|
}
|
||||||
|
if len(support.Model.Fallbacks) != 1 || support.Model.Fallbacks[0] != "haiku" {
|
||||||
|
t.Errorf("support.Model.Fallbacks = %v", support.Model.Fallbacks)
|
||||||
|
}
|
||||||
|
if support.Subagents == nil || len(support.Subagents.AllowAgents) != 1 {
|
||||||
|
t.Errorf("support.Subagents = %+v", support.Subagents)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.Bindings) != 1 {
|
||||||
|
t.Fatalf("bindings len = %d, want 1", len(cfg.Bindings))
|
||||||
|
}
|
||||||
|
binding := cfg.Bindings[0]
|
||||||
|
if binding.AgentID != "support" || binding.Match.Channel != "telegram" {
|
||||||
|
t.Errorf("binding = %+v", binding)
|
||||||
|
}
|
||||||
|
if binding.Match.Peer == nil || binding.Match.Peer.Kind != "direct" || binding.Match.Peer.ID != "user123" {
|
||||||
|
t.Errorf("binding.Match.Peer = %+v", binding.Match.Peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Session.DMScope != "per-peer" {
|
||||||
|
t.Errorf("Session.DMScope = %q", cfg.Session.DMScope)
|
||||||
|
}
|
||||||
|
if len(cfg.Session.IdentityLinks) != 1 {
|
||||||
|
t.Errorf("Session.IdentityLinks = %v", cfg.Session.IdentityLinks)
|
||||||
|
}
|
||||||
|
links := cfg.Session.IdentityLinks["john"]
|
||||||
|
if len(links) != 2 {
|
||||||
|
t.Errorf("john links = %v", links)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) {
|
||||||
|
jsonData := `{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "~/.picoclaw/workspace",
|
||||||
|
"model": "glm-4.7",
|
||||||
|
"max_tokens": 8192,
|
||||||
|
"max_tool_iterations": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.Agents.List) != 0 {
|
||||||
|
t.Errorf("agents.list should be empty for backward compat, got %d", len(cfg.Agents.List))
|
||||||
|
}
|
||||||
|
if len(cfg.Bindings) != 0 {
|
||||||
|
t.Errorf("bindings should be empty, got %d", len(cfg.Bindings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestDefaultConfig_HeartbeatEnabled verifies heartbeat is enabled by default
|
// TestDefaultConfig_HeartbeatEnabled verifies heartbeat is enabled by default
|
||||||
func TestDefaultConfig_HeartbeatEnabled(t *testing.T) {
|
func TestDefaultConfig_HeartbeatEnabled(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
@ -20,8 +201,6 @@ func TestDefaultConfig_HeartbeatEnabled(t *testing.T) {
|
||||||
func TestDefaultConfig_WorkspacePath(t *testing.T) {
|
func TestDefaultConfig_WorkspacePath(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
// Just verify the workspace is set, don't compare exact paths
|
|
||||||
// since expandHome behavior may differ based on environment
|
|
||||||
if cfg.Agents.Defaults.Workspace == "" {
|
if cfg.Agents.Defaults.Workspace == "" {
|
||||||
t.Error("Workspace should not be empty")
|
t.Error("Workspace should not be empty")
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +258,6 @@ func TestDefaultConfig_Gateway(t *testing.T) {
|
||||||
func TestDefaultConfig_Providers(t *testing.T) {
|
func TestDefaultConfig_Providers(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
// Verify all providers are empty by default
|
|
||||||
if cfg.Providers.Anthropic.APIKey != "" {
|
if cfg.Providers.Anthropic.APIKey != "" {
|
||||||
t.Error("Anthropic API key should be empty by default")
|
t.Error("Anthropic API key should be empty by default")
|
||||||
}
|
}
|
||||||
|
|
@ -89,46 +267,18 @@ func TestDefaultConfig_Providers(t *testing.T) {
|
||||||
if cfg.Providers.OpenRouter.APIKey != "" {
|
if cfg.Providers.OpenRouter.APIKey != "" {
|
||||||
t.Error("OpenRouter API key should be empty by default")
|
t.Error("OpenRouter API key should be empty by default")
|
||||||
}
|
}
|
||||||
if cfg.Providers.Groq.APIKey != "" {
|
|
||||||
t.Error("Groq API key should be empty by default")
|
|
||||||
}
|
|
||||||
if cfg.Providers.Zhipu.APIKey != "" {
|
|
||||||
t.Error("Zhipu API key should be empty by default")
|
|
||||||
}
|
|
||||||
if cfg.Providers.VLLM.APIKey != "" {
|
|
||||||
t.Error("VLLM API key should be empty by default")
|
|
||||||
}
|
|
||||||
if cfg.Providers.Gemini.APIKey != "" {
|
|
||||||
t.Error("Gemini API key should be empty by default")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDefaultConfig_Channels verifies channels are disabled by default
|
// TestDefaultConfig_Channels verifies channels are disabled by default
|
||||||
func TestDefaultConfig_Channels(t *testing.T) {
|
func TestDefaultConfig_Channels(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
// Verify all channels are disabled by default
|
|
||||||
if cfg.Channels.WhatsApp.Enabled {
|
|
||||||
t.Error("WhatsApp should be disabled by default")
|
|
||||||
}
|
|
||||||
if cfg.Channels.Telegram.Enabled {
|
if cfg.Channels.Telegram.Enabled {
|
||||||
t.Error("Telegram should be disabled by default")
|
t.Error("Telegram should be disabled by default")
|
||||||
}
|
}
|
||||||
if cfg.Channels.Feishu.Enabled {
|
|
||||||
t.Error("Feishu should be disabled by default")
|
|
||||||
}
|
|
||||||
if cfg.Channels.Discord.Enabled {
|
if cfg.Channels.Discord.Enabled {
|
||||||
t.Error("Discord should be disabled by default")
|
t.Error("Discord should be disabled by default")
|
||||||
}
|
}
|
||||||
if cfg.Channels.MaixCam.Enabled {
|
|
||||||
t.Error("MaixCam should be disabled by default")
|
|
||||||
}
|
|
||||||
if cfg.Channels.QQ.Enabled {
|
|
||||||
t.Error("QQ should be disabled by default")
|
|
||||||
}
|
|
||||||
if cfg.Channels.DingTalk.Enabled {
|
|
||||||
t.Error("DingTalk should be disabled by default")
|
|
||||||
}
|
|
||||||
if cfg.Channels.Slack.Enabled {
|
if cfg.Channels.Slack.Enabled {
|
||||||
t.Error("Slack should be disabled by default")
|
t.Error("Slack should be disabled by default")
|
||||||
}
|
}
|
||||||
|
|
@ -178,7 +328,6 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
|
||||||
func TestConfig_Complete(t *testing.T) {
|
func TestConfig_Complete(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
// Verify complete config structure
|
|
||||||
if cfg.Agents.Defaults.Workspace == "" {
|
if cfg.Agents.Defaults.Workspace == "" {
|
||||||
t.Error("Workspace should not be empty")
|
t.Error("Workspace should not be empty")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,24 @@ func TestConvertConfig(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSupportedProvidersCompatibility(t *testing.T) {
|
||||||
|
expected := []string{
|
||||||
|
"anthropic",
|
||||||
|
"openai",
|
||||||
|
"openrouter",
|
||||||
|
"groq",
|
||||||
|
"zhipu",
|
||||||
|
"vllm",
|
||||||
|
"gemini",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, provider := range expected {
|
||||||
|
if !supportedProviders[provider] {
|
||||||
|
t.Fatalf("supportedProviders missing expected key %q", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMergeConfig(t *testing.T) {
|
func TestMergeConfig(t *testing.T) {
|
||||||
t.Run("fills empty fields", func(t *testing.T) {
|
t.Run("fills empty fields", func(t *testing.T) {
|
||||||
existing := config.DefaultConfig()
|
existing := config.DefaultConfig()
|
||||||
|
|
|
||||||
248
pkg/providers/anthropic/provider.go
Normal file
248
pkg/providers/anthropic/provider.go
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
package anthropicprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
|
"github.com/anthropics/anthropic-sdk-go/option"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ToolCall = protocoltypes.ToolCall
|
||||||
|
type FunctionCall = protocoltypes.FunctionCall
|
||||||
|
type LLMResponse = protocoltypes.LLMResponse
|
||||||
|
type UsageInfo = protocoltypes.UsageInfo
|
||||||
|
type Message = protocoltypes.Message
|
||||||
|
type ToolDefinition = protocoltypes.ToolDefinition
|
||||||
|
type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||||
|
|
||||||
|
const defaultBaseURL = "https://api.anthropic.com"
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
client *anthropic.Client
|
||||||
|
tokenSource func() (string, error)
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProvider(token string) *Provider {
|
||||||
|
return NewProviderWithBaseURL(token, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProviderWithBaseURL(token, apiBase string) *Provider {
|
||||||
|
baseURL := normalizeBaseURL(apiBase)
|
||||||
|
client := anthropic.NewClient(
|
||||||
|
option.WithAuthToken(token),
|
||||||
|
option.WithBaseURL(baseURL),
|
||||||
|
)
|
||||||
|
return &Provider{
|
||||||
|
client: &client,
|
||||||
|
baseURL: baseURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProviderWithClient(client *anthropic.Client) *Provider {
|
||||||
|
return &Provider{
|
||||||
|
client: client,
|
||||||
|
baseURL: defaultBaseURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProviderWithTokenSource(token string, tokenSource func() (string, error)) *Provider {
|
||||||
|
return NewProviderWithTokenSourceAndBaseURL(token, tokenSource, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *Provider {
|
||||||
|
p := NewProviderWithBaseURL(token, apiBase)
|
||||||
|
p.tokenSource = tokenSource
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||||
|
var opts []option.RequestOption
|
||||||
|
if p.tokenSource != nil {
|
||||||
|
tok, err := p.tokenSource()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("refreshing token: %w", err)
|
||||||
|
}
|
||||||
|
opts = append(opts, option.WithAuthToken(tok))
|
||||||
|
}
|
||||||
|
|
||||||
|
params, err := buildParams(messages, tools, model, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.client.Messages.New(ctx, params, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("claude API call: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseResponse(resp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) GetDefaultModel() string {
|
||||||
|
return "claude-sonnet-4-5-20250929"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) BaseURL() string {
|
||||||
|
return p.baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) {
|
||||||
|
var system []anthropic.TextBlockParam
|
||||||
|
var anthropicMessages []anthropic.MessageParam
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
switch msg.Role {
|
||||||
|
case "system":
|
||||||
|
system = append(system, anthropic.TextBlockParam{Text: msg.Content})
|
||||||
|
case "user":
|
||||||
|
if msg.ToolCallID != "" {
|
||||||
|
anthropicMessages = append(anthropicMessages,
|
||||||
|
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
anthropicMessages = append(anthropicMessages,
|
||||||
|
anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case "assistant":
|
||||||
|
if len(msg.ToolCalls) > 0 {
|
||||||
|
var blocks []anthropic.ContentBlockParamUnion
|
||||||
|
if msg.Content != "" {
|
||||||
|
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
|
||||||
|
}
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
|
||||||
|
}
|
||||||
|
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
|
||||||
|
} else {
|
||||||
|
anthropicMessages = append(anthropicMessages,
|
||||||
|
anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case "tool":
|
||||||
|
anthropicMessages = append(anthropicMessages,
|
||||||
|
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maxTokens := int64(4096)
|
||||||
|
if mt, ok := options["max_tokens"].(int); ok {
|
||||||
|
maxTokens = int64(mt)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := anthropic.MessageNewParams{
|
||||||
|
Model: anthropic.Model(model),
|
||||||
|
Messages: anthropicMessages,
|
||||||
|
MaxTokens: maxTokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(system) > 0 {
|
||||||
|
params.System = system
|
||||||
|
}
|
||||||
|
|
||||||
|
if temp, ok := options["temperature"].(float64); ok {
|
||||||
|
params.Temperature = anthropic.Float(temp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tools) > 0 {
|
||||||
|
params.Tools = translateTools(tools)
|
||||||
|
}
|
||||||
|
|
||||||
|
return params, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
||||||
|
result := make([]anthropic.ToolUnionParam, 0, len(tools))
|
||||||
|
for _, t := range tools {
|
||||||
|
tool := anthropic.ToolParam{
|
||||||
|
Name: t.Function.Name,
|
||||||
|
InputSchema: anthropic.ToolInputSchemaParam{
|
||||||
|
Properties: t.Function.Parameters["properties"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if desc := t.Function.Description; desc != "" {
|
||||||
|
tool.Description = anthropic.String(desc)
|
||||||
|
}
|
||||||
|
if req, ok := t.Function.Parameters["required"].([]interface{}); ok {
|
||||||
|
required := make([]string, 0, len(req))
|
||||||
|
for _, r := range req {
|
||||||
|
if s, ok := r.(string); ok {
|
||||||
|
required = append(required, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tool.InputSchema.Required = required
|
||||||
|
}
|
||||||
|
result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseResponse(resp *anthropic.Message) *LLMResponse {
|
||||||
|
var content string
|
||||||
|
var toolCalls []ToolCall
|
||||||
|
|
||||||
|
for _, block := range resp.Content {
|
||||||
|
switch block.Type {
|
||||||
|
case "text":
|
||||||
|
tb := block.AsText()
|
||||||
|
content += tb.Text
|
||||||
|
case "tool_use":
|
||||||
|
tu := block.AsToolUse()
|
||||||
|
var args map[string]interface{}
|
||||||
|
if err := json.Unmarshal(tu.Input, &args); err != nil {
|
||||||
|
log.Printf("anthropic: failed to decode tool call input for %q: %v", tu.Name, err)
|
||||||
|
args = map[string]interface{}{"raw": string(tu.Input)}
|
||||||
|
}
|
||||||
|
toolCalls = append(toolCalls, ToolCall{
|
||||||
|
ID: tu.ID,
|
||||||
|
Name: tu.Name,
|
||||||
|
Arguments: args,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finishReason := "stop"
|
||||||
|
switch resp.StopReason {
|
||||||
|
case anthropic.StopReasonToolUse:
|
||||||
|
finishReason = "tool_calls"
|
||||||
|
case anthropic.StopReasonMaxTokens:
|
||||||
|
finishReason = "length"
|
||||||
|
case anthropic.StopReasonEndTurn:
|
||||||
|
finishReason = "stop"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LLMResponse{
|
||||||
|
Content: content,
|
||||||
|
ToolCalls: toolCalls,
|
||||||
|
FinishReason: finishReason,
|
||||||
|
Usage: &UsageInfo{
|
||||||
|
PromptTokens: int(resp.Usage.InputTokens),
|
||||||
|
CompletionTokens: int(resp.Usage.OutputTokens),
|
||||||
|
TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBaseURL(apiBase string) string {
|
||||||
|
base := strings.TrimSpace(apiBase)
|
||||||
|
if base == "" {
|
||||||
|
return defaultBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
base = strings.TrimRight(base, "/")
|
||||||
|
if strings.HasSuffix(base, "/v1") {
|
||||||
|
base = strings.TrimSuffix(base, "/v1")
|
||||||
|
}
|
||||||
|
if base == "" {
|
||||||
|
return defaultBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
|
}
|
||||||
265
pkg/providers/anthropic/provider_test.go
Normal file
265
pkg/providers/anthropic/provider_test.go
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
package anthropicprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
|
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildParams_BasicMessage(t *testing.T) {
|
||||||
|
messages := []Message{
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
}
|
||||||
|
params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{
|
||||||
|
"max_tokens": 1024,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildParams() error: %v", err)
|
||||||
|
}
|
||||||
|
if string(params.Model) != "claude-sonnet-4-5-20250929" {
|
||||||
|
t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-5-20250929")
|
||||||
|
}
|
||||||
|
if params.MaxTokens != 1024 {
|
||||||
|
t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
|
||||||
|
}
|
||||||
|
if len(params.Messages) != 1 {
|
||||||
|
t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildParams_SystemMessage(t *testing.T) {
|
||||||
|
messages := []Message{
|
||||||
|
{Role: "system", Content: "You are helpful"},
|
||||||
|
{Role: "user", Content: "Hi"},
|
||||||
|
}
|
||||||
|
params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildParams() error: %v", err)
|
||||||
|
}
|
||||||
|
if len(params.System) != 1 {
|
||||||
|
t.Fatalf("len(System) = %d, want 1", len(params.System))
|
||||||
|
}
|
||||||
|
if params.System[0].Text != "You are helpful" {
|
||||||
|
t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful")
|
||||||
|
}
|
||||||
|
if len(params.Messages) != 1 {
|
||||||
|
t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildParams_ToolCallMessage(t *testing.T) {
|
||||||
|
messages := []Message{
|
||||||
|
{Role: "user", Content: "What's the weather?"},
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "",
|
||||||
|
ToolCalls: []ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_1",
|
||||||
|
Name: "get_weather",
|
||||||
|
Arguments: map[string]interface{}{"city": "SF"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
|
||||||
|
}
|
||||||
|
params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildParams() error: %v", err)
|
||||||
|
}
|
||||||
|
if len(params.Messages) != 3 {
|
||||||
|
t.Fatalf("len(Messages) = %d, want 3", len(params.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildParams_WithTools(t *testing.T) {
|
||||||
|
tools := []ToolDefinition{
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolFunctionDefinition{
|
||||||
|
Name: "get_weather",
|
||||||
|
Description: "Get weather for a city",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"city": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
"required": []interface{}{"city"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildParams() error: %v", err)
|
||||||
|
}
|
||||||
|
if len(params.Tools) != 1 {
|
||||||
|
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseResponse_TextOnly(t *testing.T) {
|
||||||
|
resp := &anthropic.Message{
|
||||||
|
Content: []anthropic.ContentBlockUnion{},
|
||||||
|
Usage: anthropic.Usage{
|
||||||
|
InputTokens: 10,
|
||||||
|
OutputTokens: 20,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result := parseResponse(resp)
|
||||||
|
if result.Usage.PromptTokens != 10 {
|
||||||
|
t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens)
|
||||||
|
}
|
||||||
|
if result.Usage.CompletionTokens != 20 {
|
||||||
|
t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens)
|
||||||
|
}
|
||||||
|
if result.FinishReason != "stop" {
|
||||||
|
t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseResponse_StopReasons(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
stopReason anthropic.StopReason
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{anthropic.StopReasonEndTurn, "stop"},
|
||||||
|
{anthropic.StopReasonMaxTokens, "length"},
|
||||||
|
{anthropic.StopReasonToolUse, "tool_calls"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
resp := &anthropic.Message{
|
||||||
|
StopReason: tt.stopReason,
|
||||||
|
}
|
||||||
|
result := parseResponse(resp)
|
||||||
|
if result.FinishReason != tt.want {
|
||||||
|
t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvider_ChatRoundTrip(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/messages" {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqBody map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||||
|
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"id": "msg_test",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": reqBody["model"],
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"content": []map[string]interface{}{
|
||||||
|
{"type": "text", "text": "Hello! How can I help you?"},
|
||||||
|
},
|
||||||
|
"usage": map[string]interface{}{
|
||||||
|
"input_tokens": 15,
|
||||||
|
"output_tokens": 8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
|
||||||
|
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||||
|
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Content != "Hello! How can I help you?" {
|
||||||
|
t.Errorf("Content = %q, want %q", resp.Content, "Hello! How can I help you?")
|
||||||
|
}
|
||||||
|
if resp.FinishReason != "stop" {
|
||||||
|
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||||
|
}
|
||||||
|
if resp.Usage.PromptTokens != 15 {
|
||||||
|
t.Errorf("PromptTokens = %d, want 15", resp.Usage.PromptTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvider_GetDefaultModel(t *testing.T) {
|
||||||
|
p := NewProvider("test-token")
|
||||||
|
if got := p.GetDefaultModel(); got != "claude-sonnet-4-5-20250929" {
|
||||||
|
t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4-5-20250929")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvider_NewProviderWithBaseURL_NormalizesV1Suffix(t *testing.T) {
|
||||||
|
p := NewProviderWithBaseURL("token", "https://api.anthropic.com/v1/")
|
||||||
|
if got := p.BaseURL(); got != "https://api.anthropic.com" {
|
||||||
|
t.Fatalf("BaseURL() = %q, want %q", got, "https://api.anthropic.com")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvider_ChatUsesTokenSource(t *testing.T) {
|
||||||
|
var requests int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/messages" {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
atomic.AddInt32(&requests, 1)
|
||||||
|
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer refreshed-token" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqBody map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||||
|
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"id": "msg_test",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": reqBody["model"],
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"content": []map[string]interface{}{
|
||||||
|
{"type": "text", "text": "ok"},
|
||||||
|
},
|
||||||
|
"usage": map[string]interface{}{
|
||||||
|
"input_tokens": 1,
|
||||||
|
"output_tokens": 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProviderWithTokenSourceAndBaseURL("stale-token", func() (string, error) {
|
||||||
|
return "refreshed-token", nil
|
||||||
|
}, server.URL)
|
||||||
|
|
||||||
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hello"}}, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error: %v", err)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&requests); got != 1 {
|
||||||
|
t.Fatalf("requests = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAnthropicTestClient(baseURL, token string) *anthropic.Client {
|
||||||
|
c := anthropic.NewClient(
|
||||||
|
anthropicoption.WithAuthToken(token),
|
||||||
|
anthropicoption.WithBaseURL(baseURL),
|
||||||
|
)
|
||||||
|
return &c
|
||||||
|
}
|
||||||
|
|
@ -2,200 +2,58 @@ package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/anthropics/anthropic-sdk-go"
|
anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic"
|
||||||
"github.com/anthropics/anthropic-sdk-go/option"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ClaudeProvider struct {
|
type ClaudeProvider struct {
|
||||||
client *anthropic.Client
|
delegate *anthropicprovider.Provider
|
||||||
tokenSource func() (string, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClaudeProvider(token string) *ClaudeProvider {
|
func NewClaudeProvider(token string) *ClaudeProvider {
|
||||||
client := anthropic.NewClient(
|
return &ClaudeProvider{
|
||||||
option.WithAuthToken(token),
|
delegate: anthropicprovider.NewProvider(token),
|
||||||
option.WithBaseURL("https://api.anthropic.com"),
|
}
|
||||||
)
|
}
|
||||||
return &ClaudeProvider{client: &client}
|
|
||||||
|
func NewClaudeProviderWithBaseURL(token, apiBase string) *ClaudeProvider {
|
||||||
|
return &ClaudeProvider{
|
||||||
|
delegate: anthropicprovider.NewProviderWithBaseURL(token, apiBase),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider {
|
func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider {
|
||||||
p := NewClaudeProvider(token)
|
return &ClaudeProvider{
|
||||||
p.tokenSource = tokenSource
|
delegate: anthropicprovider.NewProviderWithTokenSource(token, tokenSource),
|
||||||
return p
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClaudeProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *ClaudeProvider {
|
||||||
|
return &ClaudeProvider{
|
||||||
|
delegate: anthropicprovider.NewProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *ClaudeProvider {
|
||||||
|
return &ClaudeProvider{delegate: delegate}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||||
var opts []option.RequestOption
|
resp, err := p.delegate.Chat(ctx, messages, tools, model, options)
|
||||||
if p.tokenSource != nil {
|
|
||||||
tok, err := p.tokenSource()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("refreshing token: %w", err)
|
|
||||||
}
|
|
||||||
opts = append(opts, option.WithAuthToken(tok))
|
|
||||||
}
|
|
||||||
|
|
||||||
params, err := buildClaudeParams(messages, tools, model, options)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return resp, nil
|
||||||
resp, err := p.client.Messages.New(ctx, params, opts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("claude API call: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseClaudeResponse(resp), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ClaudeProvider) GetDefaultModel() string {
|
func (p *ClaudeProvider) GetDefaultModel() string {
|
||||||
return "claude-sonnet-4-5-20250929"
|
return p.delegate.GetDefaultModel()
|
||||||
}
|
|
||||||
|
|
||||||
func buildClaudeParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) {
|
|
||||||
var system []anthropic.TextBlockParam
|
|
||||||
var anthropicMessages []anthropic.MessageParam
|
|
||||||
|
|
||||||
for _, msg := range messages {
|
|
||||||
switch msg.Role {
|
|
||||||
case "system":
|
|
||||||
system = append(system, anthropic.TextBlockParam{Text: msg.Content})
|
|
||||||
case "user":
|
|
||||||
if msg.ToolCallID != "" {
|
|
||||||
anthropicMessages = append(anthropicMessages,
|
|
||||||
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
anthropicMessages = append(anthropicMessages,
|
|
||||||
anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case "assistant":
|
|
||||||
if len(msg.ToolCalls) > 0 {
|
|
||||||
var blocks []anthropic.ContentBlockParamUnion
|
|
||||||
if msg.Content != "" {
|
|
||||||
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
|
|
||||||
}
|
|
||||||
for _, tc := range msg.ToolCalls {
|
|
||||||
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
|
|
||||||
}
|
|
||||||
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
|
|
||||||
} else {
|
|
||||||
anthropicMessages = append(anthropicMessages,
|
|
||||||
anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case "tool":
|
|
||||||
anthropicMessages = append(anthropicMessages,
|
|
||||||
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
maxTokens := int64(4096)
|
|
||||||
if mt, ok := options["max_tokens"].(int); ok {
|
|
||||||
maxTokens = int64(mt)
|
|
||||||
}
|
|
||||||
|
|
||||||
params := anthropic.MessageNewParams{
|
|
||||||
Model: anthropic.Model(model),
|
|
||||||
Messages: anthropicMessages,
|
|
||||||
MaxTokens: maxTokens,
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(system) > 0 {
|
|
||||||
params.System = system
|
|
||||||
}
|
|
||||||
|
|
||||||
if temp, ok := options["temperature"].(float64); ok {
|
|
||||||
params.Temperature = anthropic.Float(temp)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tools) > 0 {
|
|
||||||
params.Tools = translateToolsForClaude(tools)
|
|
||||||
}
|
|
||||||
|
|
||||||
return params, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func translateToolsForClaude(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
|
||||||
result := make([]anthropic.ToolUnionParam, 0, len(tools))
|
|
||||||
for _, t := range tools {
|
|
||||||
tool := anthropic.ToolParam{
|
|
||||||
Name: t.Function.Name,
|
|
||||||
InputSchema: anthropic.ToolInputSchemaParam{
|
|
||||||
Properties: t.Function.Parameters["properties"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if desc := t.Function.Description; desc != "" {
|
|
||||||
tool.Description = anthropic.String(desc)
|
|
||||||
}
|
|
||||||
if req, ok := t.Function.Parameters["required"].([]interface{}); ok {
|
|
||||||
required := make([]string, 0, len(req))
|
|
||||||
for _, r := range req {
|
|
||||||
if s, ok := r.(string); ok {
|
|
||||||
required = append(required, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tool.InputSchema.Required = required
|
|
||||||
}
|
|
||||||
result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseClaudeResponse(resp *anthropic.Message) *LLMResponse {
|
|
||||||
var content string
|
|
||||||
var toolCalls []ToolCall
|
|
||||||
|
|
||||||
for _, block := range resp.Content {
|
|
||||||
switch block.Type {
|
|
||||||
case "text":
|
|
||||||
tb := block.AsText()
|
|
||||||
content += tb.Text
|
|
||||||
case "tool_use":
|
|
||||||
tu := block.AsToolUse()
|
|
||||||
var args map[string]interface{}
|
|
||||||
if err := json.Unmarshal(tu.Input, &args); err != nil {
|
|
||||||
args = map[string]interface{}{"raw": string(tu.Input)}
|
|
||||||
}
|
|
||||||
toolCalls = append(toolCalls, ToolCall{
|
|
||||||
ID: tu.ID,
|
|
||||||
Name: tu.Name,
|
|
||||||
Arguments: args,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
finishReason := "stop"
|
|
||||||
switch resp.StopReason {
|
|
||||||
case anthropic.StopReasonToolUse:
|
|
||||||
finishReason = "tool_calls"
|
|
||||||
case anthropic.StopReasonMaxTokens:
|
|
||||||
finishReason = "length"
|
|
||||||
case anthropic.StopReasonEndTurn:
|
|
||||||
finishReason = "stop"
|
|
||||||
}
|
|
||||||
|
|
||||||
return &LLMResponse{
|
|
||||||
Content: content,
|
|
||||||
ToolCalls: toolCalls,
|
|
||||||
FinishReason: finishReason,
|
|
||||||
Usage: &UsageInfo{
|
|
||||||
PromptTokens: int(resp.Usage.InputTokens),
|
|
||||||
CompletionTokens: int(resp.Usage.OutputTokens),
|
|
||||||
TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func createClaudeTokenSource() func() (string, error) {
|
func createClaudeTokenSource() func() (string, error) {
|
||||||
return func() (string, error) {
|
return func() (string, error) {
|
||||||
cred, err := auth.GetCredential("anthropic")
|
cred, err := getCredential("anthropic")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("loading auth credentials: %w", err)
|
return "", fmt.Errorf("loading auth credentials: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,140 +8,9 @@ import (
|
||||||
|
|
||||||
"github.com/anthropics/anthropic-sdk-go"
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
|
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
|
||||||
|
anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildClaudeParams_BasicMessage(t *testing.T) {
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "user", Content: "Hello"},
|
|
||||||
}
|
|
||||||
params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{
|
|
||||||
"max_tokens": 1024,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("buildClaudeParams() error: %v", err)
|
|
||||||
}
|
|
||||||
if string(params.Model) != "claude-sonnet-4-5-20250929" {
|
|
||||||
t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-5-20250929")
|
|
||||||
}
|
|
||||||
if params.MaxTokens != 1024 {
|
|
||||||
t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
|
|
||||||
}
|
|
||||||
if len(params.Messages) != 1 {
|
|
||||||
t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildClaudeParams_SystemMessage(t *testing.T) {
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "system", Content: "You are helpful"},
|
|
||||||
{Role: "user", Content: "Hi"},
|
|
||||||
}
|
|
||||||
params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("buildClaudeParams() error: %v", err)
|
|
||||||
}
|
|
||||||
if len(params.System) != 1 {
|
|
||||||
t.Fatalf("len(System) = %d, want 1", len(params.System))
|
|
||||||
}
|
|
||||||
if params.System[0].Text != "You are helpful" {
|
|
||||||
t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful")
|
|
||||||
}
|
|
||||||
if len(params.Messages) != 1 {
|
|
||||||
t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildClaudeParams_ToolCallMessage(t *testing.T) {
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "user", Content: "What's the weather?"},
|
|
||||||
{
|
|
||||||
Role: "assistant",
|
|
||||||
Content: "",
|
|
||||||
ToolCalls: []ToolCall{
|
|
||||||
{
|
|
||||||
ID: "call_1",
|
|
||||||
Name: "get_weather",
|
|
||||||
Arguments: map[string]interface{}{"city": "SF"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
|
|
||||||
}
|
|
||||||
params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("buildClaudeParams() error: %v", err)
|
|
||||||
}
|
|
||||||
if len(params.Messages) != 3 {
|
|
||||||
t.Fatalf("len(Messages) = %d, want 3", len(params.Messages))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildClaudeParams_WithTools(t *testing.T) {
|
|
||||||
tools := []ToolDefinition{
|
|
||||||
{
|
|
||||||
Type: "function",
|
|
||||||
Function: ToolFunctionDefinition{
|
|
||||||
Name: "get_weather",
|
|
||||||
Description: "Get weather for a city",
|
|
||||||
Parameters: map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"city": map[string]interface{}{"type": "string"},
|
|
||||||
},
|
|
||||||
"required": []interface{}{"city"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
params, err := buildClaudeParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("buildClaudeParams() error: %v", err)
|
|
||||||
}
|
|
||||||
if len(params.Tools) != 1 {
|
|
||||||
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseClaudeResponse_TextOnly(t *testing.T) {
|
|
||||||
resp := &anthropic.Message{
|
|
||||||
Content: []anthropic.ContentBlockUnion{},
|
|
||||||
Usage: anthropic.Usage{
|
|
||||||
InputTokens: 10,
|
|
||||||
OutputTokens: 20,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
result := parseClaudeResponse(resp)
|
|
||||||
if result.Usage.PromptTokens != 10 {
|
|
||||||
t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens)
|
|
||||||
}
|
|
||||||
if result.Usage.CompletionTokens != 20 {
|
|
||||||
t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens)
|
|
||||||
}
|
|
||||||
if result.FinishReason != "stop" {
|
|
||||||
t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseClaudeResponse_StopReasons(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
stopReason anthropic.StopReason
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{anthropic.StopReasonEndTurn, "stop"},
|
|
||||||
{anthropic.StopReasonMaxTokens, "length"},
|
|
||||||
{anthropic.StopReasonToolUse, "tool_calls"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
resp := &anthropic.Message{
|
|
||||||
StopReason: tt.stopReason,
|
|
||||||
}
|
|
||||||
result := parseClaudeResponse(resp)
|
|
||||||
if result.FinishReason != tt.want {
|
|
||||||
t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
|
func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/v1/messages" {
|
if r.URL.Path != "/v1/messages" {
|
||||||
|
|
@ -175,8 +44,8 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
provider := NewClaudeProvider("test-token")
|
delegate := anthropicprovider.NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
|
||||||
provider.client = createAnthropicTestClient(server.URL, "test-token")
|
provider := newClaudeProviderWithDelegate(delegate)
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||||
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024})
|
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024})
|
||||||
|
|
|
||||||
119
pkg/providers/codex_cli_provider_integration_test.go
Normal file
119
pkg/providers/codex_cli_provider_integration_test.go
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
exec "os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIntegration_RealCodexCLI tests the CodexCliProvider with a real codex CLI.
|
||||||
|
// Run with: go test -tags=integration ./pkg/providers/...
|
||||||
|
func TestIntegration_RealCodexCLI(t *testing.T) {
|
||||||
|
path, err := exec.LookPath("codex")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("codex CLI not found in PATH, skipping integration test")
|
||||||
|
}
|
||||||
|
t.Logf("Using codex CLI at: %s", path)
|
||||||
|
|
||||||
|
p := NewCodexCliProvider(t.TempDir())
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := p.Chat(ctx, []Message{
|
||||||
|
{Role: "user", Content: "Respond with only the word 'pong'. Nothing else."},
|
||||||
|
}, nil, "", nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() with real CLI error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Content == "" {
|
||||||
|
t.Error("Content is empty")
|
||||||
|
}
|
||||||
|
if resp.FinishReason != "stop" {
|
||||||
|
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||||
|
}
|
||||||
|
if resp.Usage != nil {
|
||||||
|
t.Logf("Usage: prompt=%d, completion=%d, total=%d",
|
||||||
|
resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Response content: %q", resp.Content)
|
||||||
|
|
||||||
|
if !strings.Contains(strings.ToLower(resp.Content), "pong") {
|
||||||
|
t.Errorf("Content = %q, expected to contain 'pong'", resp.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_RealCodexCLI_WithSystemPrompt(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("codex"); err != nil {
|
||||||
|
t.Skip("codex CLI not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
p := NewCodexCliProvider(t.TempDir())
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := p.Chat(ctx, []Message{
|
||||||
|
{Role: "system", Content: "You are a calculator. Only respond with numbers. No text."},
|
||||||
|
{Role: "user", Content: "What is 2+2?"},
|
||||||
|
}, nil, "", nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Response: %q", resp.Content)
|
||||||
|
|
||||||
|
if !strings.Contains(resp.Content, "4") {
|
||||||
|
t.Errorf("Content = %q, expected to contain '4'", resp.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_RealCodexCLI_ParsesRealJSONL(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("codex"); err != nil {
|
||||||
|
t.Skip("codex CLI not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run codex directly and verify our parser handles real output
|
||||||
|
cmd := exec.Command("codex", "exec",
|
||||||
|
"--json",
|
||||||
|
"--dangerously-bypass-approvals-and-sandbox",
|
||||||
|
"--skip-git-repo-check",
|
||||||
|
"--color", "never",
|
||||||
|
"-C", t.TempDir(),
|
||||||
|
"-")
|
||||||
|
cmd.Stdin = strings.NewReader("Say hi")
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
// codex may write diagnostic noise to stderr but still produce valid output
|
||||||
|
if len(output) == 0 {
|
||||||
|
t.Fatalf("codex CLI failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Raw CLI output (first 500 chars): %s", string(output[:min(len(output), 500)]))
|
||||||
|
|
||||||
|
// Verify our parser can handle real output
|
||||||
|
p := NewCodexCliProvider("")
|
||||||
|
resp, err := p.parseJSONLEvents(string(output))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseJSONLEvents() failed on real CLI output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Content == "" {
|
||||||
|
t.Error("parsed Content is empty")
|
||||||
|
}
|
||||||
|
if resp.FinishReason != "stop" {
|
||||||
|
t.Errorf("FinishReason = %q, want stop", resp.FinishReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Parsed: content=%q, finish=%s, usage=%+v", resp.Content, resp.FinishReason, resp.Usage)
|
||||||
|
}
|
||||||
207
pkg/providers/cooldown.go
Normal file
207
pkg/providers/cooldown.go
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultFailureWindow = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// CooldownTracker manages per-provider cooldown state for the fallback chain.
|
||||||
|
// Thread-safe via sync.RWMutex. In-memory only (resets on restart).
|
||||||
|
type CooldownTracker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries map[string]*cooldownEntry
|
||||||
|
failureWindow time.Duration
|
||||||
|
nowFunc func() time.Time // for testing
|
||||||
|
}
|
||||||
|
|
||||||
|
type cooldownEntry struct {
|
||||||
|
ErrorCount int
|
||||||
|
FailureCounts map[FailoverReason]int
|
||||||
|
CooldownEnd time.Time // standard cooldown expiry
|
||||||
|
DisabledUntil time.Time // billing-specific disable expiry
|
||||||
|
DisabledReason FailoverReason // reason for disable (billing)
|
||||||
|
LastFailure time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCooldownTracker creates a tracker with default 24h failure window.
|
||||||
|
func NewCooldownTracker() *CooldownTracker {
|
||||||
|
return &CooldownTracker{
|
||||||
|
entries: make(map[string]*cooldownEntry),
|
||||||
|
failureWindow: defaultFailureWindow,
|
||||||
|
nowFunc: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkFailure records a failure for a provider and sets appropriate cooldown.
|
||||||
|
// Resets error counts if last failure was more than failureWindow ago.
|
||||||
|
func (ct *CooldownTracker) MarkFailure(provider string, reason FailoverReason) {
|
||||||
|
ct.mu.Lock()
|
||||||
|
defer ct.mu.Unlock()
|
||||||
|
|
||||||
|
now := ct.nowFunc()
|
||||||
|
entry := ct.getOrCreate(provider)
|
||||||
|
|
||||||
|
// 24h failure window reset: if no failure in failureWindow, reset counters.
|
||||||
|
if !entry.LastFailure.IsZero() && now.Sub(entry.LastFailure) > ct.failureWindow {
|
||||||
|
entry.ErrorCount = 0
|
||||||
|
entry.FailureCounts = make(map[FailoverReason]int)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.ErrorCount++
|
||||||
|
entry.FailureCounts[reason]++
|
||||||
|
entry.LastFailure = now
|
||||||
|
|
||||||
|
if reason == FailoverBilling {
|
||||||
|
billingCount := entry.FailureCounts[FailoverBilling]
|
||||||
|
entry.DisabledUntil = now.Add(calculateBillingCooldown(billingCount))
|
||||||
|
entry.DisabledReason = FailoverBilling
|
||||||
|
} else {
|
||||||
|
entry.CooldownEnd = now.Add(calculateStandardCooldown(entry.ErrorCount))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkSuccess resets all counters and cooldowns for a provider.
|
||||||
|
func (ct *CooldownTracker) MarkSuccess(provider string) {
|
||||||
|
ct.mu.Lock()
|
||||||
|
defer ct.mu.Unlock()
|
||||||
|
|
||||||
|
entry := ct.entries[provider]
|
||||||
|
if entry == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.ErrorCount = 0
|
||||||
|
entry.FailureCounts = make(map[FailoverReason]int)
|
||||||
|
entry.CooldownEnd = time.Time{}
|
||||||
|
entry.DisabledUntil = time.Time{}
|
||||||
|
entry.DisabledReason = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAvailable returns true if the provider is not in cooldown or disabled.
|
||||||
|
func (ct *CooldownTracker) IsAvailable(provider string) bool {
|
||||||
|
ct.mu.RLock()
|
||||||
|
defer ct.mu.RUnlock()
|
||||||
|
|
||||||
|
entry := ct.entries[provider]
|
||||||
|
if entry == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
now := ct.nowFunc()
|
||||||
|
|
||||||
|
// Billing disable takes precedence (longer cooldown).
|
||||||
|
if !entry.DisabledUntil.IsZero() && now.Before(entry.DisabledUntil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard cooldown.
|
||||||
|
if !entry.CooldownEnd.IsZero() && now.Before(entry.CooldownEnd) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CooldownRemaining returns how long until the provider becomes available.
|
||||||
|
// Returns 0 if already available.
|
||||||
|
func (ct *CooldownTracker) CooldownRemaining(provider string) time.Duration {
|
||||||
|
ct.mu.RLock()
|
||||||
|
defer ct.mu.RUnlock()
|
||||||
|
|
||||||
|
entry := ct.entries[provider]
|
||||||
|
if entry == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
now := ct.nowFunc()
|
||||||
|
var remaining time.Duration
|
||||||
|
|
||||||
|
if !entry.DisabledUntil.IsZero() && now.Before(entry.DisabledUntil) {
|
||||||
|
d := entry.DisabledUntil.Sub(now)
|
||||||
|
if d > remaining {
|
||||||
|
remaining = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !entry.CooldownEnd.IsZero() && now.Before(entry.CooldownEnd) {
|
||||||
|
d := entry.CooldownEnd.Sub(now)
|
||||||
|
if d > remaining {
|
||||||
|
remaining = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorCount returns the current error count for a provider.
|
||||||
|
func (ct *CooldownTracker) ErrorCount(provider string) int {
|
||||||
|
ct.mu.RLock()
|
||||||
|
defer ct.mu.RUnlock()
|
||||||
|
|
||||||
|
entry := ct.entries[provider]
|
||||||
|
if entry == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return entry.ErrorCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailureCount returns the failure count for a specific reason.
|
||||||
|
func (ct *CooldownTracker) FailureCount(provider string, reason FailoverReason) int {
|
||||||
|
ct.mu.RLock()
|
||||||
|
defer ct.mu.RUnlock()
|
||||||
|
|
||||||
|
entry := ct.entries[provider]
|
||||||
|
if entry == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return entry.FailureCounts[reason]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ct *CooldownTracker) getOrCreate(provider string) *cooldownEntry {
|
||||||
|
entry := ct.entries[provider]
|
||||||
|
if entry == nil {
|
||||||
|
entry = &cooldownEntry{
|
||||||
|
FailureCounts: make(map[FailoverReason]int),
|
||||||
|
}
|
||||||
|
ct.entries[provider] = entry
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateStandardCooldown computes standard exponential backoff.
|
||||||
|
// Formula from OpenClaw: min(1h, 1min * 5^min(n-1, 3))
|
||||||
|
//
|
||||||
|
// 1 error → 1 min
|
||||||
|
// 2 errors → 5 min
|
||||||
|
// 3 errors → 25 min
|
||||||
|
// 4+ errors → 1 hour (cap)
|
||||||
|
func calculateStandardCooldown(errorCount int) time.Duration {
|
||||||
|
n := max(1, errorCount)
|
||||||
|
exp := min(n-1, 3)
|
||||||
|
ms := 60_000 * int(math.Pow(5, float64(exp)))
|
||||||
|
ms = min(3_600_000, ms) // cap at 1 hour
|
||||||
|
return time.Duration(ms) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateBillingCooldown computes billing-specific exponential backoff.
|
||||||
|
// Formula from OpenClaw: min(24h, 5h * 2^min(n-1, 10))
|
||||||
|
//
|
||||||
|
// 1 error → 5 hours
|
||||||
|
// 2 errors → 10 hours
|
||||||
|
// 3 errors → 20 hours
|
||||||
|
// 4+ errors → 24 hours (cap)
|
||||||
|
func calculateBillingCooldown(billingErrorCount int) time.Duration {
|
||||||
|
const baseMs = 5 * 60 * 60 * 1000 // 5 hours
|
||||||
|
const maxMs = 24 * 60 * 60 * 1000 // 24 hours
|
||||||
|
|
||||||
|
n := max(1, billingErrorCount)
|
||||||
|
exp := min(n-1, 10)
|
||||||
|
raw := float64(baseMs) * math.Pow(2, float64(exp))
|
||||||
|
ms := int(math.Min(float64(maxMs), raw))
|
||||||
|
return time.Duration(ms) * time.Millisecond
|
||||||
|
}
|
||||||
269
pkg/providers/cooldown_test.go
Normal file
269
pkg/providers/cooldown_test.go
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestTracker(now time.Time) (*CooldownTracker, *time.Time) {
|
||||||
|
current := now
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
ct.nowFunc = func() time.Time { return current }
|
||||||
|
return ct, ¤t
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCooldown_InitiallyAvailable(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
if !ct.IsAvailable("openai") {
|
||||||
|
t.Error("new provider should be available")
|
||||||
|
}
|
||||||
|
if ct.ErrorCount("openai") != 0 {
|
||||||
|
t.Error("new provider should have 0 errors")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCooldown_StandardEscalation(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
ct, current := newTestTracker(now)
|
||||||
|
|
||||||
|
// 1st error → 1 min cooldown
|
||||||
|
ct.MarkFailure("openai", FailoverRateLimit)
|
||||||
|
if ct.IsAvailable("openai") {
|
||||||
|
t.Error("should be in cooldown after 1st error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance 61 seconds → available
|
||||||
|
*current = now.Add(61 * time.Second)
|
||||||
|
if !ct.IsAvailable("openai") {
|
||||||
|
t.Error("should be available after 1 min cooldown")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2nd error → 5 min cooldown
|
||||||
|
ct.MarkFailure("openai", FailoverRateLimit)
|
||||||
|
*current = now.Add(61*time.Second + 4*time.Minute)
|
||||||
|
if ct.IsAvailable("openai") {
|
||||||
|
t.Error("should be in cooldown (5 min) after 2nd error")
|
||||||
|
}
|
||||||
|
*current = now.Add(61*time.Second + 6*time.Minute)
|
||||||
|
if !ct.IsAvailable("openai") {
|
||||||
|
t.Error("should be available after 5 min cooldown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCooldown_StandardCap(t *testing.T) {
|
||||||
|
// Verify formula: 1m, 5m, 25m, 1h, 1h, 1h...
|
||||||
|
expected := []time.Duration{
|
||||||
|
1 * time.Minute,
|
||||||
|
5 * time.Minute,
|
||||||
|
25 * time.Minute,
|
||||||
|
1 * time.Hour,
|
||||||
|
1 * time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, want := range expected {
|
||||||
|
got := calculateStandardCooldown(i + 1)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("calculateStandardCooldown(%d) = %v, want %v", i+1, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCooldown_BillingEscalation(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
ct, current := newTestTracker(now)
|
||||||
|
|
||||||
|
// 1st billing error → 5h cooldown
|
||||||
|
ct.MarkFailure("openai", FailoverBilling)
|
||||||
|
if ct.IsAvailable("openai") {
|
||||||
|
t.Error("should be disabled after billing error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance 4h → still disabled
|
||||||
|
*current = now.Add(4 * time.Hour)
|
||||||
|
if ct.IsAvailable("openai") {
|
||||||
|
t.Error("should still be disabled (5h cooldown)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance 5h + 1s → available
|
||||||
|
*current = now.Add(5*time.Hour + 1*time.Second)
|
||||||
|
if !ct.IsAvailable("openai") {
|
||||||
|
t.Error("should be available after 5h billing cooldown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCooldown_BillingCap(t *testing.T) {
|
||||||
|
expected := []time.Duration{
|
||||||
|
5 * time.Hour,
|
||||||
|
10 * time.Hour,
|
||||||
|
20 * time.Hour,
|
||||||
|
24 * time.Hour,
|
||||||
|
24 * time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, want := range expected {
|
||||||
|
got := calculateBillingCooldown(i + 1)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("calculateBillingCooldown(%d) = %v, want %v", i+1, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCooldown_SuccessReset(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
|
||||||
|
ct.MarkFailure("openai", FailoverRateLimit)
|
||||||
|
ct.MarkFailure("openai", FailoverBilling)
|
||||||
|
if ct.ErrorCount("openai") != 2 {
|
||||||
|
t.Errorf("error count = %d, want 2", ct.ErrorCount("openai"))
|
||||||
|
}
|
||||||
|
|
||||||
|
ct.MarkSuccess("openai")
|
||||||
|
if ct.ErrorCount("openai") != 0 {
|
||||||
|
t.Errorf("error count after success = %d, want 0", ct.ErrorCount("openai"))
|
||||||
|
}
|
||||||
|
if !ct.IsAvailable("openai") {
|
||||||
|
t.Error("should be available after success")
|
||||||
|
}
|
||||||
|
if ct.FailureCount("openai", FailoverRateLimit) != 0 {
|
||||||
|
t.Error("failure counts should be reset after success")
|
||||||
|
}
|
||||||
|
if ct.FailureCount("openai", FailoverBilling) != 0 {
|
||||||
|
t.Error("billing failure count should be reset after success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCooldown_FailureWindowReset(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
ct, current := newTestTracker(now)
|
||||||
|
|
||||||
|
// 4 errors → 1h cooldown
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
ct.MarkFailure("openai", FailoverRateLimit)
|
||||||
|
*current = current.Add(2 * time.Second) // small advance between errors
|
||||||
|
}
|
||||||
|
if ct.ErrorCount("openai") != 4 {
|
||||||
|
t.Errorf("error count = %d, want 4", ct.ErrorCount("openai"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance 25 hours (past 24h failure window)
|
||||||
|
*current = now.Add(25 * time.Hour)
|
||||||
|
|
||||||
|
// Next error should reset counters first, then increment to 1
|
||||||
|
ct.MarkFailure("openai", FailoverRateLimit)
|
||||||
|
if ct.ErrorCount("openai") != 1 {
|
||||||
|
t.Errorf("error count after window reset = %d, want 1 (reset + 1)", ct.ErrorCount("openai"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCooldown_PerReasonTracking(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
|
||||||
|
ct.MarkFailure("openai", FailoverRateLimit)
|
||||||
|
ct.MarkFailure("openai", FailoverRateLimit)
|
||||||
|
ct.MarkFailure("openai", FailoverBilling)
|
||||||
|
ct.MarkFailure("openai", FailoverAuth)
|
||||||
|
|
||||||
|
if ct.FailureCount("openai", FailoverRateLimit) != 2 {
|
||||||
|
t.Errorf("rate_limit count = %d, want 2", ct.FailureCount("openai", FailoverRateLimit))
|
||||||
|
}
|
||||||
|
if ct.FailureCount("openai", FailoverBilling) != 1 {
|
||||||
|
t.Errorf("billing count = %d, want 1", ct.FailureCount("openai", FailoverBilling))
|
||||||
|
}
|
||||||
|
if ct.FailureCount("openai", FailoverAuth) != 1 {
|
||||||
|
t.Errorf("auth count = %d, want 1", ct.FailureCount("openai", FailoverAuth))
|
||||||
|
}
|
||||||
|
if ct.ErrorCount("openai") != 4 {
|
||||||
|
t.Errorf("total error count = %d, want 4", ct.ErrorCount("openai"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCooldown_BillingTakesPrecedence(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
ct, current := newTestTracker(now)
|
||||||
|
|
||||||
|
// Standard cooldown (1 min) + billing disable (5h)
|
||||||
|
ct.MarkFailure("openai", FailoverRateLimit) // 1 min cooldown
|
||||||
|
ct.MarkFailure("openai", FailoverBilling) // 5h disable
|
||||||
|
|
||||||
|
// After 2 min: standard cooldown expired but billing still active
|
||||||
|
*current = now.Add(2 * time.Minute)
|
||||||
|
if ct.IsAvailable("openai") {
|
||||||
|
t.Error("billing disable should take precedence over standard cooldown")
|
||||||
|
}
|
||||||
|
|
||||||
|
// After 5h + 1s: both expired
|
||||||
|
*current = now.Add(5*time.Hour + 1*time.Second)
|
||||||
|
if !ct.IsAvailable("openai") {
|
||||||
|
t.Error("should be available after all cooldowns expire")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCooldown_CooldownRemaining(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
ct, current := newTestTracker(now)
|
||||||
|
|
||||||
|
// No failures → 0 remaining
|
||||||
|
if ct.CooldownRemaining("openai") != 0 {
|
||||||
|
t.Error("expected 0 remaining for new provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
ct.MarkFailure("openai", FailoverRateLimit)
|
||||||
|
|
||||||
|
*current = now.Add(30 * time.Second)
|
||||||
|
remaining := ct.CooldownRemaining("openai")
|
||||||
|
if remaining <= 0 || remaining > 1*time.Minute {
|
||||||
|
t.Errorf("remaining = %v, expected ~30s", remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCooldown_SuccessOnUnknownProvider(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
// Should not panic
|
||||||
|
ct.MarkSuccess("nonexistent")
|
||||||
|
if !ct.IsAvailable("nonexistent") {
|
||||||
|
t.Error("nonexistent provider should be available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCooldown_ConcurrentAccess(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
wg.Add(3)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
ct.MarkFailure("openai", FailoverRateLimit)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
ct.IsAvailable("openai")
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
ct.MarkSuccess("openai")
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
// If we got here without panic, concurrent access is safe
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCooldown_MultipleProviders(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
|
||||||
|
ct.MarkFailure("openai", FailoverRateLimit)
|
||||||
|
ct.MarkFailure("anthropic", FailoverBilling)
|
||||||
|
|
||||||
|
if ct.IsAvailable("openai") {
|
||||||
|
t.Error("openai should be in cooldown")
|
||||||
|
}
|
||||||
|
if ct.IsAvailable("anthropic") {
|
||||||
|
t.Error("anthropic should be in cooldown")
|
||||||
|
}
|
||||||
|
// groq was never touched
|
||||||
|
if !ct.IsAvailable("groq") {
|
||||||
|
t.Error("groq should be available")
|
||||||
|
}
|
||||||
|
}
|
||||||
253
pkg/providers/error_classifier.go
Normal file
253
pkg/providers/error_classifier.go
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errorPattern defines a single pattern (string or regex) for error classification.
|
||||||
|
type errorPattern struct {
|
||||||
|
substring string
|
||||||
|
regex *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
func substr(s string) errorPattern { return errorPattern{substring: s} }
|
||||||
|
func rxp(r string) errorPattern { return errorPattern{regex: regexp.MustCompile("(?i)" + r)} }
|
||||||
|
|
||||||
|
// Error patterns organized by FailoverReason, matching OpenClaw production (~40 patterns).
|
||||||
|
var (
|
||||||
|
rateLimitPatterns = []errorPattern{
|
||||||
|
rxp(`rate[_ ]limit`),
|
||||||
|
substr("too many requests"),
|
||||||
|
substr("429"),
|
||||||
|
substr("exceeded your current quota"),
|
||||||
|
rxp(`exceeded.*quota`),
|
||||||
|
rxp(`resource has been exhausted`),
|
||||||
|
rxp(`resource.*exhausted`),
|
||||||
|
substr("resource_exhausted"),
|
||||||
|
substr("quota exceeded"),
|
||||||
|
substr("usage limit"),
|
||||||
|
}
|
||||||
|
|
||||||
|
overloadedPatterns = []errorPattern{
|
||||||
|
rxp(`overloaded_error`),
|
||||||
|
rxp(`"type"\s*:\s*"overloaded_error"`),
|
||||||
|
substr("overloaded"),
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutPatterns = []errorPattern{
|
||||||
|
substr("timeout"),
|
||||||
|
substr("timed out"),
|
||||||
|
substr("deadline exceeded"),
|
||||||
|
substr("context deadline exceeded"),
|
||||||
|
}
|
||||||
|
|
||||||
|
billingPatterns = []errorPattern{
|
||||||
|
rxp(`\b402\b`),
|
||||||
|
substr("payment required"),
|
||||||
|
substr("insufficient credits"),
|
||||||
|
substr("credit balance"),
|
||||||
|
substr("plans & billing"),
|
||||||
|
substr("insufficient balance"),
|
||||||
|
}
|
||||||
|
|
||||||
|
authPatterns = []errorPattern{
|
||||||
|
rxp(`invalid[_ ]?api[_ ]?key`),
|
||||||
|
substr("incorrect api key"),
|
||||||
|
substr("invalid token"),
|
||||||
|
substr("authentication"),
|
||||||
|
substr("re-authenticate"),
|
||||||
|
substr("oauth token refresh failed"),
|
||||||
|
substr("unauthorized"),
|
||||||
|
substr("forbidden"),
|
||||||
|
substr("access denied"),
|
||||||
|
substr("expired"),
|
||||||
|
substr("token has expired"),
|
||||||
|
rxp(`\b401\b`),
|
||||||
|
rxp(`\b403\b`),
|
||||||
|
substr("no credentials found"),
|
||||||
|
substr("no api key found"),
|
||||||
|
}
|
||||||
|
|
||||||
|
formatPatterns = []errorPattern{
|
||||||
|
substr("string should match pattern"),
|
||||||
|
substr("tool_use.id"),
|
||||||
|
substr("tool_use_id"),
|
||||||
|
substr("messages.1.content.1.tool_use.id"),
|
||||||
|
substr("invalid request format"),
|
||||||
|
}
|
||||||
|
|
||||||
|
imageDimensionPatterns = []errorPattern{
|
||||||
|
rxp(`image dimensions exceed max`),
|
||||||
|
}
|
||||||
|
|
||||||
|
imageSizePatterns = []errorPattern{
|
||||||
|
rxp(`image exceeds.*mb`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transient HTTP status codes that map to timeout (server-side failures).
|
||||||
|
transientStatusCodes = map[int]bool{
|
||||||
|
500: true, 502: true, 503: true,
|
||||||
|
521: true, 522: true, 523: true, 524: true,
|
||||||
|
529: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClassifyError classifies an error into a FailoverError with reason.
|
||||||
|
// Returns nil if the error is not classifiable (unknown errors should not trigger fallback).
|
||||||
|
func ClassifyError(err error, provider, model string) *FailoverError {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context cancellation: user abort, never fallback.
|
||||||
|
if err == context.Canceled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context deadline exceeded: treat as timeout, always fallback.
|
||||||
|
if err == context.DeadlineExceeded {
|
||||||
|
return &FailoverError{
|
||||||
|
Reason: FailoverTimeout,
|
||||||
|
Provider: provider,
|
||||||
|
Model: model,
|
||||||
|
Wrapped: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
|
||||||
|
// Image dimension/size errors: non-retriable, non-fallback.
|
||||||
|
if IsImageDimensionError(msg) || IsImageSizeError(msg) {
|
||||||
|
return &FailoverError{
|
||||||
|
Reason: FailoverFormat,
|
||||||
|
Provider: provider,
|
||||||
|
Model: model,
|
||||||
|
Wrapped: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try HTTP status code extraction first.
|
||||||
|
if status := extractHTTPStatus(msg); status > 0 {
|
||||||
|
if reason := classifyByStatus(status); reason != "" {
|
||||||
|
return &FailoverError{
|
||||||
|
Reason: reason,
|
||||||
|
Provider: provider,
|
||||||
|
Model: model,
|
||||||
|
Status: status,
|
||||||
|
Wrapped: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message pattern matching (priority order from OpenClaw).
|
||||||
|
if reason := classifyByMessage(msg); reason != "" {
|
||||||
|
return &FailoverError{
|
||||||
|
Reason: reason,
|
||||||
|
Provider: provider,
|
||||||
|
Model: model,
|
||||||
|
Wrapped: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyByStatus maps HTTP status codes to FailoverReason.
|
||||||
|
func classifyByStatus(status int) FailoverReason {
|
||||||
|
switch {
|
||||||
|
case status == 401 || status == 403:
|
||||||
|
return FailoverAuth
|
||||||
|
case status == 402:
|
||||||
|
return FailoverBilling
|
||||||
|
case status == 408:
|
||||||
|
return FailoverTimeout
|
||||||
|
case status == 429:
|
||||||
|
return FailoverRateLimit
|
||||||
|
case status == 400:
|
||||||
|
return FailoverFormat
|
||||||
|
case transientStatusCodes[status]:
|
||||||
|
return FailoverTimeout
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyByMessage matches error messages against patterns.
|
||||||
|
// Priority order matters (from OpenClaw classifyFailoverReason).
|
||||||
|
func classifyByMessage(msg string) FailoverReason {
|
||||||
|
if matchesAny(msg, rateLimitPatterns) {
|
||||||
|
return FailoverRateLimit
|
||||||
|
}
|
||||||
|
if matchesAny(msg, overloadedPatterns) {
|
||||||
|
return FailoverRateLimit // Overloaded treated as rate_limit
|
||||||
|
}
|
||||||
|
if matchesAny(msg, billingPatterns) {
|
||||||
|
return FailoverBilling
|
||||||
|
}
|
||||||
|
if matchesAny(msg, timeoutPatterns) {
|
||||||
|
return FailoverTimeout
|
||||||
|
}
|
||||||
|
if matchesAny(msg, authPatterns) {
|
||||||
|
return FailoverAuth
|
||||||
|
}
|
||||||
|
if matchesAny(msg, formatPatterns) {
|
||||||
|
return FailoverFormat
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractHTTPStatus extracts an HTTP status code from an error message.
|
||||||
|
// Looks for patterns like "status: 429", "status 429", "HTTP 429", or standalone "429".
|
||||||
|
func extractHTTPStatus(msg string) int {
|
||||||
|
// Common patterns in Go HTTP error messages
|
||||||
|
patterns := []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`status[:\s]+(\d{3})`),
|
||||||
|
regexp.MustCompile(`HTTP[/\s]+\d*\.?\d*\s+(\d{3})`),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range patterns {
|
||||||
|
if m := p.FindStringSubmatch(msg); len(m) > 1 {
|
||||||
|
return parseDigits(m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsImageDimensionError returns true if the message indicates an image dimension error.
|
||||||
|
func IsImageDimensionError(msg string) bool {
|
||||||
|
return matchesAny(msg, imageDimensionPatterns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsImageSizeError returns true if the message indicates an image file size error.
|
||||||
|
func IsImageSizeError(msg string) bool {
|
||||||
|
return matchesAny(msg, imageSizePatterns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesAny checks if msg matches any of the patterns.
|
||||||
|
func matchesAny(msg string, patterns []errorPattern) bool {
|
||||||
|
for _, p := range patterns {
|
||||||
|
if p.regex != nil {
|
||||||
|
if p.regex.MatchString(msg) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else if p.substring != "" {
|
||||||
|
if strings.Contains(msg, p.substring) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDigits converts a string of digits to an int.
|
||||||
|
func parseDigits(s string) int {
|
||||||
|
n := 0
|
||||||
|
for _, c := range s {
|
||||||
|
if c >= '0' && c <= '9' {
|
||||||
|
n = n*10 + int(c-'0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
337
pkg/providers/error_classifier_test.go
Normal file
337
pkg/providers/error_classifier_test.go
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClassifyError_Nil(t *testing.T) {
|
||||||
|
result := ClassifyError(nil, "openai", "gpt-4")
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("expected nil for nil error, got %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyError_ContextCanceled(t *testing.T) {
|
||||||
|
result := ClassifyError(context.Canceled, "openai", "gpt-4")
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("expected nil for context.Canceled (user abort), got %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyError_ContextDeadlineExceeded(t *testing.T) {
|
||||||
|
result := ClassifyError(context.DeadlineExceeded, "openai", "gpt-4")
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected non-nil for deadline exceeded")
|
||||||
|
}
|
||||||
|
if result.Reason != FailoverTimeout {
|
||||||
|
t.Errorf("reason = %q, want timeout", result.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyError_StatusCodes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
status int
|
||||||
|
reason FailoverReason
|
||||||
|
}{
|
||||||
|
{401, FailoverAuth},
|
||||||
|
{403, FailoverAuth},
|
||||||
|
{402, FailoverBilling},
|
||||||
|
{408, FailoverTimeout},
|
||||||
|
{429, FailoverRateLimit},
|
||||||
|
{400, FailoverFormat},
|
||||||
|
{500, FailoverTimeout},
|
||||||
|
{502, FailoverTimeout},
|
||||||
|
{503, FailoverTimeout},
|
||||||
|
{521, FailoverTimeout},
|
||||||
|
{522, FailoverTimeout},
|
||||||
|
{523, FailoverTimeout},
|
||||||
|
{524, FailoverTimeout},
|
||||||
|
{529, FailoverTimeout},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
err := fmt.Errorf("API error: status: %d something went wrong", tt.status)
|
||||||
|
result := ClassifyError(err, "test", "model")
|
||||||
|
if result == nil {
|
||||||
|
t.Errorf("status %d: expected non-nil", tt.status)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result.Reason != tt.reason {
|
||||||
|
t.Errorf("status %d: reason = %q, want %q", tt.status, result.Reason, tt.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyError_RateLimitPatterns(t *testing.T) {
|
||||||
|
patterns := []string{
|
||||||
|
"rate limit exceeded",
|
||||||
|
"rate_limit reached",
|
||||||
|
"too many requests",
|
||||||
|
"exceeded your current quota",
|
||||||
|
"resource has been exhausted",
|
||||||
|
"resource_exhausted",
|
||||||
|
"quota exceeded",
|
||||||
|
"usage limit reached",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range patterns {
|
||||||
|
err := errors.New(msg)
|
||||||
|
result := ClassifyError(err, "openai", "gpt-4")
|
||||||
|
if result == nil {
|
||||||
|
t.Errorf("pattern %q: expected non-nil", msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result.Reason != FailoverRateLimit {
|
||||||
|
t.Errorf("pattern %q: reason = %q, want rate_limit", msg, result.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyError_OverloadedPatterns(t *testing.T) {
|
||||||
|
patterns := []string{
|
||||||
|
"overloaded_error",
|
||||||
|
`{"type": "overloaded_error"}`,
|
||||||
|
"server is overloaded",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range patterns {
|
||||||
|
err := errors.New(msg)
|
||||||
|
result := ClassifyError(err, "anthropic", "claude")
|
||||||
|
if result == nil {
|
||||||
|
t.Errorf("pattern %q: expected non-nil", msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Overloaded is treated as rate_limit
|
||||||
|
if result.Reason != FailoverRateLimit {
|
||||||
|
t.Errorf("pattern %q: reason = %q, want rate_limit", msg, result.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyError_BillingPatterns(t *testing.T) {
|
||||||
|
patterns := []string{
|
||||||
|
"payment required",
|
||||||
|
"insufficient credits",
|
||||||
|
"credit balance too low",
|
||||||
|
"plans & billing page",
|
||||||
|
"insufficient balance",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range patterns {
|
||||||
|
err := errors.New(msg)
|
||||||
|
result := ClassifyError(err, "openai", "gpt-4")
|
||||||
|
if result == nil {
|
||||||
|
t.Errorf("pattern %q: expected non-nil", msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result.Reason != FailoverBilling {
|
||||||
|
t.Errorf("pattern %q: reason = %q, want billing", msg, result.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyError_TimeoutPatterns(t *testing.T) {
|
||||||
|
patterns := []string{
|
||||||
|
"request timeout",
|
||||||
|
"connection timed out",
|
||||||
|
"deadline exceeded",
|
||||||
|
"context deadline exceeded",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range patterns {
|
||||||
|
err := errors.New(msg)
|
||||||
|
result := ClassifyError(err, "openai", "gpt-4")
|
||||||
|
if result == nil {
|
||||||
|
t.Errorf("pattern %q: expected non-nil", msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result.Reason != FailoverTimeout {
|
||||||
|
t.Errorf("pattern %q: reason = %q, want timeout", msg, result.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyError_AuthPatterns(t *testing.T) {
|
||||||
|
patterns := []string{
|
||||||
|
"invalid api key",
|
||||||
|
"invalid_api_key",
|
||||||
|
"incorrect api key",
|
||||||
|
"invalid token",
|
||||||
|
"authentication failed",
|
||||||
|
"re-authenticate",
|
||||||
|
"oauth token refresh failed",
|
||||||
|
"unauthorized access",
|
||||||
|
"forbidden",
|
||||||
|
"access denied",
|
||||||
|
"expired",
|
||||||
|
"token has expired",
|
||||||
|
"no credentials found",
|
||||||
|
"no api key found",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range patterns {
|
||||||
|
err := errors.New(msg)
|
||||||
|
result := ClassifyError(err, "openai", "gpt-4")
|
||||||
|
if result == nil {
|
||||||
|
t.Errorf("pattern %q: expected non-nil", msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result.Reason != FailoverAuth {
|
||||||
|
t.Errorf("pattern %q: reason = %q, want auth", msg, result.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyError_FormatPatterns(t *testing.T) {
|
||||||
|
patterns := []string{
|
||||||
|
"string should match pattern",
|
||||||
|
"tool_use.id is required",
|
||||||
|
"invalid tool_use_id",
|
||||||
|
"messages.1.content.1.tool_use.id must be valid",
|
||||||
|
"invalid request format",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range patterns {
|
||||||
|
err := errors.New(msg)
|
||||||
|
result := ClassifyError(err, "anthropic", "claude")
|
||||||
|
if result == nil {
|
||||||
|
t.Errorf("pattern %q: expected non-nil", msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result.Reason != FailoverFormat {
|
||||||
|
t.Errorf("pattern %q: reason = %q, want format", msg, result.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyError_ImageDimensionError(t *testing.T) {
|
||||||
|
err := errors.New("image dimensions exceed max allowed 2048x2048")
|
||||||
|
result := ClassifyError(err, "openai", "gpt-4o")
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected non-nil for image dimension error")
|
||||||
|
}
|
||||||
|
if result.Reason != FailoverFormat {
|
||||||
|
t.Errorf("reason = %q, want format", result.Reason)
|
||||||
|
}
|
||||||
|
if result.IsRetriable() {
|
||||||
|
t.Error("image dimension error should not be retriable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyError_ImageSizeError(t *testing.T) {
|
||||||
|
err := errors.New("image exceeds 20 mb limit")
|
||||||
|
result := ClassifyError(err, "openai", "gpt-4o")
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected non-nil for image size error")
|
||||||
|
}
|
||||||
|
if result.Reason != FailoverFormat {
|
||||||
|
t.Errorf("reason = %q, want format", result.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyError_UnknownError(t *testing.T) {
|
||||||
|
err := errors.New("some completely random error")
|
||||||
|
result := ClassifyError(err, "openai", "gpt-4")
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("expected nil for unknown error, got %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyError_ProviderModelPropagation(t *testing.T) {
|
||||||
|
err := errors.New("rate limit exceeded")
|
||||||
|
result := ClassifyError(err, "my-provider", "my-model")
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected non-nil")
|
||||||
|
}
|
||||||
|
if result.Provider != "my-provider" {
|
||||||
|
t.Errorf("provider = %q, want my-provider", result.Provider)
|
||||||
|
}
|
||||||
|
if result.Model != "my-model" {
|
||||||
|
t.Errorf("model = %q, want my-model", result.Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailoverError_IsRetriable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
reason FailoverReason
|
||||||
|
retriable bool
|
||||||
|
}{
|
||||||
|
{FailoverAuth, true},
|
||||||
|
{FailoverRateLimit, true},
|
||||||
|
{FailoverBilling, true},
|
||||||
|
{FailoverTimeout, true},
|
||||||
|
{FailoverOverloaded, true},
|
||||||
|
{FailoverFormat, false},
|
||||||
|
{FailoverUnknown, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
fe := &FailoverError{Reason: tt.reason}
|
||||||
|
if fe.IsRetriable() != tt.retriable {
|
||||||
|
t.Errorf("IsRetriable(%q) = %v, want %v", tt.reason, fe.IsRetriable(), tt.retriable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailoverError_ErrorString(t *testing.T) {
|
||||||
|
fe := &FailoverError{
|
||||||
|
Reason: FailoverRateLimit,
|
||||||
|
Provider: "openai",
|
||||||
|
Model: "gpt-4",
|
||||||
|
Status: 429,
|
||||||
|
Wrapped: errors.New("too many requests"),
|
||||||
|
}
|
||||||
|
s := fe.Error()
|
||||||
|
if s == "" {
|
||||||
|
t.Error("expected non-empty error string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailoverError_Unwrap(t *testing.T) {
|
||||||
|
inner := errors.New("inner error")
|
||||||
|
fe := &FailoverError{Reason: FailoverTimeout, Wrapped: inner}
|
||||||
|
if fe.Unwrap() != inner {
|
||||||
|
t.Error("Unwrap should return wrapped error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractHTTPStatus(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
msg string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"status: 429 rate limited", 429},
|
||||||
|
{"status 401 unauthorized", 401},
|
||||||
|
{"HTTP/1.1 502 Bad Gateway", 502},
|
||||||
|
{"no status code here", 0},
|
||||||
|
{"random number 12345", 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := extractHTTPStatus(tt.msg)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("extractHTTPStatus(%q) = %d, want %d", tt.msg, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsImageDimensionError(t *testing.T) {
|
||||||
|
if !IsImageDimensionError("image dimensions exceed max 4096x4096") {
|
||||||
|
t.Error("should match image dimensions exceed max")
|
||||||
|
}
|
||||||
|
if IsImageDimensionError("normal error message") {
|
||||||
|
t.Error("should not match normal error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsImageSizeError(t *testing.T) {
|
||||||
|
if !IsImageSizeError("image exceeds 20 mb") {
|
||||||
|
t.Error("should match image exceeds mb")
|
||||||
|
}
|
||||||
|
if IsImageSizeError("normal error message") {
|
||||||
|
t.Error("should not match normal error")
|
||||||
|
}
|
||||||
|
}
|
||||||
360
pkg/providers/factory.go
Normal file
360
pkg/providers/factory.go
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultAnthropicAPIBase = "https://api.anthropic.com/v1"
|
||||||
|
|
||||||
|
var getCredential = auth.GetCredential
|
||||||
|
|
||||||
|
type providerType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
providerTypeHTTPCompat providerType = iota
|
||||||
|
providerTypeClaudeAuth
|
||||||
|
providerTypeCodexAuth
|
||||||
|
providerTypeCodexCLIToken
|
||||||
|
providerTypeClaudeCLI
|
||||||
|
providerTypeCodexCLI
|
||||||
|
providerTypeGitHubCopilot
|
||||||
|
)
|
||||||
|
|
||||||
|
type providerSelection struct {
|
||||||
|
providerType providerType
|
||||||
|
apiKey string
|
||||||
|
apiBase string
|
||||||
|
proxy string
|
||||||
|
model string
|
||||||
|
workspace string
|
||||||
|
connectMode string
|
||||||
|
enableWebSearch bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func createClaudeAuthProvider(apiBase string) (LLMProvider, error) {
|
||||||
|
if apiBase == "" {
|
||||||
|
apiBase = defaultAnthropicAPIBase
|
||||||
|
}
|
||||||
|
cred, err := getCredential("anthropic")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("loading auth credentials: %w", err)
|
||||||
|
}
|
||||||
|
if cred == nil {
|
||||||
|
return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic")
|
||||||
|
}
|
||||||
|
return NewClaudeProviderWithTokenSourceAndBaseURL(cred.AccessToken, createClaudeTokenSource(), apiBase), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createCodexAuthProvider(enableWebSearch bool) (LLMProvider, error) {
|
||||||
|
cred, err := getCredential("openai")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("loading auth credentials: %w", err)
|
||||||
|
}
|
||||||
|
if cred == nil {
|
||||||
|
return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai")
|
||||||
|
}
|
||||||
|
p := NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource())
|
||||||
|
p.enableWebSearch = enableWebSearch
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
|
model := cfg.Agents.Defaults.Model
|
||||||
|
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
|
||||||
|
lowerModel := strings.ToLower(model)
|
||||||
|
|
||||||
|
sel := providerSelection{
|
||||||
|
providerType: providerTypeHTTPCompat,
|
||||||
|
model: model,
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, prefer explicit provider configuration.
|
||||||
|
if providerName != "" {
|
||||||
|
switch providerName {
|
||||||
|
case "groq":
|
||||||
|
if cfg.Providers.Groq.APIKey != "" {
|
||||||
|
sel.apiKey = cfg.Providers.Groq.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Groq.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Groq.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://api.groq.com/openai/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "openai", "gpt":
|
||||||
|
if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" {
|
||||||
|
sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch
|
||||||
|
if cfg.Providers.OpenAI.AuthMethod == "codex-cli" {
|
||||||
|
sel.providerType = providerTypeCodexCLIToken
|
||||||
|
return sel, nil
|
||||||
|
}
|
||||||
|
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
|
||||||
|
sel.providerType = providerTypeCodexAuth
|
||||||
|
return sel, nil
|
||||||
|
}
|
||||||
|
sel.apiKey = cfg.Providers.OpenAI.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.OpenAI.APIBase
|
||||||
|
sel.proxy = cfg.Providers.OpenAI.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "anthropic", "claude":
|
||||||
|
if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" {
|
||||||
|
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
|
||||||
|
sel.apiBase = cfg.Providers.Anthropic.APIBase
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = defaultAnthropicAPIBase
|
||||||
|
}
|
||||||
|
sel.providerType = providerTypeClaudeAuth
|
||||||
|
return sel, nil
|
||||||
|
}
|
||||||
|
sel.apiKey = cfg.Providers.Anthropic.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Anthropic.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Anthropic.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = defaultAnthropicAPIBase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "openrouter":
|
||||||
|
if cfg.Providers.OpenRouter.APIKey != "" {
|
||||||
|
sel.apiKey = cfg.Providers.OpenRouter.APIKey
|
||||||
|
sel.proxy = cfg.Providers.OpenRouter.Proxy
|
||||||
|
if cfg.Providers.OpenRouter.APIBase != "" {
|
||||||
|
sel.apiBase = cfg.Providers.OpenRouter.APIBase
|
||||||
|
} else {
|
||||||
|
sel.apiBase = "https://openrouter.ai/api/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "zhipu", "glm":
|
||||||
|
if cfg.Providers.Zhipu.APIKey != "" {
|
||||||
|
sel.apiKey = cfg.Providers.Zhipu.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Zhipu.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Zhipu.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://open.bigmodel.cn/api/paas/v4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "gemini", "google":
|
||||||
|
if cfg.Providers.Gemini.APIKey != "" {
|
||||||
|
sel.apiKey = cfg.Providers.Gemini.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Gemini.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Gemini.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "vllm":
|
||||||
|
if cfg.Providers.VLLM.APIBase != "" {
|
||||||
|
sel.apiKey = cfg.Providers.VLLM.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.VLLM.APIBase
|
||||||
|
sel.proxy = cfg.Providers.VLLM.Proxy
|
||||||
|
}
|
||||||
|
case "shengsuanyun":
|
||||||
|
if cfg.Providers.ShengSuanYun.APIKey != "" {
|
||||||
|
sel.apiKey = cfg.Providers.ShengSuanYun.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.ShengSuanYun.APIBase
|
||||||
|
sel.proxy = cfg.Providers.ShengSuanYun.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://router.shengsuanyun.com/api/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "nvidia":
|
||||||
|
if cfg.Providers.Nvidia.APIKey != "" {
|
||||||
|
sel.apiKey = cfg.Providers.Nvidia.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Nvidia.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Nvidia.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://integrate.api.nvidia.com/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "claude-cli", "claude-code", "claudecode":
|
||||||
|
workspace := cfg.WorkspacePath()
|
||||||
|
if workspace == "" {
|
||||||
|
workspace = "."
|
||||||
|
}
|
||||||
|
sel.providerType = providerTypeClaudeCLI
|
||||||
|
sel.workspace = workspace
|
||||||
|
return sel, nil
|
||||||
|
case "codex-cli", "codex-code":
|
||||||
|
workspace := cfg.WorkspacePath()
|
||||||
|
if workspace == "" {
|
||||||
|
workspace = "."
|
||||||
|
}
|
||||||
|
sel.providerType = providerTypeCodexCLI
|
||||||
|
sel.workspace = workspace
|
||||||
|
return sel, nil
|
||||||
|
case "deepseek":
|
||||||
|
if cfg.Providers.DeepSeek.APIKey != "" {
|
||||||
|
sel.apiKey = cfg.Providers.DeepSeek.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.DeepSeek.APIBase
|
||||||
|
sel.proxy = cfg.Providers.DeepSeek.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://api.deepseek.com/v1"
|
||||||
|
}
|
||||||
|
if model != "deepseek-chat" && model != "deepseek-reasoner" {
|
||||||
|
sel.model = "deepseek-chat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "github_copilot", "copilot":
|
||||||
|
sel.providerType = providerTypeGitHubCopilot
|
||||||
|
if cfg.Providers.GitHubCopilot.APIBase != "" {
|
||||||
|
sel.apiBase = cfg.Providers.GitHubCopilot.APIBase
|
||||||
|
} else {
|
||||||
|
sel.apiBase = "localhost:4321"
|
||||||
|
}
|
||||||
|
sel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode
|
||||||
|
return sel, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: infer provider from model and configured keys.
|
||||||
|
if sel.apiKey == "" && sel.apiBase == "" {
|
||||||
|
switch {
|
||||||
|
case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "":
|
||||||
|
sel.apiKey = cfg.Providers.Moonshot.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Moonshot.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Moonshot.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://api.moonshot.cn/v1"
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(model, "openrouter/") ||
|
||||||
|
strings.HasPrefix(model, "anthropic/") ||
|
||||||
|
strings.HasPrefix(model, "openai/") ||
|
||||||
|
strings.HasPrefix(model, "meta-llama/") ||
|
||||||
|
strings.HasPrefix(model, "deepseek/") ||
|
||||||
|
strings.HasPrefix(model, "google/"):
|
||||||
|
sel.apiKey = cfg.Providers.OpenRouter.APIKey
|
||||||
|
sel.proxy = cfg.Providers.OpenRouter.Proxy
|
||||||
|
if cfg.Providers.OpenRouter.APIBase != "" {
|
||||||
|
sel.apiBase = cfg.Providers.OpenRouter.APIBase
|
||||||
|
} else {
|
||||||
|
sel.apiBase = "https://openrouter.ai/api/v1"
|
||||||
|
}
|
||||||
|
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) &&
|
||||||
|
(cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""):
|
||||||
|
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
|
||||||
|
sel.apiBase = cfg.Providers.Anthropic.APIBase
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = defaultAnthropicAPIBase
|
||||||
|
}
|
||||||
|
sel.providerType = providerTypeClaudeAuth
|
||||||
|
return sel, nil
|
||||||
|
}
|
||||||
|
sel.apiKey = cfg.Providers.Anthropic.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Anthropic.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Anthropic.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = defaultAnthropicAPIBase
|
||||||
|
}
|
||||||
|
case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) &&
|
||||||
|
(cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""):
|
||||||
|
sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch
|
||||||
|
if cfg.Providers.OpenAI.AuthMethod == "codex-cli" {
|
||||||
|
sel.providerType = providerTypeCodexCLIToken
|
||||||
|
return sel, nil
|
||||||
|
}
|
||||||
|
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
|
||||||
|
sel.providerType = providerTypeCodexAuth
|
||||||
|
return sel, nil
|
||||||
|
}
|
||||||
|
sel.apiKey = cfg.Providers.OpenAI.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.OpenAI.APIBase
|
||||||
|
sel.proxy = cfg.Providers.OpenAI.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "":
|
||||||
|
sel.apiKey = cfg.Providers.Gemini.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Gemini.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Gemini.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
}
|
||||||
|
case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "":
|
||||||
|
sel.apiKey = cfg.Providers.Zhipu.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Zhipu.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Zhipu.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://open.bigmodel.cn/api/paas/v4"
|
||||||
|
}
|
||||||
|
case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "":
|
||||||
|
sel.apiKey = cfg.Providers.Groq.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Groq.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Groq.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://api.groq.com/openai/v1"
|
||||||
|
}
|
||||||
|
case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "":
|
||||||
|
sel.apiKey = cfg.Providers.Nvidia.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Nvidia.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Nvidia.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://integrate.api.nvidia.com/v1"
|
||||||
|
}
|
||||||
|
case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "":
|
||||||
|
sel.apiKey = cfg.Providers.Ollama.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Ollama.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Ollama.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "http://localhost:11434/v1"
|
||||||
|
}
|
||||||
|
case cfg.Providers.VLLM.APIBase != "":
|
||||||
|
sel.apiKey = cfg.Providers.VLLM.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.VLLM.APIBase
|
||||||
|
sel.proxy = cfg.Providers.VLLM.Proxy
|
||||||
|
default:
|
||||||
|
if cfg.Providers.OpenRouter.APIKey != "" {
|
||||||
|
sel.apiKey = cfg.Providers.OpenRouter.APIKey
|
||||||
|
sel.proxy = cfg.Providers.OpenRouter.Proxy
|
||||||
|
if cfg.Providers.OpenRouter.APIBase != "" {
|
||||||
|
sel.apiBase = cfg.Providers.OpenRouter.APIBase
|
||||||
|
} else {
|
||||||
|
sel.apiBase = "https://openrouter.ai/api/v1"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return providerSelection{}, fmt.Errorf("no API key configured for model: %s", model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sel.providerType == providerTypeHTTPCompat {
|
||||||
|
if sel.apiKey == "" && !strings.HasPrefix(model, "bedrock/") {
|
||||||
|
return providerSelection{}, fmt.Errorf("no API key configured for provider (model: %s)", model)
|
||||||
|
}
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
return providerSelection{}, fmt.Errorf("no API base configured for provider (model: %s)", model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateProvider(cfg *config.Config) (LLMProvider, error) {
|
||||||
|
sel, err := resolveProviderSelection(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sel.providerType {
|
||||||
|
case providerTypeClaudeAuth:
|
||||||
|
return createClaudeAuthProvider(sel.apiBase)
|
||||||
|
case providerTypeCodexAuth:
|
||||||
|
return createCodexAuthProvider(sel.enableWebSearch)
|
||||||
|
case providerTypeCodexCLIToken:
|
||||||
|
c := NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource())
|
||||||
|
c.enableWebSearch = sel.enableWebSearch
|
||||||
|
return c, nil
|
||||||
|
case providerTypeClaudeCLI:
|
||||||
|
return NewClaudeCliProvider(sel.workspace), nil
|
||||||
|
case providerTypeCodexCLI:
|
||||||
|
return NewCodexCliProvider(sel.workspace), nil
|
||||||
|
case providerTypeGitHubCopilot:
|
||||||
|
return NewGitHubCopilotProvider(sel.apiBase, sel.connectMode, sel.model)
|
||||||
|
default:
|
||||||
|
return NewHTTPProvider(sel.apiKey, sel.apiBase, sel.proxy), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
299
pkg/providers/factory_test.go
Normal file
299
pkg/providers/factory_test.go
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveProviderSelection(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func(*config.Config)
|
||||||
|
wantType providerType
|
||||||
|
wantAPIBase string
|
||||||
|
wantProxy string
|
||||||
|
wantErrSubstr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "explicit claude-cli provider routes to cli provider type",
|
||||||
|
setup: func(cfg *config.Config) {
|
||||||
|
cfg.Agents.Defaults.Provider = "claude-cli"
|
||||||
|
cfg.Agents.Defaults.Workspace = "/tmp/ws"
|
||||||
|
},
|
||||||
|
wantType: providerTypeClaudeCLI,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit copilot provider routes to github copilot type",
|
||||||
|
setup: func(cfg *config.Config) {
|
||||||
|
cfg.Agents.Defaults.Provider = "copilot"
|
||||||
|
},
|
||||||
|
wantType: providerTypeGitHubCopilot,
|
||||||
|
wantAPIBase: "localhost:4321",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit deepseek provider uses deepseek defaults",
|
||||||
|
setup: func(cfg *config.Config) {
|
||||||
|
cfg.Agents.Defaults.Provider = "deepseek"
|
||||||
|
cfg.Agents.Defaults.Model = "deepseek/deepseek-chat"
|
||||||
|
cfg.Providers.DeepSeek.APIKey = "deepseek-key"
|
||||||
|
cfg.Providers.DeepSeek.Proxy = "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
|
wantType: providerTypeHTTPCompat,
|
||||||
|
wantAPIBase: "https://api.deepseek.com/v1",
|
||||||
|
wantProxy: "http://127.0.0.1:7890",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit shengsuanyun provider uses defaults",
|
||||||
|
setup: func(cfg *config.Config) {
|
||||||
|
cfg.Agents.Defaults.Provider = "shengsuanyun"
|
||||||
|
cfg.Providers.ShengSuanYun.APIKey = "ssy-key"
|
||||||
|
cfg.Providers.ShengSuanYun.Proxy = "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
|
wantType: providerTypeHTTPCompat,
|
||||||
|
wantAPIBase: "https://router.shengsuanyun.com/api/v1",
|
||||||
|
wantProxy: "http://127.0.0.1:7890",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit nvidia provider uses defaults",
|
||||||
|
setup: func(cfg *config.Config) {
|
||||||
|
cfg.Agents.Defaults.Provider = "nvidia"
|
||||||
|
cfg.Providers.Nvidia.APIKey = "nvapi-test"
|
||||||
|
cfg.Providers.Nvidia.Proxy = "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
|
wantType: providerTypeHTTPCompat,
|
||||||
|
wantAPIBase: "https://integrate.api.nvidia.com/v1",
|
||||||
|
wantProxy: "http://127.0.0.1:7890",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "openrouter model uses openrouter defaults",
|
||||||
|
setup: func(cfg *config.Config) {
|
||||||
|
cfg.Agents.Defaults.Model = "openrouter/auto"
|
||||||
|
cfg.Providers.OpenRouter.APIKey = "sk-or-test"
|
||||||
|
},
|
||||||
|
wantType: providerTypeHTTPCompat,
|
||||||
|
wantAPIBase: "https://openrouter.ai/api/v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "anthropic oauth routes to claude auth provider",
|
||||||
|
setup: func(cfg *config.Config) {
|
||||||
|
cfg.Agents.Defaults.Model = "claude-sonnet-4-5-20250929"
|
||||||
|
cfg.Providers.Anthropic.AuthMethod = "oauth"
|
||||||
|
},
|
||||||
|
wantType: providerTypeClaudeAuth,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "openai oauth routes to codex auth provider",
|
||||||
|
setup: func(cfg *config.Config) {
|
||||||
|
cfg.Agents.Defaults.Model = "gpt-4o"
|
||||||
|
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||||
|
},
|
||||||
|
wantType: providerTypeCodexAuth,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "openai codex-cli auth routes to codex cli token provider",
|
||||||
|
setup: func(cfg *config.Config) {
|
||||||
|
cfg.Agents.Defaults.Model = "gpt-4o"
|
||||||
|
cfg.Providers.OpenAI.AuthMethod = "codex-cli"
|
||||||
|
},
|
||||||
|
wantType: providerTypeCodexCLIToken,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit codex-code provider routes to codex cli provider type",
|
||||||
|
setup: func(cfg *config.Config) {
|
||||||
|
cfg.Agents.Defaults.Provider = "codex-code"
|
||||||
|
cfg.Agents.Defaults.Workspace = "/tmp/ws"
|
||||||
|
},
|
||||||
|
wantType: providerTypeCodexCLI,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zhipu model uses zhipu base default",
|
||||||
|
setup: func(cfg *config.Config) {
|
||||||
|
cfg.Agents.Defaults.Model = "glm-4.7"
|
||||||
|
cfg.Providers.Zhipu.APIKey = "zhipu-key"
|
||||||
|
},
|
||||||
|
wantType: providerTypeHTTPCompat,
|
||||||
|
wantAPIBase: "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "groq model uses groq base default",
|
||||||
|
setup: func(cfg *config.Config) {
|
||||||
|
cfg.Agents.Defaults.Model = "groq/llama-3.3-70b"
|
||||||
|
cfg.Providers.Groq.APIKey = "gsk-key"
|
||||||
|
},
|
||||||
|
wantType: providerTypeHTTPCompat,
|
||||||
|
wantAPIBase: "https://api.groq.com/openai/v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ollama model uses ollama base default",
|
||||||
|
setup: func(cfg *config.Config) {
|
||||||
|
cfg.Agents.Defaults.Model = "ollama/qwen2.5:14b"
|
||||||
|
cfg.Providers.Ollama.APIKey = "ollama-key"
|
||||||
|
},
|
||||||
|
wantType: providerTypeHTTPCompat,
|
||||||
|
wantAPIBase: "http://localhost:11434/v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "moonshot model keeps proxy and default base",
|
||||||
|
setup: func(cfg *config.Config) {
|
||||||
|
cfg.Agents.Defaults.Model = "moonshot/kimi-k2.5"
|
||||||
|
cfg.Providers.Moonshot.APIKey = "moonshot-key"
|
||||||
|
cfg.Providers.Moonshot.Proxy = "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
|
wantType: providerTypeHTTPCompat,
|
||||||
|
wantAPIBase: "https://api.moonshot.cn/v1",
|
||||||
|
wantProxy: "http://127.0.0.1:7890",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing keys returns model config error",
|
||||||
|
setup: func(cfg *config.Config) {
|
||||||
|
cfg.Agents.Defaults.Model = "custom-model"
|
||||||
|
},
|
||||||
|
wantErrSubstr: "no API key configured for model",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "openrouter prefix without key returns provider key error",
|
||||||
|
setup: func(cfg *config.Config) {
|
||||||
|
cfg.Agents.Defaults.Model = "openrouter/auto"
|
||||||
|
},
|
||||||
|
wantErrSubstr: "no API key configured for provider",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
tt.setup(cfg)
|
||||||
|
|
||||||
|
got, err := resolveProviderSelection(cfg)
|
||||||
|
if tt.wantErrSubstr != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantErrSubstr) {
|
||||||
|
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErrSubstr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveProviderSelection() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.providerType != tt.wantType {
|
||||||
|
t.Fatalf("providerType = %v, want %v", got.providerType, tt.wantType)
|
||||||
|
}
|
||||||
|
if tt.wantAPIBase != "" && got.apiBase != tt.wantAPIBase {
|
||||||
|
t.Fatalf("apiBase = %q, want %q", got.apiBase, tt.wantAPIBase)
|
||||||
|
}
|
||||||
|
if tt.wantProxy != "" && got.proxy != tt.wantProxy {
|
||||||
|
t.Fatalf("proxy = %q, want %q", got.proxy, tt.wantProxy)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Model = "openrouter/auto"
|
||||||
|
cfg.Providers.OpenRouter.APIKey = "sk-or-test"
|
||||||
|
|
||||||
|
provider, err := CreateProvider(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProvider() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := provider.(*HTTPProvider); !ok {
|
||||||
|
t.Fatalf("provider type = %T, want *HTTPProvider", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Provider = "codex-code"
|
||||||
|
|
||||||
|
provider, err := CreateProvider(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProvider() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := provider.(*CodexCliProvider); !ok {
|
||||||
|
t.Fatalf("provider type = %T, want *CodexCliProvider", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderReturnsCodexProviderForCodexCliAuthMethod(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Provider = "openai"
|
||||||
|
cfg.Providers.OpenAI.AuthMethod = "codex-cli"
|
||||||
|
|
||||||
|
provider, err := CreateProvider(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProvider() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := provider.(*CodexProvider); !ok {
|
||||||
|
t.Fatalf("provider type = %T, want *CodexProvider", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderReturnsClaudeProviderForAnthropicOAuth(t *testing.T) {
|
||||||
|
originalGetCredential := getCredential
|
||||||
|
t.Cleanup(func() { getCredential = originalGetCredential })
|
||||||
|
|
||||||
|
getCredential = func(provider string) (*auth.AuthCredential, error) {
|
||||||
|
if provider != "anthropic" {
|
||||||
|
t.Fatalf("provider = %q, want anthropic", provider)
|
||||||
|
}
|
||||||
|
return &auth.AuthCredential{
|
||||||
|
AccessToken: "anthropic-token",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Provider = "anthropic"
|
||||||
|
cfg.Providers.Anthropic.AuthMethod = "oauth"
|
||||||
|
cfg.Providers.Anthropic.APIBase = "https://proxy.example.com/v1"
|
||||||
|
|
||||||
|
provider, err := CreateProvider(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProvider() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeProvider, ok := provider.(*ClaudeProvider)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("provider type = %T, want *ClaudeProvider", provider)
|
||||||
|
}
|
||||||
|
if got := claudeProvider.delegate.BaseURL(); got != "https://proxy.example.com" {
|
||||||
|
t.Fatalf("anthropic baseURL = %q, want %q", got, "https://proxy.example.com")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderReturnsCodexProviderForOpenAIOAuth(t *testing.T) {
|
||||||
|
originalGetCredential := getCredential
|
||||||
|
t.Cleanup(func() { getCredential = originalGetCredential })
|
||||||
|
|
||||||
|
getCredential = func(provider string) (*auth.AuthCredential, error) {
|
||||||
|
if provider != "openai" {
|
||||||
|
t.Fatalf("provider = %q, want openai", provider)
|
||||||
|
}
|
||||||
|
return &auth.AuthCredential{
|
||||||
|
AccessToken: "openai-token",
|
||||||
|
AccountID: "acct_123",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Provider = "openai"
|
||||||
|
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||||
|
|
||||||
|
provider, err := CreateProvider(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProvider() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := provider.(*CodexProvider); !ok {
|
||||||
|
t.Fatalf("provider type = %T, want *CodexProvider", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
283
pkg/providers/fallback.go
Normal file
283
pkg/providers/fallback.go
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FallbackChain orchestrates model fallback across multiple candidates.
|
||||||
|
type FallbackChain struct {
|
||||||
|
cooldown *CooldownTracker
|
||||||
|
}
|
||||||
|
|
||||||
|
// FallbackCandidate represents one model/provider to try.
|
||||||
|
type FallbackCandidate struct {
|
||||||
|
Provider string
|
||||||
|
Model string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FallbackResult contains the successful response and metadata about all attempts.
|
||||||
|
type FallbackResult struct {
|
||||||
|
Response *LLMResponse
|
||||||
|
Provider string
|
||||||
|
Model string
|
||||||
|
Attempts []FallbackAttempt
|
||||||
|
}
|
||||||
|
|
||||||
|
// FallbackAttempt records one attempt in the fallback chain.
|
||||||
|
type FallbackAttempt struct {
|
||||||
|
Provider string
|
||||||
|
Model string
|
||||||
|
Error error
|
||||||
|
Reason FailoverReason
|
||||||
|
Duration time.Duration
|
||||||
|
Skipped bool // true if skipped due to cooldown
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFallbackChain creates a new fallback chain with the given cooldown tracker.
|
||||||
|
func NewFallbackChain(cooldown *CooldownTracker) *FallbackChain {
|
||||||
|
return &FallbackChain{cooldown: cooldown}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveCandidates parses model config into a deduplicated candidate list.
|
||||||
|
func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var candidates []FallbackCandidate
|
||||||
|
|
||||||
|
addCandidate := func(raw string) {
|
||||||
|
ref := ParseModelRef(raw, defaultProvider)
|
||||||
|
if ref == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := ModelKey(ref.Provider, ref.Model)
|
||||||
|
if seen[key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
candidates = append(candidates, FallbackCandidate{
|
||||||
|
Provider: ref.Provider,
|
||||||
|
Model: ref.Model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary first.
|
||||||
|
addCandidate(cfg.Primary)
|
||||||
|
|
||||||
|
// Then fallbacks.
|
||||||
|
for _, fb := range cfg.Fallbacks {
|
||||||
|
addCandidate(fb)
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute runs the fallback chain for text/chat requests.
|
||||||
|
// It tries each candidate in order, respecting cooldowns and error classification.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Candidates in cooldown are skipped (logged as skipped attempt).
|
||||||
|
// - context.Canceled aborts immediately (user abort, no fallback).
|
||||||
|
// - Non-retriable errors (format) abort immediately.
|
||||||
|
// - Retriable errors trigger fallback to next candidate.
|
||||||
|
// - Success marks provider as good (resets cooldown).
|
||||||
|
// - If all fail, returns aggregate error with all attempts.
|
||||||
|
func (fc *FallbackChain) Execute(
|
||||||
|
ctx context.Context,
|
||||||
|
candidates []FallbackCandidate,
|
||||||
|
run func(ctx context.Context, provider, model string) (*LLMResponse, error),
|
||||||
|
) (*FallbackResult, error) {
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, fmt.Errorf("fallback: no candidates configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &FallbackResult{
|
||||||
|
Attempts: make([]FallbackAttempt, 0, len(candidates)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, candidate := range candidates {
|
||||||
|
// Check context before each attempt.
|
||||||
|
if ctx.Err() == context.Canceled {
|
||||||
|
return nil, context.Canceled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cooldown.
|
||||||
|
if !fc.cooldown.IsAvailable(candidate.Provider) {
|
||||||
|
remaining := fc.cooldown.CooldownRemaining(candidate.Provider)
|
||||||
|
result.Attempts = append(result.Attempts, FallbackAttempt{
|
||||||
|
Provider: candidate.Provider,
|
||||||
|
Model: candidate.Model,
|
||||||
|
Skipped: true,
|
||||||
|
Reason: FailoverRateLimit,
|
||||||
|
Error: fmt.Errorf("provider %s in cooldown (%s remaining)", candidate.Provider, remaining.Round(time.Second)),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the run function.
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := run(ctx, candidate.Provider, candidate.Model)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// Success.
|
||||||
|
fc.cooldown.MarkSuccess(candidate.Provider)
|
||||||
|
result.Response = resp
|
||||||
|
result.Provider = candidate.Provider
|
||||||
|
result.Model = candidate.Model
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context cancellation: abort immediately, no fallback.
|
||||||
|
if ctx.Err() == context.Canceled {
|
||||||
|
result.Attempts = append(result.Attempts, FallbackAttempt{
|
||||||
|
Provider: candidate.Provider,
|
||||||
|
Model: candidate.Model,
|
||||||
|
Error: err,
|
||||||
|
Duration: elapsed,
|
||||||
|
})
|
||||||
|
return nil, context.Canceled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify the error.
|
||||||
|
failErr := ClassifyError(err, candidate.Provider, candidate.Model)
|
||||||
|
|
||||||
|
if failErr == nil {
|
||||||
|
// Unclassifiable error: do not fallback, return immediately.
|
||||||
|
result.Attempts = append(result.Attempts, FallbackAttempt{
|
||||||
|
Provider: candidate.Provider,
|
||||||
|
Model: candidate.Model,
|
||||||
|
Error: err,
|
||||||
|
Duration: elapsed,
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("fallback: unclassified error from %s/%s: %w",
|
||||||
|
candidate.Provider, candidate.Model, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-retriable error: abort immediately.
|
||||||
|
if !failErr.IsRetriable() {
|
||||||
|
result.Attempts = append(result.Attempts, FallbackAttempt{
|
||||||
|
Provider: candidate.Provider,
|
||||||
|
Model: candidate.Model,
|
||||||
|
Error: failErr,
|
||||||
|
Reason: failErr.Reason,
|
||||||
|
Duration: elapsed,
|
||||||
|
})
|
||||||
|
return nil, failErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retriable error: mark failure and continue to next candidate.
|
||||||
|
fc.cooldown.MarkFailure(candidate.Provider, failErr.Reason)
|
||||||
|
result.Attempts = append(result.Attempts, FallbackAttempt{
|
||||||
|
Provider: candidate.Provider,
|
||||||
|
Model: candidate.Model,
|
||||||
|
Error: failErr,
|
||||||
|
Reason: failErr.Reason,
|
||||||
|
Duration: elapsed,
|
||||||
|
})
|
||||||
|
|
||||||
|
// If this was the last candidate, return aggregate error.
|
||||||
|
if i == len(candidates)-1 {
|
||||||
|
return nil, &FallbackExhaustedError{Attempts: result.Attempts}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All candidates were skipped (all in cooldown).
|
||||||
|
return nil, &FallbackExhaustedError{Attempts: result.Attempts}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteImage runs the fallback chain for image/vision requests.
|
||||||
|
// Simpler than Execute: no cooldown checks (image endpoints have different rate limits).
|
||||||
|
// Image dimension/size errors abort immediately (non-retriable).
|
||||||
|
func (fc *FallbackChain) ExecuteImage(
|
||||||
|
ctx context.Context,
|
||||||
|
candidates []FallbackCandidate,
|
||||||
|
run func(ctx context.Context, provider, model string) (*LLMResponse, error),
|
||||||
|
) (*FallbackResult, error) {
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, fmt.Errorf("image fallback: no candidates configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &FallbackResult{
|
||||||
|
Attempts: make([]FallbackAttempt, 0, len(candidates)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, candidate := range candidates {
|
||||||
|
if ctx.Err() == context.Canceled {
|
||||||
|
return nil, context.Canceled
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := run(ctx, candidate.Provider, candidate.Model)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
result.Response = resp
|
||||||
|
result.Provider = candidate.Provider
|
||||||
|
result.Model = candidate.Model
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Err() == context.Canceled {
|
||||||
|
result.Attempts = append(result.Attempts, FallbackAttempt{
|
||||||
|
Provider: candidate.Provider,
|
||||||
|
Model: candidate.Model,
|
||||||
|
Error: err,
|
||||||
|
Duration: elapsed,
|
||||||
|
})
|
||||||
|
return nil, context.Canceled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image dimension/size errors are non-retriable.
|
||||||
|
errMsg := strings.ToLower(err.Error())
|
||||||
|
if IsImageDimensionError(errMsg) || IsImageSizeError(errMsg) {
|
||||||
|
result.Attempts = append(result.Attempts, FallbackAttempt{
|
||||||
|
Provider: candidate.Provider,
|
||||||
|
Model: candidate.Model,
|
||||||
|
Error: err,
|
||||||
|
Reason: FailoverFormat,
|
||||||
|
Duration: elapsed,
|
||||||
|
})
|
||||||
|
return nil, &FailoverError{
|
||||||
|
Reason: FailoverFormat,
|
||||||
|
Provider: candidate.Provider,
|
||||||
|
Model: candidate.Model,
|
||||||
|
Wrapped: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any other error: record and try next.
|
||||||
|
result.Attempts = append(result.Attempts, FallbackAttempt{
|
||||||
|
Provider: candidate.Provider,
|
||||||
|
Model: candidate.Model,
|
||||||
|
Error: err,
|
||||||
|
Duration: elapsed,
|
||||||
|
})
|
||||||
|
|
||||||
|
if i == len(candidates)-1 {
|
||||||
|
return nil, &FallbackExhaustedError{Attempts: result.Attempts}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, &FallbackExhaustedError{Attempts: result.Attempts}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FallbackExhaustedError indicates all fallback candidates were tried and failed.
|
||||||
|
type FallbackExhaustedError struct {
|
||||||
|
Attempts []FallbackAttempt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *FallbackExhaustedError) Error() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("fallback: all %d candidates failed:", len(e.Attempts)))
|
||||||
|
for i, a := range e.Attempts {
|
||||||
|
if a.Skipped {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: skipped (cooldown)", i+1, a.Provider, a.Model))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: %v (reason=%s, %s)",
|
||||||
|
i+1, a.Provider, a.Model, a.Error, a.Reason, a.Duration.Round(time.Millisecond)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
473
pkg/providers/fallback_test.go
Normal file
473
pkg/providers/fallback_test.go
Normal file
|
|
@ -0,0 +1,473 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeCandidate(provider, model string) FallbackCandidate {
|
||||||
|
return FallbackCandidate{Provider: provider, Model: model}
|
||||||
|
}
|
||||||
|
|
||||||
|
func successRun(content string) func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||||
|
return func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||||
|
return &LLMResponse{Content: content, FinishReason: "stop"}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func failRun(err error) func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||||
|
return func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallback_SingleCandidate_Success(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
fc := NewFallbackChain(ct)
|
||||||
|
|
||||||
|
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
|
||||||
|
result, err := fc.Execute(context.Background(), candidates, successRun("hello"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Response.Content != "hello" {
|
||||||
|
t.Errorf("content = %q, want hello", result.Response.Content)
|
||||||
|
}
|
||||||
|
if result.Provider != "openai" || result.Model != "gpt-4" {
|
||||||
|
t.Errorf("provider/model = %s/%s, want openai/gpt-4", result.Provider, result.Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallback_SecondCandidateSuccess(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
fc := NewFallbackChain(ct)
|
||||||
|
|
||||||
|
candidates := []FallbackCandidate{
|
||||||
|
makeCandidate("openai", "gpt-4"),
|
||||||
|
makeCandidate("anthropic", "claude-opus"),
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt := 0
|
||||||
|
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||||
|
attempt++
|
||||||
|
if attempt == 1 {
|
||||||
|
return nil, errors.New("rate limit exceeded")
|
||||||
|
}
|
||||||
|
return &LLMResponse{Content: "from claude", FinishReason: "stop"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := fc.Execute(context.Background(), candidates, run)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Provider != "anthropic" {
|
||||||
|
t.Errorf("provider = %q, want anthropic", result.Provider)
|
||||||
|
}
|
||||||
|
if result.Response.Content != "from claude" {
|
||||||
|
t.Errorf("content = %q, want 'from claude'", result.Response.Content)
|
||||||
|
}
|
||||||
|
if len(result.Attempts) != 1 {
|
||||||
|
t.Errorf("attempts = %d, want 1 (failed attempt recorded)", len(result.Attempts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallback_AllFail(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
fc := NewFallbackChain(ct)
|
||||||
|
|
||||||
|
candidates := []FallbackCandidate{
|
||||||
|
makeCandidate("openai", "gpt-4"),
|
||||||
|
makeCandidate("anthropic", "claude"),
|
||||||
|
makeCandidate("groq", "llama"),
|
||||||
|
}
|
||||||
|
|
||||||
|
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||||
|
return nil, errors.New("rate limit exceeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fc.Execute(context.Background(), candidates, run)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when all candidates fail")
|
||||||
|
}
|
||||||
|
var exhausted *FallbackExhaustedError
|
||||||
|
if !errors.As(err, &exhausted) {
|
||||||
|
t.Errorf("expected FallbackExhaustedError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if len(exhausted.Attempts) != 3 {
|
||||||
|
t.Errorf("attempts = %d, want 3", len(exhausted.Attempts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallback_ContextCanceled(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
fc := NewFallbackChain(ct)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
candidates := []FallbackCandidate{
|
||||||
|
makeCandidate("openai", "gpt-4"),
|
||||||
|
makeCandidate("anthropic", "claude"),
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt := 0
|
||||||
|
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||||
|
attempt++
|
||||||
|
if attempt == 1 {
|
||||||
|
cancel() // cancel context
|
||||||
|
return nil, context.Canceled
|
||||||
|
}
|
||||||
|
t.Error("should not reach second candidate after cancel")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fc.Execute(ctx, candidates, run)
|
||||||
|
if err != context.Canceled {
|
||||||
|
t.Errorf("expected context.Canceled, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallback_NonRetriableError(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
fc := NewFallbackChain(ct)
|
||||||
|
|
||||||
|
candidates := []FallbackCandidate{
|
||||||
|
makeCandidate("openai", "gpt-4"),
|
||||||
|
makeCandidate("anthropic", "claude"),
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt := 0
|
||||||
|
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||||
|
attempt++
|
||||||
|
return nil, errors.New("string should match pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fc.Execute(context.Background(), candidates, run)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-retriable")
|
||||||
|
}
|
||||||
|
var fe *FailoverError
|
||||||
|
if !errors.As(err, &fe) {
|
||||||
|
t.Fatalf("expected FailoverError, got %T", err)
|
||||||
|
}
|
||||||
|
if fe.Reason != FailoverFormat {
|
||||||
|
t.Errorf("reason = %q, want format", fe.Reason)
|
||||||
|
}
|
||||||
|
if attempt != 1 {
|
||||||
|
t.Errorf("attempt = %d, want 1 (non-retriable should not try next)", attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallback_CooldownSkip(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
ct, _ := newTestTracker(now)
|
||||||
|
fc := NewFallbackChain(ct)
|
||||||
|
|
||||||
|
// Put openai in cooldown
|
||||||
|
ct.MarkFailure("openai", FailoverRateLimit)
|
||||||
|
|
||||||
|
candidates := []FallbackCandidate{
|
||||||
|
makeCandidate("openai", "gpt-4"),
|
||||||
|
makeCandidate("anthropic", "claude"),
|
||||||
|
}
|
||||||
|
|
||||||
|
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||||
|
if provider == "openai" {
|
||||||
|
t.Error("should not call openai (in cooldown)")
|
||||||
|
}
|
||||||
|
return &LLMResponse{Content: "claude response", FinishReason: "stop"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := fc.Execute(context.Background(), candidates, run)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Provider != "anthropic" {
|
||||||
|
t.Errorf("provider = %q, want anthropic", result.Provider)
|
||||||
|
}
|
||||||
|
// Should have 1 skipped attempt
|
||||||
|
skipped := 0
|
||||||
|
for _, a := range result.Attempts {
|
||||||
|
if a.Skipped {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if skipped != 1 {
|
||||||
|
t.Errorf("skipped = %d, want 1", skipped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallback_AllInCooldown(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
fc := NewFallbackChain(ct)
|
||||||
|
|
||||||
|
// Put all providers in cooldown
|
||||||
|
ct.MarkFailure("openai", FailoverRateLimit)
|
||||||
|
ct.MarkFailure("anthropic", FailoverBilling)
|
||||||
|
|
||||||
|
candidates := []FallbackCandidate{
|
||||||
|
makeCandidate("openai", "gpt-4"),
|
||||||
|
makeCandidate("anthropic", "claude"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fc.Execute(context.Background(), candidates,
|
||||||
|
func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||||
|
t.Error("should not call any provider (all in cooldown)")
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when all in cooldown")
|
||||||
|
}
|
||||||
|
var exhausted *FallbackExhaustedError
|
||||||
|
if !errors.As(err, &exhausted) {
|
||||||
|
t.Fatalf("expected FallbackExhaustedError, got %T", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallback_NoCandidates(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
fc := NewFallbackChain(ct)
|
||||||
|
|
||||||
|
_, err := fc.Execute(context.Background(), nil, successRun("ok"))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for empty candidates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallback_EmptyFallbacks(t *testing.T) {
|
||||||
|
// Single primary, no fallbacks: should work like direct call
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
fc := NewFallbackChain(ct)
|
||||||
|
|
||||||
|
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
|
||||||
|
result, err := fc.Execute(context.Background(), candidates, successRun("ok"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Response.Content != "ok" {
|
||||||
|
t.Error("expected success with single candidate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallback_UnclassifiedError(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
fc := NewFallbackChain(ct)
|
||||||
|
|
||||||
|
candidates := []FallbackCandidate{
|
||||||
|
makeCandidate("openai", "gpt-4"),
|
||||||
|
makeCandidate("anthropic", "claude"),
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt := 0
|
||||||
|
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||||
|
attempt++
|
||||||
|
return nil, errors.New("completely unknown internal error")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fc.Execute(context.Background(), candidates, run)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unclassified error")
|
||||||
|
}
|
||||||
|
if attempt != 1 {
|
||||||
|
t.Errorf("attempt = %d, want 1 (should not fallback on unclassified)", attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallback_SuccessResetsCooldown(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
fc := NewFallbackChain(ct)
|
||||||
|
|
||||||
|
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
|
||||||
|
|
||||||
|
attempt := 0
|
||||||
|
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||||
|
attempt++
|
||||||
|
if attempt == 1 {
|
||||||
|
ct.MarkFailure("openai", FailoverRateLimit) // simulate failure tracked elsewhere
|
||||||
|
}
|
||||||
|
return &LLMResponse{Content: "ok", FinishReason: "stop"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fc.Execute(context.Background(), candidates, run)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !ct.IsAvailable("openai") {
|
||||||
|
t.Error("success should reset cooldown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Image Fallback Tests ---
|
||||||
|
|
||||||
|
func TestImageFallback_Success(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
fc := NewFallbackChain(ct)
|
||||||
|
|
||||||
|
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4o")}
|
||||||
|
result, err := fc.ExecuteImage(context.Background(), candidates, successRun("image result"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Response.Content != "image result" {
|
||||||
|
t.Error("expected image result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageFallback_DimensionError(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
fc := NewFallbackChain(ct)
|
||||||
|
|
||||||
|
candidates := []FallbackCandidate{
|
||||||
|
makeCandidate("openai", "gpt-4o"),
|
||||||
|
makeCandidate("anthropic", "claude"),
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt := 0
|
||||||
|
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||||
|
attempt++
|
||||||
|
return nil, errors.New("image dimensions exceed max 4096x4096")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fc.ExecuteImage(context.Background(), candidates, run)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for image dimension error")
|
||||||
|
}
|
||||||
|
if attempt != 1 {
|
||||||
|
t.Errorf("attempt = %d, want 1 (image dimension error should not retry)", attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageFallback_SizeError(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
fc := NewFallbackChain(ct)
|
||||||
|
|
||||||
|
candidates := []FallbackCandidate{
|
||||||
|
makeCandidate("openai", "gpt-4o"),
|
||||||
|
makeCandidate("anthropic", "claude"),
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt := 0
|
||||||
|
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||||
|
attempt++
|
||||||
|
return nil, errors.New("image exceeds 20 mb")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fc.ExecuteImage(context.Background(), candidates, run)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for image size error")
|
||||||
|
}
|
||||||
|
if attempt != 1 {
|
||||||
|
t.Errorf("attempt = %d, want 1 (image size error should not retry)", attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageFallback_RetryOnOtherErrors(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
fc := NewFallbackChain(ct)
|
||||||
|
|
||||||
|
candidates := []FallbackCandidate{
|
||||||
|
makeCandidate("openai", "gpt-4o"),
|
||||||
|
makeCandidate("anthropic", "claude-sonnet"),
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt := 0
|
||||||
|
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||||
|
attempt++
|
||||||
|
if attempt == 1 {
|
||||||
|
return nil, errors.New("rate limit exceeded")
|
||||||
|
}
|
||||||
|
return &LLMResponse{Content: "image ok", FinishReason: "stop"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := fc.ExecuteImage(context.Background(), candidates, run)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Provider != "anthropic" {
|
||||||
|
t.Errorf("provider = %q, want anthropic", result.Provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageFallback_NoCandidates(t *testing.T) {
|
||||||
|
ct := NewCooldownTracker()
|
||||||
|
fc := NewFallbackChain(ct)
|
||||||
|
|
||||||
|
_, err := fc.ExecuteImage(context.Background(), nil, successRun("ok"))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for empty candidates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ResolveCandidates Tests ---
|
||||||
|
|
||||||
|
func TestResolveCandidates_Simple(t *testing.T) {
|
||||||
|
cfg := ModelConfig{
|
||||||
|
Primary: "gpt-4",
|
||||||
|
Fallbacks: []string{"anthropic/claude-opus", "groq/llama-3"},
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := ResolveCandidates(cfg, "openai")
|
||||||
|
if len(candidates) != 3 {
|
||||||
|
t.Fatalf("candidates = %d, want 3", len(candidates))
|
||||||
|
}
|
||||||
|
|
||||||
|
if candidates[0].Provider != "openai" || candidates[0].Model != "gpt-4" {
|
||||||
|
t.Errorf("candidate[0] = %s/%s, want openai/gpt-4", candidates[0].Provider, candidates[0].Model)
|
||||||
|
}
|
||||||
|
if candidates[1].Provider != "anthropic" || candidates[1].Model != "claude-opus" {
|
||||||
|
t.Errorf("candidate[1] = %s/%s, want anthropic/claude-opus", candidates[1].Provider, candidates[1].Model)
|
||||||
|
}
|
||||||
|
if candidates[2].Provider != "groq" || candidates[2].Model != "llama-3" {
|
||||||
|
t.Errorf("candidate[2] = %s/%s, want groq/llama-3", candidates[2].Provider, candidates[2].Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCandidates_Deduplication(t *testing.T) {
|
||||||
|
cfg := ModelConfig{
|
||||||
|
Primary: "openai/gpt-4",
|
||||||
|
Fallbacks: []string{"openai/gpt-4", "anthropic/claude"},
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := ResolveCandidates(cfg, "default")
|
||||||
|
if len(candidates) != 2 {
|
||||||
|
t.Errorf("candidates = %d, want 2 (duplicate removed)", len(candidates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCandidates_EmptyFallbacks(t *testing.T) {
|
||||||
|
cfg := ModelConfig{
|
||||||
|
Primary: "gpt-4",
|
||||||
|
Fallbacks: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := ResolveCandidates(cfg, "openai")
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Errorf("candidates = %d, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCandidates_EmptyPrimary(t *testing.T) {
|
||||||
|
cfg := ModelConfig{
|
||||||
|
Primary: "",
|
||||||
|
Fallbacks: []string{"anthropic/claude"},
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := ResolveCandidates(cfg, "openai")
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Errorf("candidates = %d, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallbackExhaustedError_Message(t *testing.T) {
|
||||||
|
e := &FallbackExhaustedError{
|
||||||
|
Attempts: []FallbackAttempt{
|
||||||
|
{Provider: "openai", Model: "gpt-4", Error: errors.New("rate limited"), Reason: FailoverRateLimit, Duration: 500 * time.Millisecond},
|
||||||
|
{Provider: "anthropic", Model: "claude", Skipped: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msg := e.Error()
|
||||||
|
if msg == "" {
|
||||||
|
t.Error("expected non-empty error message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,448 +7,25 @@
|
||||||
package providers
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
"github.com/sipeed/picoclaw/pkg/providers/openai_compat"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type HTTPProvider struct {
|
type HTTPProvider struct {
|
||||||
apiKey string
|
delegate *openai_compat.Provider
|
||||||
apiBase string
|
|
||||||
httpClient *http.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
||||||
client := &http.Client{
|
|
||||||
Timeout: 120 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
if proxy != "" {
|
|
||||||
proxyURL, err := url.Parse(proxy)
|
|
||||||
if err == nil {
|
|
||||||
client.Transport = &http.Transport{
|
|
||||||
Proxy: http.ProxyURL(proxyURL),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &HTTPProvider{
|
return &HTTPProvider{
|
||||||
apiKey: apiKey,
|
delegate: openai_compat.NewProvider(apiKey, apiBase, proxy),
|
||||||
apiBase: strings.TrimRight(apiBase, "/"),
|
|
||||||
httpClient: client,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||||
if p.apiBase == "" {
|
return p.delegate.Chat(ctx, messages, tools, model, options)
|
||||||
return nil, fmt.Errorf("API base not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip provider prefix from model name (e.g., moonshot/kimi-k2.5 -> kimi-k2.5, groq/openai/gpt-oss-120b -> openai/gpt-oss-120b, ollama/qwen2.5:14b -> qwen2.5:14b)
|
|
||||||
if idx := strings.Index(model, "/"); idx != -1 {
|
|
||||||
prefix := model[:idx]
|
|
||||||
if prefix == "moonshot" || prefix == "nvidia" || prefix == "groq" || prefix == "ollama" {
|
|
||||||
model = model[idx+1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
requestBody := map[string]interface{}{
|
|
||||||
"model": model,
|
|
||||||
"messages": messages,
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tools) > 0 {
|
|
||||||
requestBody["tools"] = tools
|
|
||||||
requestBody["tool_choice"] = "auto"
|
|
||||||
}
|
|
||||||
|
|
||||||
if maxTokens, ok := options["max_tokens"].(int); ok {
|
|
||||||
lowerModel := strings.ToLower(model)
|
|
||||||
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") {
|
|
||||||
requestBody["max_completion_tokens"] = maxTokens
|
|
||||||
} else {
|
|
||||||
requestBody["max_tokens"] = maxTokens
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if temperature, ok := options["temperature"].(float64); ok {
|
|
||||||
lowerModel := strings.ToLower(model)
|
|
||||||
// Kimi k2 models only support temperature=1
|
|
||||||
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
|
|
||||||
requestBody["temperature"] = 1.0
|
|
||||||
} else {
|
|
||||||
requestBody["temperature"] = temperature
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonData, err := json.Marshal(requestBody)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
if p.apiKey != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := p.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
return p.parseResponse(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) {
|
|
||||||
var apiResponse struct {
|
|
||||||
Choices []struct {
|
|
||||||
Message struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
ToolCalls []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Function *struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Arguments string `json:"arguments"`
|
|
||||||
} `json:"function"`
|
|
||||||
} `json:"tool_calls"`
|
|
||||||
} `json:"message"`
|
|
||||||
FinishReason string `json:"finish_reason"`
|
|
||||||
} `json:"choices"`
|
|
||||||
Usage *UsageInfo `json:"usage"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(apiResponse.Choices) == 0 {
|
|
||||||
return &LLMResponse{
|
|
||||||
Content: "",
|
|
||||||
FinishReason: "stop",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
choice := apiResponse.Choices[0]
|
|
||||||
|
|
||||||
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
|
|
||||||
for _, tc := range choice.Message.ToolCalls {
|
|
||||||
arguments := make(map[string]interface{})
|
|
||||||
name := ""
|
|
||||||
|
|
||||||
// Handle OpenAI format with nested function object
|
|
||||||
if tc.Type == "function" && tc.Function != nil {
|
|
||||||
name = tc.Function.Name
|
|
||||||
if tc.Function.Arguments != "" {
|
|
||||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
|
|
||||||
arguments["raw"] = tc.Function.Arguments
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if tc.Function != nil {
|
|
||||||
// Legacy format without type field
|
|
||||||
name = tc.Function.Name
|
|
||||||
if tc.Function.Arguments != "" {
|
|
||||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
|
|
||||||
arguments["raw"] = tc.Function.Arguments
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toolCalls = append(toolCalls, ToolCall{
|
|
||||||
ID: tc.ID,
|
|
||||||
Name: name,
|
|
||||||
Arguments: arguments,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return &LLMResponse{
|
|
||||||
Content: choice.Message.Content,
|
|
||||||
ToolCalls: toolCalls,
|
|
||||||
FinishReason: choice.FinishReason,
|
|
||||||
Usage: apiResponse.Usage,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTPProvider) GetDefaultModel() string {
|
func (p *HTTPProvider) GetDefaultModel() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func createClaudeAuthProvider() (LLMProvider, error) {
|
|
||||||
cred, err := auth.GetCredential("anthropic")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("loading auth credentials: %w", err)
|
|
||||||
}
|
|
||||||
if cred == nil {
|
|
||||||
return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic")
|
|
||||||
}
|
|
||||||
return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func createCodexAuthProvider(enableWebSearch bool) (LLMProvider, error) {
|
|
||||||
cred, err := auth.GetCredential("openai")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("loading auth credentials: %w", err)
|
|
||||||
}
|
|
||||||
if cred == nil {
|
|
||||||
return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai")
|
|
||||||
}
|
|
||||||
p := NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource())
|
|
||||||
p.enableWebSearch = enableWebSearch
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateProvider(cfg *config.Config) (LLMProvider, error) {
|
|
||||||
model := cfg.Agents.Defaults.Model
|
|
||||||
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
|
|
||||||
|
|
||||||
var apiKey, apiBase, proxy string
|
|
||||||
|
|
||||||
lowerModel := strings.ToLower(model)
|
|
||||||
|
|
||||||
// First, try to use explicitly configured provider
|
|
||||||
if providerName != "" {
|
|
||||||
switch providerName {
|
|
||||||
case "groq":
|
|
||||||
if cfg.Providers.Groq.APIKey != "" {
|
|
||||||
apiKey = cfg.Providers.Groq.APIKey
|
|
||||||
apiBase = cfg.Providers.Groq.APIBase
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.groq.com/openai/v1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "openai", "gpt":
|
|
||||||
if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" {
|
|
||||||
if cfg.Providers.OpenAI.AuthMethod == "codex-cli" {
|
|
||||||
c := NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource())
|
|
||||||
c.enableWebSearch = cfg.Providers.OpenAI.WebSearch
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
|
|
||||||
return createCodexAuthProvider(cfg.Providers.OpenAI.WebSearch)
|
|
||||||
}
|
|
||||||
apiKey = cfg.Providers.OpenAI.APIKey
|
|
||||||
apiBase = cfg.Providers.OpenAI.APIBase
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.openai.com/v1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "anthropic", "claude":
|
|
||||||
if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" {
|
|
||||||
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
|
|
||||||
return createClaudeAuthProvider()
|
|
||||||
}
|
|
||||||
apiKey = cfg.Providers.Anthropic.APIKey
|
|
||||||
apiBase = cfg.Providers.Anthropic.APIBase
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.anthropic.com/v1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "openrouter":
|
|
||||||
if cfg.Providers.OpenRouter.APIKey != "" {
|
|
||||||
apiKey = cfg.Providers.OpenRouter.APIKey
|
|
||||||
if cfg.Providers.OpenRouter.APIBase != "" {
|
|
||||||
apiBase = cfg.Providers.OpenRouter.APIBase
|
|
||||||
} else {
|
|
||||||
apiBase = "https://openrouter.ai/api/v1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "zhipu", "glm":
|
|
||||||
if cfg.Providers.Zhipu.APIKey != "" {
|
|
||||||
apiKey = cfg.Providers.Zhipu.APIKey
|
|
||||||
apiBase = cfg.Providers.Zhipu.APIBase
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://open.bigmodel.cn/api/paas/v4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "gemini", "google":
|
|
||||||
if cfg.Providers.Gemini.APIKey != "" {
|
|
||||||
apiKey = cfg.Providers.Gemini.APIKey
|
|
||||||
apiBase = cfg.Providers.Gemini.APIBase
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://generativelanguage.googleapis.com/v1beta"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "vllm":
|
|
||||||
if cfg.Providers.VLLM.APIBase != "" {
|
|
||||||
apiKey = cfg.Providers.VLLM.APIKey
|
|
||||||
apiBase = cfg.Providers.VLLM.APIBase
|
|
||||||
}
|
|
||||||
case "shengsuanyun":
|
|
||||||
if cfg.Providers.ShengSuanYun.APIKey != "" {
|
|
||||||
apiKey = cfg.Providers.ShengSuanYun.APIKey
|
|
||||||
apiBase = cfg.Providers.ShengSuanYun.APIBase
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://router.shengsuanyun.com/api/v1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "claude-cli", "claudecode", "claude-code":
|
|
||||||
workspace := cfg.WorkspacePath()
|
|
||||||
if workspace == "" {
|
|
||||||
workspace = "."
|
|
||||||
}
|
|
||||||
return NewClaudeCliProvider(workspace), nil
|
|
||||||
case "codex-cli", "codex-code":
|
|
||||||
workspace := cfg.WorkspacePath()
|
|
||||||
if workspace == "" {
|
|
||||||
workspace = "."
|
|
||||||
}
|
|
||||||
return NewCodexCliProvider(workspace), nil
|
|
||||||
case "deepseek":
|
|
||||||
if cfg.Providers.DeepSeek.APIKey != "" {
|
|
||||||
apiKey = cfg.Providers.DeepSeek.APIKey
|
|
||||||
apiBase = cfg.Providers.DeepSeek.APIBase
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.deepseek.com/v1"
|
|
||||||
}
|
|
||||||
if model != "deepseek-chat" && model != "deepseek-reasoner" {
|
|
||||||
model = "deepseek-chat"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "github_copilot", "copilot":
|
|
||||||
if cfg.Providers.GitHubCopilot.APIBase != "" {
|
|
||||||
apiBase = cfg.Providers.GitHubCopilot.APIBase
|
|
||||||
} else {
|
|
||||||
apiBase = "localhost:4321"
|
|
||||||
}
|
|
||||||
return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: detect provider from model name
|
|
||||||
if apiKey == "" && apiBase == "" {
|
|
||||||
switch {
|
|
||||||
case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "":
|
|
||||||
apiKey = cfg.Providers.Moonshot.APIKey
|
|
||||||
apiBase = cfg.Providers.Moonshot.APIBase
|
|
||||||
proxy = cfg.Providers.Moonshot.Proxy
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.moonshot.cn/v1"
|
|
||||||
}
|
|
||||||
|
|
||||||
case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"):
|
|
||||||
apiKey = cfg.Providers.OpenRouter.APIKey
|
|
||||||
proxy = cfg.Providers.OpenRouter.Proxy
|
|
||||||
if cfg.Providers.OpenRouter.APIBase != "" {
|
|
||||||
apiBase = cfg.Providers.OpenRouter.APIBase
|
|
||||||
} else {
|
|
||||||
apiBase = "https://openrouter.ai/api/v1"
|
|
||||||
}
|
|
||||||
|
|
||||||
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""):
|
|
||||||
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
|
|
||||||
return createClaudeAuthProvider()
|
|
||||||
}
|
|
||||||
apiKey = cfg.Providers.Anthropic.APIKey
|
|
||||||
apiBase = cfg.Providers.Anthropic.APIBase
|
|
||||||
proxy = cfg.Providers.Anthropic.Proxy
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.anthropic.com/v1"
|
|
||||||
}
|
|
||||||
|
|
||||||
case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""):
|
|
||||||
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
|
|
||||||
return createCodexAuthProvider(cfg.Providers.OpenAI.WebSearch)
|
|
||||||
}
|
|
||||||
apiKey = cfg.Providers.OpenAI.APIKey
|
|
||||||
apiBase = cfg.Providers.OpenAI.APIBase
|
|
||||||
proxy = cfg.Providers.OpenAI.Proxy
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.openai.com/v1"
|
|
||||||
}
|
|
||||||
|
|
||||||
case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "":
|
|
||||||
apiKey = cfg.Providers.Gemini.APIKey
|
|
||||||
apiBase = cfg.Providers.Gemini.APIBase
|
|
||||||
proxy = cfg.Providers.Gemini.Proxy
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://generativelanguage.googleapis.com/v1beta"
|
|
||||||
}
|
|
||||||
|
|
||||||
case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "":
|
|
||||||
apiKey = cfg.Providers.Zhipu.APIKey
|
|
||||||
apiBase = cfg.Providers.Zhipu.APIBase
|
|
||||||
proxy = cfg.Providers.Zhipu.Proxy
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://open.bigmodel.cn/api/paas/v4"
|
|
||||||
}
|
|
||||||
|
|
||||||
case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "":
|
|
||||||
apiKey = cfg.Providers.Groq.APIKey
|
|
||||||
apiBase = cfg.Providers.Groq.APIBase
|
|
||||||
proxy = cfg.Providers.Groq.Proxy
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.groq.com/openai/v1"
|
|
||||||
}
|
|
||||||
|
|
||||||
case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "":
|
|
||||||
apiKey = cfg.Providers.Nvidia.APIKey
|
|
||||||
apiBase = cfg.Providers.Nvidia.APIBase
|
|
||||||
proxy = cfg.Providers.Nvidia.Proxy
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://integrate.api.nvidia.com/v1"
|
|
||||||
}
|
|
||||||
case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "":
|
|
||||||
fmt.Println("Ollama provider selected based on model name prefix")
|
|
||||||
apiKey = cfg.Providers.Ollama.APIKey
|
|
||||||
apiBase = cfg.Providers.Ollama.APIBase
|
|
||||||
proxy = cfg.Providers.Ollama.Proxy
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "http://localhost:11434/v1"
|
|
||||||
}
|
|
||||||
fmt.Println("Ollama apiBase:", apiBase)
|
|
||||||
case cfg.Providers.VLLM.APIBase != "":
|
|
||||||
apiKey = cfg.Providers.VLLM.APIKey
|
|
||||||
apiBase = cfg.Providers.VLLM.APIBase
|
|
||||||
proxy = cfg.Providers.VLLM.Proxy
|
|
||||||
|
|
||||||
default:
|
|
||||||
if cfg.Providers.OpenRouter.APIKey != "" {
|
|
||||||
apiKey = cfg.Providers.OpenRouter.APIKey
|
|
||||||
proxy = cfg.Providers.OpenRouter.Proxy
|
|
||||||
if cfg.Providers.OpenRouter.APIBase != "" {
|
|
||||||
apiBase = cfg.Providers.OpenRouter.APIBase
|
|
||||||
} else {
|
|
||||||
apiBase = "https://openrouter.ai/api/v1"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("no API key configured for model: %s", model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if apiKey == "" && !strings.HasPrefix(model, "bedrock/") {
|
|
||||||
return nil, fmt.Errorf("no API key configured for provider (model: %s)", model)
|
|
||||||
}
|
|
||||||
|
|
||||||
if apiBase == "" {
|
|
||||||
return nil, fmt.Errorf("no API base configured for provider (model: %s)", model)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NewHTTPProvider(apiKey, apiBase, proxy), nil
|
|
||||||
}
|
|
||||||
|
|
|
||||||
64
pkg/providers/model_ref.go
Normal file
64
pkg/providers/model_ref.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// ModelRef represents a parsed model reference with provider and model name.
|
||||||
|
type ModelRef struct {
|
||||||
|
Provider string
|
||||||
|
Model string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseModelRef parses "anthropic/claude-opus" into {Provider: "anthropic", Model: "claude-opus"}.
|
||||||
|
// If no slash present, uses defaultProvider.
|
||||||
|
// Returns nil for empty input.
|
||||||
|
func ParseModelRef(raw string, defaultProvider string) *ModelRef {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx := strings.Index(raw, "/"); idx > 0 {
|
||||||
|
provider := NormalizeProvider(raw[:idx])
|
||||||
|
model := strings.TrimSpace(raw[idx+1:])
|
||||||
|
if model == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &ModelRef{Provider: provider, Model: model}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ModelRef{
|
||||||
|
Provider: NormalizeProvider(defaultProvider),
|
||||||
|
Model: raw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeProvider normalizes provider identifiers to canonical form.
|
||||||
|
func NormalizeProvider(provider string) string {
|
||||||
|
p := strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
|
||||||
|
switch p {
|
||||||
|
case "z.ai", "z-ai":
|
||||||
|
return "zai"
|
||||||
|
case "opencode-zen":
|
||||||
|
return "opencode"
|
||||||
|
case "qwen":
|
||||||
|
return "qwen-portal"
|
||||||
|
case "kimi-code":
|
||||||
|
return "kimi-coding"
|
||||||
|
case "gpt":
|
||||||
|
return "openai"
|
||||||
|
case "claude":
|
||||||
|
return "anthropic"
|
||||||
|
case "glm":
|
||||||
|
return "zhipu"
|
||||||
|
case "google":
|
||||||
|
return "gemini"
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelKey returns a canonical "provider/model" key for deduplication.
|
||||||
|
func ModelKey(provider, model string) string {
|
||||||
|
return NormalizeProvider(provider) + "/" + strings.ToLower(strings.TrimSpace(model))
|
||||||
|
}
|
||||||
125
pkg/providers/model_ref_test.go
Normal file
125
pkg/providers/model_ref_test.go
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseModelRef_WithSlash(t *testing.T) {
|
||||||
|
ref := ParseModelRef("anthropic/claude-opus", "openai")
|
||||||
|
if ref == nil {
|
||||||
|
t.Fatal("expected non-nil ref")
|
||||||
|
}
|
||||||
|
if ref.Provider != "anthropic" {
|
||||||
|
t.Errorf("provider = %q, want anthropic", ref.Provider)
|
||||||
|
}
|
||||||
|
if ref.Model != "claude-opus" {
|
||||||
|
t.Errorf("model = %q, want claude-opus", ref.Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseModelRef_WithoutSlash(t *testing.T) {
|
||||||
|
ref := ParseModelRef("gpt-4", "openai")
|
||||||
|
if ref == nil {
|
||||||
|
t.Fatal("expected non-nil ref")
|
||||||
|
}
|
||||||
|
if ref.Provider != "openai" {
|
||||||
|
t.Errorf("provider = %q, want openai", ref.Provider)
|
||||||
|
}
|
||||||
|
if ref.Model != "gpt-4" {
|
||||||
|
t.Errorf("model = %q, want gpt-4", ref.Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseModelRef_Empty(t *testing.T) {
|
||||||
|
ref := ParseModelRef("", "openai")
|
||||||
|
if ref != nil {
|
||||||
|
t.Errorf("expected nil for empty string, got %+v", ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseModelRef_EmptyModelAfterSlash(t *testing.T) {
|
||||||
|
ref := ParseModelRef("openai/", "default")
|
||||||
|
if ref != nil {
|
||||||
|
t.Errorf("expected nil for empty model, got %+v", ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseModelRef_WhitespaceHandling(t *testing.T) {
|
||||||
|
ref := ParseModelRef(" anthropic / claude-opus ", "openai")
|
||||||
|
if ref == nil {
|
||||||
|
t.Fatal("expected non-nil ref")
|
||||||
|
}
|
||||||
|
if ref.Provider != "anthropic" {
|
||||||
|
t.Errorf("provider = %q, want anthropic", ref.Provider)
|
||||||
|
}
|
||||||
|
if ref.Model != "claude-opus" {
|
||||||
|
t.Errorf("model = %q, want claude-opus", ref.Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeProvider(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"OpenAI", "openai"},
|
||||||
|
{"ANTHROPIC", "anthropic"},
|
||||||
|
{"z.ai", "zai"},
|
||||||
|
{"z-ai", "zai"},
|
||||||
|
{"Z.AI", "zai"},
|
||||||
|
{"opencode-zen", "opencode"},
|
||||||
|
{"qwen", "qwen-portal"},
|
||||||
|
{"kimi-code", "kimi-coding"},
|
||||||
|
{"gpt", "openai"},
|
||||||
|
{"claude", "anthropic"},
|
||||||
|
{"glm", "zhipu"},
|
||||||
|
{"google", "gemini"},
|
||||||
|
{"groq", "groq"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := NormalizeProvider(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("NormalizeProvider(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
provider string
|
||||||
|
model string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"openai", "gpt-4", "openai/gpt-4"},
|
||||||
|
{"Anthropic", "Claude-Opus", "anthropic/claude-opus"},
|
||||||
|
{"claude", "sonnet", "anthropic/sonnet"},
|
||||||
|
{"z.ai", "Model-X", "zai/model-x"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := ModelKey(tt.provider, tt.model)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("ModelKey(%q, %q) = %q, want %q", tt.provider, tt.model, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseModelRef_ProviderNormalization(t *testing.T) {
|
||||||
|
ref := ParseModelRef("Z.AI/model-x", "default")
|
||||||
|
if ref == nil {
|
||||||
|
t.Fatal("expected non-nil ref")
|
||||||
|
}
|
||||||
|
if ref.Provider != "zai" {
|
||||||
|
t.Errorf("provider = %q, want zai", ref.Provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseModelRef_DefaultProviderNormalization(t *testing.T) {
|
||||||
|
ref := ParseModelRef("gpt-4o", "GPT")
|
||||||
|
if ref == nil {
|
||||||
|
t.Fatal("expected non-nil ref")
|
||||||
|
}
|
||||||
|
if ref.Provider != "openai" {
|
||||||
|
t.Errorf("provider = %q, want openai (normalized from GPT)", ref.Provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
232
pkg/providers/openai_compat/provider.go
Normal file
232
pkg/providers/openai_compat/provider.go
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
package openai_compat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ToolCall = protocoltypes.ToolCall
|
||||||
|
type FunctionCall = protocoltypes.FunctionCall
|
||||||
|
type LLMResponse = protocoltypes.LLMResponse
|
||||||
|
type UsageInfo = protocoltypes.UsageInfo
|
||||||
|
type Message = protocoltypes.Message
|
||||||
|
type ToolDefinition = protocoltypes.ToolDefinition
|
||||||
|
type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
apiKey string
|
||||||
|
apiBase string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProvider(apiKey, apiBase, proxy string) *Provider {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 120 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
if proxy != "" {
|
||||||
|
parsed, err := url.Parse(proxy)
|
||||||
|
if err == nil {
|
||||||
|
client.Transport = &http.Transport{
|
||||||
|
Proxy: http.ProxyURL(parsed),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("openai_compat: invalid proxy URL %q: %v", proxy, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Provider{
|
||||||
|
apiKey: apiKey,
|
||||||
|
apiBase: strings.TrimRight(apiBase, "/"),
|
||||||
|
httpClient: client,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||||
|
if p.apiBase == "" {
|
||||||
|
return nil, fmt.Errorf("API base not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
model = normalizeModel(model, p.apiBase)
|
||||||
|
|
||||||
|
requestBody := map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tools) > 0 {
|
||||||
|
requestBody["tools"] = tools
|
||||||
|
requestBody["tool_choice"] = "auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxTokens, ok := asInt(options["max_tokens"]); ok {
|
||||||
|
lowerModel := strings.ToLower(model)
|
||||||
|
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") {
|
||||||
|
requestBody["max_completion_tokens"] = maxTokens
|
||||||
|
} else {
|
||||||
|
requestBody["max_tokens"] = maxTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if temperature, ok := asFloat(options["temperature"]); ok {
|
||||||
|
lowerModel := strings.ToLower(model)
|
||||||
|
// Kimi k2 models only support temperature=1.
|
||||||
|
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
|
||||||
|
requestBody["temperature"] = 1.0
|
||||||
|
} else {
|
||||||
|
requestBody["temperature"] = temperature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(requestBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if p.apiKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseResponse(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseResponse(body []byte) (*LLMResponse, error) {
|
||||||
|
var apiResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function *struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
} `json:"function"`
|
||||||
|
} `json:"tool_calls"`
|
||||||
|
} `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage *UsageInfo `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(apiResponse.Choices) == 0 {
|
||||||
|
return &LLMResponse{
|
||||||
|
Content: "",
|
||||||
|
FinishReason: "stop",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
choice := apiResponse.Choices[0]
|
||||||
|
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
|
||||||
|
for _, tc := range choice.Message.ToolCalls {
|
||||||
|
arguments := make(map[string]interface{})
|
||||||
|
name := ""
|
||||||
|
|
||||||
|
if tc.Function != nil {
|
||||||
|
name = tc.Function.Name
|
||||||
|
if tc.Function.Arguments != "" {
|
||||||
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
|
||||||
|
log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
|
||||||
|
arguments["raw"] = tc.Function.Arguments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls = append(toolCalls, ToolCall{
|
||||||
|
ID: tc.ID,
|
||||||
|
Name: name,
|
||||||
|
Arguments: arguments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LLMResponse{
|
||||||
|
Content: choice.Message.Content,
|
||||||
|
ToolCalls: toolCalls,
|
||||||
|
FinishReason: choice.FinishReason,
|
||||||
|
Usage: apiResponse.Usage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeModel(model, apiBase string) string {
|
||||||
|
idx := strings.Index(model, "/")
|
||||||
|
if idx == -1 {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(strings.ToLower(apiBase), "openrouter.ai") {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := strings.ToLower(model[:idx])
|
||||||
|
switch prefix {
|
||||||
|
case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu":
|
||||||
|
return model[idx+1:]
|
||||||
|
default:
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func asInt(v interface{}) (int, bool) {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case int:
|
||||||
|
return val, true
|
||||||
|
case int64:
|
||||||
|
return int(val), true
|
||||||
|
case float64:
|
||||||
|
return int(val), true
|
||||||
|
case float32:
|
||||||
|
return int(val), true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func asFloat(v interface{}) (float64, bool) {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return val, true
|
||||||
|
case float32:
|
||||||
|
return float64(val), true
|
||||||
|
case int:
|
||||||
|
return float64(val), true
|
||||||
|
case int64:
|
||||||
|
return float64(val), true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
277
pkg/providers/openai_compat/provider_test.go
Normal file
277
pkg/providers/openai_compat/provider_test.go
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
package openai_compat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
|
||||||
|
var requestBody map[string]interface{}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/chat/completions" {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"choices": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"message": map[string]interface{}{"content": "ok"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "glm-4.7", map[string]interface{}{"max_tokens": 1234})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := requestBody["max_completion_tokens"]; !ok {
|
||||||
|
t.Fatalf("expected max_completion_tokens in request body")
|
||||||
|
}
|
||||||
|
if _, ok := requestBody["max_tokens"]; ok {
|
||||||
|
t.Fatalf("did not expect max_tokens key for glm model")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_ParsesToolCalls(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"choices": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"message": map[string]interface{}{
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"id": "call_1",
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]interface{}{
|
||||||
|
"name": "get_weather",
|
||||||
|
"arguments": "{\"city\":\"SF\"}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"usage": map[string]interface{}{
|
||||||
|
"prompt_tokens": 10,
|
||||||
|
"completion_tokens": 5,
|
||||||
|
"total_tokens": 15,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(out.ToolCalls) != 1 {
|
||||||
|
t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
|
||||||
|
}
|
||||||
|
if out.ToolCalls[0].Name != "get_weather" {
|
||||||
|
t.Fatalf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather")
|
||||||
|
}
|
||||||
|
if out.ToolCalls[0].Arguments["city"] != "SF" {
|
||||||
|
t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", out.ToolCalls[0].Arguments["city"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_HTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) {
|
||||||
|
var requestBody map[string]interface{}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"choices": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"message": map[string]interface{}{"content": "ok"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
_, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
"moonshot/kimi-k2.5",
|
||||||
|
map[string]interface{}{"temperature": 0.3},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestBody["model"] != "kimi-k2.5" {
|
||||||
|
t.Fatalf("model = %v, want kimi-k2.5", requestBody["model"])
|
||||||
|
}
|
||||||
|
if requestBody["temperature"] != 1.0 {
|
||||||
|
t.Fatalf("temperature = %v, want 1.0", requestBody["temperature"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantModel string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "strips groq prefix and keeps nested model",
|
||||||
|
input: "groq/openai/gpt-oss-120b",
|
||||||
|
wantModel: "openai/gpt-oss-120b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "strips ollama prefix",
|
||||||
|
input: "ollama/qwen2.5:14b",
|
||||||
|
wantModel: "qwen2.5:14b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "strips deepseek prefix",
|
||||||
|
input: "deepseek/deepseek-chat",
|
||||||
|
wantModel: "deepseek-chat",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var requestBody map[string]interface{}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"choices": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"message": map[string]interface{}{"content": "ok"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, tt.input, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestBody["model"] != tt.wantModel {
|
||||||
|
t.Fatalf("model = %v, want %s", requestBody["model"], tt.wantModel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvider_ProxyConfigured(t *testing.T) {
|
||||||
|
proxyURL := "http://127.0.0.1:8080"
|
||||||
|
p := NewProvider("key", "https://example.com", proxyURL)
|
||||||
|
|
||||||
|
transport, ok := p.httpClient.Transport.(*http.Transport)
|
||||||
|
if !ok || transport == nil {
|
||||||
|
t.Fatalf("expected http transport with proxy, got %T", p.httpClient.Transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.example.com"}}
|
||||||
|
gotProxy, err := transport.Proxy(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("proxy function returned error: %v", err)
|
||||||
|
}
|
||||||
|
if gotProxy == nil || gotProxy.String() != proxyURL {
|
||||||
|
t.Fatalf("proxy = %v, want %s", gotProxy, proxyURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_AcceptsNumericOptionTypes(t *testing.T) {
|
||||||
|
var requestBody map[string]interface{}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"choices": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"message": map[string]interface{}{"content": "ok"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
_, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
"gpt-4o",
|
||||||
|
map[string]interface{}{"max_tokens": float64(512), "temperature": 1},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestBody["max_tokens"] != float64(512) {
|
||||||
|
t.Fatalf("max_tokens = %v, want 512", requestBody["max_tokens"])
|
||||||
|
}
|
||||||
|
if requestBody["temperature"] != float64(1) {
|
||||||
|
t.Fatalf("temperature = %v, want 1", requestBody["temperature"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeModel_UsesAPIBase(t *testing.T) {
|
||||||
|
if got := normalizeModel("deepseek/deepseek-chat", "https://api.deepseek.com/v1"); got != "deepseek-chat" {
|
||||||
|
t.Fatalf("normalizeModel(deepseek) = %q, want %q", got, "deepseek-chat")
|
||||||
|
}
|
||||||
|
if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" {
|
||||||
|
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
|
||||||
|
}
|
||||||
|
}
|
||||||
45
pkg/providers/protocoltypes/types.go
Normal file
45
pkg/providers/protocoltypes/types.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
package protocoltypes
|
||||||
|
|
||||||
|
type ToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Function *FunctionCall `json:"function,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Arguments map[string]interface{} `json:"arguments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FunctionCall struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LLMResponse struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
Usage *UsageInfo `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UsageInfo struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolDefinition struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function ToolFunctionDefinition `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolFunctionDefinition struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Parameters map[string]interface{} `json:"parameters"`
|
||||||
|
}
|
||||||
|
|
@ -1,52 +1,64 @@
|
||||||
package providers
|
package providers
|
||||||
|
|
||||||
import "context"
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
type ToolCall struct {
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
ID string `json:"id"`
|
)
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
Function *FunctionCall `json:"function,omitempty"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Arguments map[string]interface{} `json:"arguments,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type FunctionCall struct {
|
type ToolCall = protocoltypes.ToolCall
|
||||||
Name string `json:"name"`
|
type FunctionCall = protocoltypes.FunctionCall
|
||||||
Arguments string `json:"arguments"`
|
type LLMResponse = protocoltypes.LLMResponse
|
||||||
}
|
type UsageInfo = protocoltypes.UsageInfo
|
||||||
|
type Message = protocoltypes.Message
|
||||||
type LLMResponse struct {
|
type ToolDefinition = protocoltypes.ToolDefinition
|
||||||
Content string `json:"content"`
|
type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
|
||||||
FinishReason string `json:"finish_reason"`
|
|
||||||
Usage *UsageInfo `json:"usage,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UsageInfo struct {
|
|
||||||
PromptTokens int `json:"prompt_tokens"`
|
|
||||||
CompletionTokens int `json:"completion_tokens"`
|
|
||||||
TotalTokens int `json:"total_tokens"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Message struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LLMProvider interface {
|
type LLMProvider interface {
|
||||||
Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error)
|
Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error)
|
||||||
GetDefaultModel() string
|
GetDefaultModel() string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolDefinition struct {
|
// FailoverReason classifies why an LLM request failed for fallback decisions.
|
||||||
Type string `json:"type"`
|
type FailoverReason string
|
||||||
Function ToolFunctionDefinition `json:"function"`
|
|
||||||
|
const (
|
||||||
|
FailoverAuth FailoverReason = "auth"
|
||||||
|
FailoverRateLimit FailoverReason = "rate_limit"
|
||||||
|
FailoverBilling FailoverReason = "billing"
|
||||||
|
FailoverTimeout FailoverReason = "timeout"
|
||||||
|
FailoverFormat FailoverReason = "format"
|
||||||
|
FailoverOverloaded FailoverReason = "overloaded"
|
||||||
|
FailoverUnknown FailoverReason = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FailoverError wraps an LLM provider error with classification metadata.
|
||||||
|
type FailoverError struct {
|
||||||
|
Reason FailoverReason
|
||||||
|
Provider string
|
||||||
|
Model string
|
||||||
|
Status int
|
||||||
|
Wrapped error
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolFunctionDefinition struct {
|
func (e *FailoverError) Error() string {
|
||||||
Name string `json:"name"`
|
return fmt.Sprintf("failover(%s): provider=%s model=%s status=%d: %v",
|
||||||
Description string `json:"description"`
|
e.Reason, e.Provider, e.Model, e.Status, e.Wrapped)
|
||||||
Parameters map[string]interface{} `json:"parameters"`
|
}
|
||||||
|
|
||||||
|
func (e *FailoverError) Unwrap() error {
|
||||||
|
return e.Wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRetriable returns true if this error should trigger fallback to next candidate.
|
||||||
|
// Non-retriable: Format errors (bad request structure, image dimension/size).
|
||||||
|
func (e *FailoverError) IsRetriable() bool {
|
||||||
|
return e.Reason != FailoverFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelConfig holds primary model and fallback list.
|
||||||
|
type ModelConfig struct {
|
||||||
|
Primary string
|
||||||
|
Fallbacks []string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
66
pkg/routing/agent_id.go
Normal file
66
pkg/routing/agent_id.go
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
package routing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultAgentID = "main"
|
||||||
|
DefaultMainKey = "main"
|
||||||
|
DefaultAccountID = "default"
|
||||||
|
MaxAgentIDLength = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
validIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
|
||||||
|
invalidCharsRe = regexp.MustCompile(`[^a-z0-9_-]+`)
|
||||||
|
leadingDashRe = regexp.MustCompile(`^-+`)
|
||||||
|
trailingDashRe = regexp.MustCompile(`-+$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeAgentID sanitizes an agent ID to [a-z0-9][a-z0-9_-]{0,63}.
|
||||||
|
// Invalid characters are collapsed to "-". Leading/trailing dashes stripped.
|
||||||
|
// Empty input returns DefaultAgentID ("main").
|
||||||
|
func NormalizeAgentID(id string) string {
|
||||||
|
trimmed := strings.TrimSpace(id)
|
||||||
|
if trimmed == "" {
|
||||||
|
return DefaultAgentID
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
if validIDRe.MatchString(lower) {
|
||||||
|
return lower
|
||||||
|
}
|
||||||
|
result := invalidCharsRe.ReplaceAllString(lower, "-")
|
||||||
|
result = leadingDashRe.ReplaceAllString(result, "")
|
||||||
|
result = trailingDashRe.ReplaceAllString(result, "")
|
||||||
|
if len(result) > MaxAgentIDLength {
|
||||||
|
result = result[:MaxAgentIDLength]
|
||||||
|
}
|
||||||
|
if result == "" {
|
||||||
|
return DefaultAgentID
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeAccountID sanitizes an account ID. Empty returns DefaultAccountID.
|
||||||
|
func NormalizeAccountID(id string) string {
|
||||||
|
trimmed := strings.TrimSpace(id)
|
||||||
|
if trimmed == "" {
|
||||||
|
return DefaultAccountID
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
if validIDRe.MatchString(lower) {
|
||||||
|
return lower
|
||||||
|
}
|
||||||
|
result := invalidCharsRe.ReplaceAllString(lower, "-")
|
||||||
|
result = leadingDashRe.ReplaceAllString(result, "")
|
||||||
|
result = trailingDashRe.ReplaceAllString(result, "")
|
||||||
|
if len(result) > MaxAgentIDLength {
|
||||||
|
result = result[:MaxAgentIDLength]
|
||||||
|
}
|
||||||
|
if result == "" {
|
||||||
|
return DefaultAccountID
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
86
pkg/routing/agent_id_test.go
Normal file
86
pkg/routing/agent_id_test.go
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
package routing
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeAgentID_Empty(t *testing.T) {
|
||||||
|
if got := NormalizeAgentID(""); got != DefaultAgentID {
|
||||||
|
t.Errorf("NormalizeAgentID('') = %q, want %q", got, DefaultAgentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeAgentID_Whitespace(t *testing.T) {
|
||||||
|
if got := NormalizeAgentID(" "); got != DefaultAgentID {
|
||||||
|
t.Errorf("NormalizeAgentID(' ') = %q, want %q", got, DefaultAgentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeAgentID_Valid(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input, want string
|
||||||
|
}{
|
||||||
|
{"main", "main"},
|
||||||
|
{"Main", "main"},
|
||||||
|
{"SALES", "sales"},
|
||||||
|
{"support-bot", "support-bot"},
|
||||||
|
{"agent_1", "agent_1"},
|
||||||
|
{"a", "a"},
|
||||||
|
{"0test", "0test"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := NormalizeAgentID(tt.input); got != tt.want {
|
||||||
|
t.Errorf("NormalizeAgentID(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeAgentID_InvalidChars(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input, want string
|
||||||
|
}{
|
||||||
|
{"Hello World", "hello-world"},
|
||||||
|
{"agent@123", "agent-123"},
|
||||||
|
{"foo.bar.baz", "foo-bar-baz"},
|
||||||
|
{"--leading", "leading"},
|
||||||
|
{"--both--", "both"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := NormalizeAgentID(tt.input); got != tt.want {
|
||||||
|
t.Errorf("NormalizeAgentID(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeAgentID_AllInvalid(t *testing.T) {
|
||||||
|
if got := NormalizeAgentID("@@@"); got != DefaultAgentID {
|
||||||
|
t.Errorf("NormalizeAgentID('@@@') = %q, want %q", got, DefaultAgentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeAgentID_TruncatesAt64(t *testing.T) {
|
||||||
|
long := ""
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
long += "a"
|
||||||
|
}
|
||||||
|
got := NormalizeAgentID(long)
|
||||||
|
if len(got) > MaxAgentIDLength {
|
||||||
|
t.Errorf("length = %d, want <= %d", len(got), MaxAgentIDLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeAccountID_Empty(t *testing.T) {
|
||||||
|
if got := NormalizeAccountID(""); got != DefaultAccountID {
|
||||||
|
t.Errorf("NormalizeAccountID('') = %q, want %q", got, DefaultAccountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeAccountID_Valid(t *testing.T) {
|
||||||
|
if got := NormalizeAccountID("MyBot"); got != "mybot" {
|
||||||
|
t.Errorf("NormalizeAccountID('MyBot') = %q, want 'mybot'", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeAccountID_InvalidChars(t *testing.T) {
|
||||||
|
if got := NormalizeAccountID("bot@home"); got != "bot-home" {
|
||||||
|
t.Errorf("NormalizeAccountID('bot@home') = %q, want 'bot-home'", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
252
pkg/routing/route.go
Normal file
252
pkg/routing/route.go
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
package routing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RouteInput contains the routing context from an inbound message.
|
||||||
|
type RouteInput struct {
|
||||||
|
Channel string
|
||||||
|
AccountID string
|
||||||
|
Peer *RoutePeer
|
||||||
|
ParentPeer *RoutePeer
|
||||||
|
GuildID string
|
||||||
|
TeamID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvedRoute is the result of agent routing.
|
||||||
|
type ResolvedRoute struct {
|
||||||
|
AgentID string
|
||||||
|
Channel string
|
||||||
|
AccountID string
|
||||||
|
SessionKey string
|
||||||
|
MainSessionKey string
|
||||||
|
MatchedBy string // "binding.peer", "binding.peer.parent", "binding.guild", "binding.team", "binding.account", "binding.channel", "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteResolver determines which agent handles a message based on config bindings.
|
||||||
|
type RouteResolver struct {
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRouteResolver creates a new route resolver.
|
||||||
|
func NewRouteResolver(cfg *config.Config) *RouteResolver {
|
||||||
|
return &RouteResolver{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveRoute determines which agent handles the message and constructs session keys.
|
||||||
|
// Implements the 7-level priority cascade:
|
||||||
|
// peer > parent_peer > guild > team > account > channel_wildcard > default
|
||||||
|
func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {
|
||||||
|
channel := strings.ToLower(strings.TrimSpace(input.Channel))
|
||||||
|
accountID := NormalizeAccountID(input.AccountID)
|
||||||
|
peer := input.Peer
|
||||||
|
|
||||||
|
dmScope := DMScope(r.cfg.Session.DMScope)
|
||||||
|
if dmScope == "" {
|
||||||
|
dmScope = DMScopeMain
|
||||||
|
}
|
||||||
|
identityLinks := r.cfg.Session.IdentityLinks
|
||||||
|
|
||||||
|
bindings := r.filterBindings(channel, accountID)
|
||||||
|
|
||||||
|
choose := func(agentID string, matchedBy string) ResolvedRoute {
|
||||||
|
resolvedAgentID := r.pickAgentID(agentID)
|
||||||
|
sessionKey := strings.ToLower(BuildAgentPeerSessionKey(SessionKeyParams{
|
||||||
|
AgentID: resolvedAgentID,
|
||||||
|
Channel: channel,
|
||||||
|
AccountID: accountID,
|
||||||
|
Peer: peer,
|
||||||
|
DMScope: dmScope,
|
||||||
|
IdentityLinks: identityLinks,
|
||||||
|
}))
|
||||||
|
mainSessionKey := strings.ToLower(BuildAgentMainSessionKey(resolvedAgentID))
|
||||||
|
return ResolvedRoute{
|
||||||
|
AgentID: resolvedAgentID,
|
||||||
|
Channel: channel,
|
||||||
|
AccountID: accountID,
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
MainSessionKey: mainSessionKey,
|
||||||
|
MatchedBy: matchedBy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 1: Peer binding
|
||||||
|
if peer != nil && strings.TrimSpace(peer.ID) != "" {
|
||||||
|
if match := r.findPeerMatch(bindings, peer); match != nil {
|
||||||
|
return choose(match.AgentID, "binding.peer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Parent peer binding
|
||||||
|
parentPeer := input.ParentPeer
|
||||||
|
if parentPeer != nil && strings.TrimSpace(parentPeer.ID) != "" {
|
||||||
|
if match := r.findPeerMatch(bindings, parentPeer); match != nil {
|
||||||
|
return choose(match.AgentID, "binding.peer.parent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Guild binding
|
||||||
|
guildID := strings.TrimSpace(input.GuildID)
|
||||||
|
if guildID != "" {
|
||||||
|
if match := r.findGuildMatch(bindings, guildID); match != nil {
|
||||||
|
return choose(match.AgentID, "binding.guild")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 4: Team binding
|
||||||
|
teamID := strings.TrimSpace(input.TeamID)
|
||||||
|
if teamID != "" {
|
||||||
|
if match := r.findTeamMatch(bindings, teamID); match != nil {
|
||||||
|
return choose(match.AgentID, "binding.team")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 5: Account binding
|
||||||
|
if match := r.findAccountMatch(bindings); match != nil {
|
||||||
|
return choose(match.AgentID, "binding.account")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 6: Channel wildcard binding
|
||||||
|
if match := r.findChannelWildcardMatch(bindings); match != nil {
|
||||||
|
return choose(match.AgentID, "binding.channel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 7: Default agent
|
||||||
|
return choose(r.resolveDefaultAgentID(), "default")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RouteResolver) filterBindings(channel, accountID string) []config.AgentBinding {
|
||||||
|
var filtered []config.AgentBinding
|
||||||
|
for _, b := range r.cfg.Bindings {
|
||||||
|
matchChannel := strings.ToLower(strings.TrimSpace(b.Match.Channel))
|
||||||
|
if matchChannel == "" || matchChannel != channel {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchesAccountID(b.Match.AccountID, accountID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, b)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesAccountID(matchAccountID, actual string) bool {
|
||||||
|
trimmed := strings.TrimSpace(matchAccountID)
|
||||||
|
if trimmed == "" {
|
||||||
|
return actual == DefaultAccountID
|
||||||
|
}
|
||||||
|
if trimmed == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.ToLower(trimmed) == strings.ToLower(actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RouteResolver) findPeerMatch(bindings []config.AgentBinding, peer *RoutePeer) *config.AgentBinding {
|
||||||
|
for i := range bindings {
|
||||||
|
b := &bindings[i]
|
||||||
|
if b.Match.Peer == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
peerKind := strings.ToLower(strings.TrimSpace(b.Match.Peer.Kind))
|
||||||
|
peerID := strings.TrimSpace(b.Match.Peer.ID)
|
||||||
|
if peerKind == "" || peerID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if peerKind == strings.ToLower(peer.Kind) && peerID == peer.ID {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RouteResolver) findGuildMatch(bindings []config.AgentBinding, guildID string) *config.AgentBinding {
|
||||||
|
for i := range bindings {
|
||||||
|
b := &bindings[i]
|
||||||
|
matchGuild := strings.TrimSpace(b.Match.GuildID)
|
||||||
|
if matchGuild != "" && matchGuild == guildID {
|
||||||
|
return &bindings[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RouteResolver) findTeamMatch(bindings []config.AgentBinding, teamID string) *config.AgentBinding {
|
||||||
|
for i := range bindings {
|
||||||
|
b := &bindings[i]
|
||||||
|
matchTeam := strings.TrimSpace(b.Match.TeamID)
|
||||||
|
if matchTeam != "" && matchTeam == teamID {
|
||||||
|
return &bindings[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RouteResolver) findAccountMatch(bindings []config.AgentBinding) *config.AgentBinding {
|
||||||
|
for i := range bindings {
|
||||||
|
b := &bindings[i]
|
||||||
|
accountID := strings.TrimSpace(b.Match.AccountID)
|
||||||
|
if accountID == "*" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Match.Peer != nil || b.Match.GuildID != "" || b.Match.TeamID != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return &bindings[i]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RouteResolver) findChannelWildcardMatch(bindings []config.AgentBinding) *config.AgentBinding {
|
||||||
|
for i := range bindings {
|
||||||
|
b := &bindings[i]
|
||||||
|
accountID := strings.TrimSpace(b.Match.AccountID)
|
||||||
|
if accountID != "*" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Match.Peer != nil || b.Match.GuildID != "" || b.Match.TeamID != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return &bindings[i]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RouteResolver) pickAgentID(agentID string) string {
|
||||||
|
trimmed := strings.TrimSpace(agentID)
|
||||||
|
if trimmed == "" {
|
||||||
|
return NormalizeAgentID(r.resolveDefaultAgentID())
|
||||||
|
}
|
||||||
|
normalized := NormalizeAgentID(trimmed)
|
||||||
|
agents := r.cfg.Agents.List
|
||||||
|
if len(agents) == 0 {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
for _, a := range agents {
|
||||||
|
if NormalizeAgentID(a.ID) == normalized {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NormalizeAgentID(r.resolveDefaultAgentID())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RouteResolver) resolveDefaultAgentID() string {
|
||||||
|
agents := r.cfg.Agents.List
|
||||||
|
if len(agents) == 0 {
|
||||||
|
return DefaultAgentID
|
||||||
|
}
|
||||||
|
for _, a := range agents {
|
||||||
|
if a.Default {
|
||||||
|
id := strings.TrimSpace(a.ID)
|
||||||
|
if id != "" {
|
||||||
|
return NormalizeAgentID(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id := strings.TrimSpace(agents[0].ID); id != "" {
|
||||||
|
return NormalizeAgentID(id)
|
||||||
|
}
|
||||||
|
return DefaultAgentID
|
||||||
|
}
|
||||||
297
pkg/routing/route_test.go
Normal file
297
pkg/routing/route_test.go
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
package routing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testConfig(agents []config.AgentConfig, bindings []config.AgentBinding) *config.Config {
|
||||||
|
return &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: "/tmp/picoclaw-test",
|
||||||
|
Model: "gpt-4",
|
||||||
|
},
|
||||||
|
List: agents,
|
||||||
|
},
|
||||||
|
Bindings: bindings,
|
||||||
|
Session: config.SessionConfig{
|
||||||
|
DMScope: "per-peer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRoute_DefaultAgent_NoBindings(t *testing.T) {
|
||||||
|
cfg := testConfig(nil, nil)
|
||||||
|
r := NewRouteResolver(cfg)
|
||||||
|
|
||||||
|
route := r.ResolveRoute(RouteInput{
|
||||||
|
Channel: "telegram",
|
||||||
|
Peer: &RoutePeer{Kind: "direct", ID: "user1"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if route.AgentID != DefaultAgentID {
|
||||||
|
t.Errorf("AgentID = %q, want %q", route.AgentID, DefaultAgentID)
|
||||||
|
}
|
||||||
|
if route.MatchedBy != "default" {
|
||||||
|
t.Errorf("MatchedBy = %q, want 'default'", route.MatchedBy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRoute_PeerBinding(t *testing.T) {
|
||||||
|
agents := []config.AgentConfig{
|
||||||
|
{ID: "sales", Default: true},
|
||||||
|
{ID: "support"},
|
||||||
|
}
|
||||||
|
bindings := []config.AgentBinding{
|
||||||
|
{
|
||||||
|
AgentID: "support",
|
||||||
|
Match: config.BindingMatch{
|
||||||
|
Channel: "telegram",
|
||||||
|
AccountID: "*",
|
||||||
|
Peer: &config.PeerMatch{Kind: "direct", ID: "user123"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg := testConfig(agents, bindings)
|
||||||
|
r := NewRouteResolver(cfg)
|
||||||
|
|
||||||
|
route := r.ResolveRoute(RouteInput{
|
||||||
|
Channel: "telegram",
|
||||||
|
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if route.AgentID != "support" {
|
||||||
|
t.Errorf("AgentID = %q, want 'support'", route.AgentID)
|
||||||
|
}
|
||||||
|
if route.MatchedBy != "binding.peer" {
|
||||||
|
t.Errorf("MatchedBy = %q, want 'binding.peer'", route.MatchedBy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRoute_GuildBinding(t *testing.T) {
|
||||||
|
agents := []config.AgentConfig{
|
||||||
|
{ID: "general", Default: true},
|
||||||
|
{ID: "gaming"},
|
||||||
|
}
|
||||||
|
bindings := []config.AgentBinding{
|
||||||
|
{
|
||||||
|
AgentID: "gaming",
|
||||||
|
Match: config.BindingMatch{
|
||||||
|
Channel: "discord",
|
||||||
|
AccountID: "*",
|
||||||
|
GuildID: "guild-abc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg := testConfig(agents, bindings)
|
||||||
|
r := NewRouteResolver(cfg)
|
||||||
|
|
||||||
|
route := r.ResolveRoute(RouteInput{
|
||||||
|
Channel: "discord",
|
||||||
|
GuildID: "guild-abc",
|
||||||
|
Peer: &RoutePeer{Kind: "channel", ID: "ch1"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if route.AgentID != "gaming" {
|
||||||
|
t.Errorf("AgentID = %q, want 'gaming'", route.AgentID)
|
||||||
|
}
|
||||||
|
if route.MatchedBy != "binding.guild" {
|
||||||
|
t.Errorf("MatchedBy = %q, want 'binding.guild'", route.MatchedBy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRoute_TeamBinding(t *testing.T) {
|
||||||
|
agents := []config.AgentConfig{
|
||||||
|
{ID: "general", Default: true},
|
||||||
|
{ID: "work"},
|
||||||
|
}
|
||||||
|
bindings := []config.AgentBinding{
|
||||||
|
{
|
||||||
|
AgentID: "work",
|
||||||
|
Match: config.BindingMatch{
|
||||||
|
Channel: "slack",
|
||||||
|
AccountID: "*",
|
||||||
|
TeamID: "T12345",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg := testConfig(agents, bindings)
|
||||||
|
r := NewRouteResolver(cfg)
|
||||||
|
|
||||||
|
route := r.ResolveRoute(RouteInput{
|
||||||
|
Channel: "slack",
|
||||||
|
TeamID: "T12345",
|
||||||
|
Peer: &RoutePeer{Kind: "channel", ID: "C001"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if route.AgentID != "work" {
|
||||||
|
t.Errorf("AgentID = %q, want 'work'", route.AgentID)
|
||||||
|
}
|
||||||
|
if route.MatchedBy != "binding.team" {
|
||||||
|
t.Errorf("MatchedBy = %q, want 'binding.team'", route.MatchedBy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRoute_AccountBinding(t *testing.T) {
|
||||||
|
agents := []config.AgentConfig{
|
||||||
|
{ID: "default-agent", Default: true},
|
||||||
|
{ID: "premium"},
|
||||||
|
}
|
||||||
|
bindings := []config.AgentBinding{
|
||||||
|
{
|
||||||
|
AgentID: "premium",
|
||||||
|
Match: config.BindingMatch{
|
||||||
|
Channel: "telegram",
|
||||||
|
AccountID: "bot2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg := testConfig(agents, bindings)
|
||||||
|
r := NewRouteResolver(cfg)
|
||||||
|
|
||||||
|
route := r.ResolveRoute(RouteInput{
|
||||||
|
Channel: "telegram",
|
||||||
|
AccountID: "bot2",
|
||||||
|
Peer: &RoutePeer{Kind: "direct", ID: "user1"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if route.AgentID != "premium" {
|
||||||
|
t.Errorf("AgentID = %q, want 'premium'", route.AgentID)
|
||||||
|
}
|
||||||
|
if route.MatchedBy != "binding.account" {
|
||||||
|
t.Errorf("MatchedBy = %q, want 'binding.account'", route.MatchedBy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRoute_ChannelWildcard(t *testing.T) {
|
||||||
|
agents := []config.AgentConfig{
|
||||||
|
{ID: "main", Default: true},
|
||||||
|
{ID: "telegram-bot"},
|
||||||
|
}
|
||||||
|
bindings := []config.AgentBinding{
|
||||||
|
{
|
||||||
|
AgentID: "telegram-bot",
|
||||||
|
Match: config.BindingMatch{
|
||||||
|
Channel: "telegram",
|
||||||
|
AccountID: "*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg := testConfig(agents, bindings)
|
||||||
|
r := NewRouteResolver(cfg)
|
||||||
|
|
||||||
|
route := r.ResolveRoute(RouteInput{
|
||||||
|
Channel: "telegram",
|
||||||
|
Peer: &RoutePeer{Kind: "direct", ID: "user1"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if route.AgentID != "telegram-bot" {
|
||||||
|
t.Errorf("AgentID = %q, want 'telegram-bot'", route.AgentID)
|
||||||
|
}
|
||||||
|
if route.MatchedBy != "binding.channel" {
|
||||||
|
t.Errorf("MatchedBy = %q, want 'binding.channel'", route.MatchedBy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRoute_PriorityOrder_PeerBeatsGuild(t *testing.T) {
|
||||||
|
agents := []config.AgentConfig{
|
||||||
|
{ID: "general", Default: true},
|
||||||
|
{ID: "vip"},
|
||||||
|
{ID: "gaming"},
|
||||||
|
}
|
||||||
|
bindings := []config.AgentBinding{
|
||||||
|
{
|
||||||
|
AgentID: "vip",
|
||||||
|
Match: config.BindingMatch{
|
||||||
|
Channel: "discord",
|
||||||
|
AccountID: "*",
|
||||||
|
Peer: &config.PeerMatch{Kind: "direct", ID: "user-vip"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AgentID: "gaming",
|
||||||
|
Match: config.BindingMatch{
|
||||||
|
Channel: "discord",
|
||||||
|
AccountID: "*",
|
||||||
|
GuildID: "guild-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg := testConfig(agents, bindings)
|
||||||
|
r := NewRouteResolver(cfg)
|
||||||
|
|
||||||
|
route := r.ResolveRoute(RouteInput{
|
||||||
|
Channel: "discord",
|
||||||
|
GuildID: "guild-1",
|
||||||
|
Peer: &RoutePeer{Kind: "direct", ID: "user-vip"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if route.AgentID != "vip" {
|
||||||
|
t.Errorf("AgentID = %q, want 'vip' (peer should beat guild)", route.AgentID)
|
||||||
|
}
|
||||||
|
if route.MatchedBy != "binding.peer" {
|
||||||
|
t.Errorf("MatchedBy = %q, want 'binding.peer'", route.MatchedBy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRoute_InvalidAgentFallsToDefault(t *testing.T) {
|
||||||
|
agents := []config.AgentConfig{
|
||||||
|
{ID: "main", Default: true},
|
||||||
|
}
|
||||||
|
bindings := []config.AgentBinding{
|
||||||
|
{
|
||||||
|
AgentID: "nonexistent",
|
||||||
|
Match: config.BindingMatch{
|
||||||
|
Channel: "telegram",
|
||||||
|
AccountID: "*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg := testConfig(agents, bindings)
|
||||||
|
r := NewRouteResolver(cfg)
|
||||||
|
|
||||||
|
route := r.ResolveRoute(RouteInput{
|
||||||
|
Channel: "telegram",
|
||||||
|
})
|
||||||
|
|
||||||
|
if route.AgentID != "main" {
|
||||||
|
t.Errorf("AgentID = %q, want 'main' (invalid agent should fall to default)", route.AgentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRoute_DefaultAgentSelection(t *testing.T) {
|
||||||
|
agents := []config.AgentConfig{
|
||||||
|
{ID: "alpha"},
|
||||||
|
{ID: "beta", Default: true},
|
||||||
|
{ID: "gamma"},
|
||||||
|
}
|
||||||
|
cfg := testConfig(agents, nil)
|
||||||
|
r := NewRouteResolver(cfg)
|
||||||
|
|
||||||
|
route := r.ResolveRoute(RouteInput{
|
||||||
|
Channel: "cli",
|
||||||
|
})
|
||||||
|
|
||||||
|
if route.AgentID != "beta" {
|
||||||
|
t.Errorf("AgentID = %q, want 'beta' (marked as default)", route.AgentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRoute_NoDefaultUsesFirst(t *testing.T) {
|
||||||
|
agents := []config.AgentConfig{
|
||||||
|
{ID: "alpha"},
|
||||||
|
{ID: "beta"},
|
||||||
|
}
|
||||||
|
cfg := testConfig(agents, nil)
|
||||||
|
r := NewRouteResolver(cfg)
|
||||||
|
|
||||||
|
route := r.ResolveRoute(RouteInput{
|
||||||
|
Channel: "cli",
|
||||||
|
})
|
||||||
|
|
||||||
|
if route.AgentID != "alpha" {
|
||||||
|
t.Errorf("AgentID = %q, want 'alpha' (first in list)", route.AgentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
183
pkg/routing/session_key.go
Normal file
183
pkg/routing/session_key.go
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
package routing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DMScope controls DM session isolation granularity.
|
||||||
|
type DMScope string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DMScopeMain DMScope = "main"
|
||||||
|
DMScopePerPeer DMScope = "per-peer"
|
||||||
|
DMScopePerChannelPeer DMScope = "per-channel-peer"
|
||||||
|
DMScopePerAccountChannelPeer DMScope = "per-account-channel-peer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RoutePeer represents a chat peer with kind and ID.
|
||||||
|
type RoutePeer struct {
|
||||||
|
Kind string // "direct", "group", "channel"
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionKeyParams holds all inputs for session key construction.
|
||||||
|
type SessionKeyParams struct {
|
||||||
|
AgentID string
|
||||||
|
Channel string
|
||||||
|
AccountID string
|
||||||
|
Peer *RoutePeer
|
||||||
|
DMScope DMScope
|
||||||
|
IdentityLinks map[string][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedSessionKey is the result of parsing an agent-scoped session key.
|
||||||
|
type ParsedSessionKey struct {
|
||||||
|
AgentID string
|
||||||
|
Rest string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAgentMainSessionKey returns "agent:<agentId>:main".
|
||||||
|
func BuildAgentMainSessionKey(agentID string) string {
|
||||||
|
return fmt.Sprintf("agent:%s:%s", NormalizeAgentID(agentID), DefaultMainKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAgentPeerSessionKey constructs a session key based on agent, channel, peer, and DM scope.
|
||||||
|
func BuildAgentPeerSessionKey(params SessionKeyParams) string {
|
||||||
|
agentID := NormalizeAgentID(params.AgentID)
|
||||||
|
|
||||||
|
peer := params.Peer
|
||||||
|
if peer == nil {
|
||||||
|
peer = &RoutePeer{Kind: "direct"}
|
||||||
|
}
|
||||||
|
peerKind := strings.TrimSpace(peer.Kind)
|
||||||
|
if peerKind == "" {
|
||||||
|
peerKind = "direct"
|
||||||
|
}
|
||||||
|
|
||||||
|
if peerKind == "direct" {
|
||||||
|
dmScope := params.DMScope
|
||||||
|
if dmScope == "" {
|
||||||
|
dmScope = DMScopeMain
|
||||||
|
}
|
||||||
|
peerID := strings.TrimSpace(peer.ID)
|
||||||
|
|
||||||
|
// Resolve identity links (cross-platform collapse)
|
||||||
|
if dmScope != DMScopeMain && peerID != "" {
|
||||||
|
if linked := resolveLinkedPeerID(params.IdentityLinks, params.Channel, peerID); linked != "" {
|
||||||
|
peerID = linked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
peerID = strings.ToLower(peerID)
|
||||||
|
|
||||||
|
switch dmScope {
|
||||||
|
case DMScopePerAccountChannelPeer:
|
||||||
|
if peerID != "" {
|
||||||
|
channel := normalizeChannel(params.Channel)
|
||||||
|
accountID := NormalizeAccountID(params.AccountID)
|
||||||
|
return fmt.Sprintf("agent:%s:%s:%s:direct:%s", agentID, channel, accountID, peerID)
|
||||||
|
}
|
||||||
|
case DMScopePerChannelPeer:
|
||||||
|
if peerID != "" {
|
||||||
|
channel := normalizeChannel(params.Channel)
|
||||||
|
return fmt.Sprintf("agent:%s:%s:direct:%s", agentID, channel, peerID)
|
||||||
|
}
|
||||||
|
case DMScopePerPeer:
|
||||||
|
if peerID != "" {
|
||||||
|
return fmt.Sprintf("agent:%s:direct:%s", agentID, peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return BuildAgentMainSessionKey(agentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group/channel peers always get per-peer sessions
|
||||||
|
channel := normalizeChannel(params.Channel)
|
||||||
|
peerID := strings.ToLower(strings.TrimSpace(peer.ID))
|
||||||
|
if peerID == "" {
|
||||||
|
peerID = "unknown"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("agent:%s:%s:%s:%s", agentID, channel, peerKind, peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAgentSessionKey extracts agentId and rest from "agent:<agentId>:<rest>".
|
||||||
|
func ParseAgentSessionKey(sessionKey string) *ParsedSessionKey {
|
||||||
|
raw := strings.TrimSpace(sessionKey)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(raw, ":", 3)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if parts[0] != "agent" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
agentID := strings.TrimSpace(parts[1])
|
||||||
|
rest := parts[2]
|
||||||
|
if agentID == "" || rest == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &ParsedSessionKey{AgentID: agentID, Rest: rest}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSubagentSessionKey returns true if the session key represents a subagent.
|
||||||
|
func IsSubagentSessionKey(sessionKey string) bool {
|
||||||
|
raw := strings.TrimSpace(sessionKey)
|
||||||
|
if raw == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.ToLower(raw), "subagent:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
parsed := ParseAgentSessionKey(raw)
|
||||||
|
if parsed == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(strings.ToLower(parsed.Rest), "subagent:")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeChannel(channel string) string {
|
||||||
|
c := strings.TrimSpace(strings.ToLower(channel))
|
||||||
|
if c == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID string) string {
|
||||||
|
if len(identityLinks) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
peerID = strings.TrimSpace(peerID)
|
||||||
|
if peerID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := make(map[string]bool)
|
||||||
|
rawCandidate := strings.ToLower(peerID)
|
||||||
|
if rawCandidate != "" {
|
||||||
|
candidates[rawCandidate] = true
|
||||||
|
}
|
||||||
|
channel = strings.ToLower(strings.TrimSpace(channel))
|
||||||
|
if channel != "" {
|
||||||
|
scopedCandidate := fmt.Sprintf("%s:%s", channel, strings.ToLower(peerID))
|
||||||
|
candidates[scopedCandidate] = true
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for canonical, ids := range identityLinks {
|
||||||
|
canonicalName := strings.TrimSpace(canonical)
|
||||||
|
if canonicalName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(id))
|
||||||
|
if normalized != "" && candidates[normalized] {
|
||||||
|
return canonicalName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
162
pkg/routing/session_key_test.go
Normal file
162
pkg/routing/session_key_test.go
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
package routing
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildAgentMainSessionKey(t *testing.T) {
|
||||||
|
got := BuildAgentMainSessionKey("sales")
|
||||||
|
want := "agent:sales:main"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("BuildAgentMainSessionKey('sales') = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAgentMainSessionKey_Normalizes(t *testing.T) {
|
||||||
|
got := BuildAgentMainSessionKey("Sales Bot")
|
||||||
|
want := "agent:sales-bot:main"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("BuildAgentMainSessionKey('Sales Bot') = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAgentPeerSessionKey_DMScopeMain(t *testing.T) {
|
||||||
|
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||||
|
AgentID: "main",
|
||||||
|
Channel: "telegram",
|
||||||
|
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
|
||||||
|
DMScope: DMScopeMain,
|
||||||
|
})
|
||||||
|
want := "agent:main:main"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("DMScopeMain = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAgentPeerSessionKey_DMScopePerPeer(t *testing.T) {
|
||||||
|
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||||
|
AgentID: "main",
|
||||||
|
Channel: "telegram",
|
||||||
|
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
|
||||||
|
DMScope: DMScopePerPeer,
|
||||||
|
})
|
||||||
|
want := "agent:main:direct:user123"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("DMScopePerPeer = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAgentPeerSessionKey_DMScopePerChannelPeer(t *testing.T) {
|
||||||
|
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||||
|
AgentID: "main",
|
||||||
|
Channel: "telegram",
|
||||||
|
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
|
||||||
|
DMScope: DMScopePerChannelPeer,
|
||||||
|
})
|
||||||
|
want := "agent:main:telegram:direct:user123"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("DMScopePerChannelPeer = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAgentPeerSessionKey_DMScopePerAccountChannelPeer(t *testing.T) {
|
||||||
|
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||||
|
AgentID: "main",
|
||||||
|
Channel: "telegram",
|
||||||
|
AccountID: "bot1",
|
||||||
|
Peer: &RoutePeer{Kind: "direct", ID: "User123"},
|
||||||
|
DMScope: DMScopePerAccountChannelPeer,
|
||||||
|
})
|
||||||
|
want := "agent:main:telegram:bot1:direct:user123"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("DMScopePerAccountChannelPeer = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAgentPeerSessionKey_GroupPeer(t *testing.T) {
|
||||||
|
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||||
|
AgentID: "main",
|
||||||
|
Channel: "telegram",
|
||||||
|
Peer: &RoutePeer{Kind: "group", ID: "chat456"},
|
||||||
|
DMScope: DMScopePerPeer,
|
||||||
|
})
|
||||||
|
want := "agent:main:telegram:group:chat456"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("GroupPeer = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAgentPeerSessionKey_NilPeer(t *testing.T) {
|
||||||
|
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||||
|
AgentID: "main",
|
||||||
|
Channel: "telegram",
|
||||||
|
Peer: nil,
|
||||||
|
DMScope: DMScopePerPeer,
|
||||||
|
})
|
||||||
|
// nil peer defaults to direct with empty ID, falls to main
|
||||||
|
want := "agent:main:main"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("NilPeer = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAgentPeerSessionKey_IdentityLink(t *testing.T) {
|
||||||
|
links := map[string][]string{
|
||||||
|
"john": {"telegram:user123", "discord:john#1234"},
|
||||||
|
}
|
||||||
|
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||||
|
AgentID: "main",
|
||||||
|
Channel: "telegram",
|
||||||
|
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
|
||||||
|
DMScope: DMScopePerPeer,
|
||||||
|
IdentityLinks: links,
|
||||||
|
})
|
||||||
|
want := "agent:main:direct:john"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("IdentityLink = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAgentSessionKey_Valid(t *testing.T) {
|
||||||
|
parsed := ParseAgentSessionKey("agent:sales:telegram:direct:user123")
|
||||||
|
if parsed == nil {
|
||||||
|
t.Fatal("expected non-nil result")
|
||||||
|
}
|
||||||
|
if parsed.AgentID != "sales" {
|
||||||
|
t.Errorf("AgentID = %q, want 'sales'", parsed.AgentID)
|
||||||
|
}
|
||||||
|
if parsed.Rest != "telegram:direct:user123" {
|
||||||
|
t.Errorf("Rest = %q, want 'telegram:direct:user123'", parsed.Rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAgentSessionKey_Invalid(t *testing.T) {
|
||||||
|
tests := []string{
|
||||||
|
"",
|
||||||
|
"foo:bar",
|
||||||
|
"notprefix:sales:main",
|
||||||
|
"agent::main",
|
||||||
|
"agent:sales:",
|
||||||
|
}
|
||||||
|
for _, input := range tests {
|
||||||
|
if got := ParseAgentSessionKey(input); got != nil {
|
||||||
|
t.Errorf("ParseAgentSessionKey(%q) = %+v, want nil", input, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSubagentSessionKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"subagent:task-1", true},
|
||||||
|
{"agent:main:subagent:task-1", true},
|
||||||
|
{"agent:main:main", false},
|
||||||
|
{"agent:main:telegram:direct:user123", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := IsSubagentSessionKey(tt.input); got != tt.want {
|
||||||
|
t.Errorf("IsSubagentSessionKey(%q) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
|
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
|
||||||
|
|
@ -251,6 +253,11 @@ func (sl *SkillsLoader) BuildSkillsSummary() string {
|
||||||
func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
|
func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
|
||||||
content, err := os.ReadFile(skillPath)
|
content, err := os.ReadFile(skillPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.WarnCF("skills", "Failed to read skill metadata",
|
||||||
|
map[string]interface{}{
|
||||||
|
"skill_path": skillPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -283,10 +290,15 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
|
||||||
|
|
||||||
// parseSimpleYAML parses simple key: value YAML format
|
// parseSimpleYAML parses simple key: value YAML format
|
||||||
// Example: name: github\n description: "..."
|
// Example: name: github\n description: "..."
|
||||||
|
// Normalizes line endings to handle \n (Unix), \r\n (Windows), and \r (classic Mac)
|
||||||
func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
|
func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
|
||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
|
|
||||||
for _, line := range strings.Split(content, "\n") {
|
// Normalize line endings: convert \r\n and \r to \n
|
||||||
|
normalized := strings.ReplaceAll(content, "\r\n", "\n")
|
||||||
|
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
||||||
|
|
||||||
|
for _, line := range strings.Split(normalized, "\n") {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
continue
|
continue
|
||||||
|
|
@ -306,9 +318,10 @@ func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sl *SkillsLoader) extractFrontmatter(content string) string {
|
func (sl *SkillsLoader) extractFrontmatter(content string) string {
|
||||||
// (?s) enables DOTALL mode so . matches newlines
|
// Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
|
||||||
// Match first ---, capture everything until next --- on its own line
|
// (?s) enables DOTALL so . matches newlines;
|
||||||
re := regexp.MustCompile(`(?s)^---\n(.*)\n---`)
|
// ^--- at start, then ... --- at start of line, honoring all three line ending types
|
||||||
|
re := regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`)
|
||||||
match := re.FindStringSubmatch(content)
|
match := re.FindStringSubmatch(content)
|
||||||
if len(match) > 1 {
|
if len(match) > 1 {
|
||||||
return match[1]
|
return match[1]
|
||||||
|
|
@ -317,7 +330,11 @@ func (sl *SkillsLoader) extractFrontmatter(content string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sl *SkillsLoader) stripFrontmatter(content string) string {
|
func (sl *SkillsLoader) stripFrontmatter(content string) string {
|
||||||
re := regexp.MustCompile(`^---\n.*?\n---\n`)
|
// Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
|
||||||
|
// (?s) enables DOTALL so . matches newlines;
|
||||||
|
// ^--- at start, then ... --- at start of line, honoring all three line ending types
|
||||||
|
// Match zero or more trailing line endings after closing --- (handles both with and without blank lines)
|
||||||
|
re := regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
|
||||||
return re.ReplaceAllString(content, "")
|
return re.ReplaceAllString(content, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,3 +75,105 @@ func TestSkillsInfoValidate(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractFrontmatter(t *testing.T) {
|
||||||
|
sl := &SkillsLoader{}
|
||||||
|
|
||||||
|
testcases := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
expectedName string
|
||||||
|
expectedDesc string
|
||||||
|
lineEndingType string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unix-line-endings",
|
||||||
|
lineEndingType: "Unix (\\n)",
|
||||||
|
content: "---\nname: test-skill\ndescription: A test skill\n---\n\n# Skill Content",
|
||||||
|
expectedName: "test-skill",
|
||||||
|
expectedDesc: "A test skill",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "windows-line-endings",
|
||||||
|
lineEndingType: "Windows (\\r\\n)",
|
||||||
|
content: "---\r\nname: test-skill\r\ndescription: A test skill\r\n---\r\n\r\n# Skill Content",
|
||||||
|
expectedName: "test-skill",
|
||||||
|
expectedDesc: "A test skill",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "classic-mac-line-endings",
|
||||||
|
lineEndingType: "Classic Mac (\\r)",
|
||||||
|
content: "---\rname: test-skill\rdescription: A test skill\r---\r\r# Skill Content",
|
||||||
|
expectedName: "test-skill",
|
||||||
|
expectedDesc: "A test skill",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testcases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Extract frontmatter
|
||||||
|
frontmatter := sl.extractFrontmatter(tc.content)
|
||||||
|
assert.NotEmpty(t, frontmatter, "Frontmatter should be extracted for %s line endings", tc.lineEndingType)
|
||||||
|
|
||||||
|
// Parse YAML to get name and description (parseSimpleYAML now handles all line ending types)
|
||||||
|
yamlMeta := sl.parseSimpleYAML(frontmatter)
|
||||||
|
assert.Equal(t, tc.expectedName, yamlMeta["name"], "Name should be correctly parsed from frontmatter with %s line endings", tc.lineEndingType)
|
||||||
|
assert.Equal(t, tc.expectedDesc, yamlMeta["description"], "Description should be correctly parsed from frontmatter with %s line endings", tc.lineEndingType)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripFrontmatter(t *testing.T) {
|
||||||
|
sl := &SkillsLoader{}
|
||||||
|
|
||||||
|
testcases := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
expectedContent string
|
||||||
|
lineEndingType string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unix-line-endings",
|
||||||
|
lineEndingType: "Unix (\\n)",
|
||||||
|
content: "---\nname: test-skill\ndescription: A test skill\n---\n\n# Skill Content",
|
||||||
|
expectedContent: "# Skill Content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "windows-line-endings",
|
||||||
|
lineEndingType: "Windows (\\r\\n)",
|
||||||
|
content: "---\r\nname: test-skill\r\ndescription: A test skill\r\n---\r\n\r\n# Skill Content",
|
||||||
|
expectedContent: "# Skill Content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "classic-mac-line-endings",
|
||||||
|
lineEndingType: "Classic Mac (\\r)",
|
||||||
|
content: "---\rname: test-skill\rdescription: A test skill\r---\r\r# Skill Content",
|
||||||
|
expectedContent: "# Skill Content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unix-line-endings-without-trailing-newline",
|
||||||
|
lineEndingType: "Unix (\\n) without trailing newline",
|
||||||
|
content: "---\nname: test-skill\ndescription: A test skill\n---\n# Skill Content",
|
||||||
|
expectedContent: "# Skill Content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "windows-line-endings-without-trailing-newline",
|
||||||
|
lineEndingType: "Windows (\\r\\n) without trailing newline",
|
||||||
|
content: "---\r\nname: test-skill\r\ndescription: A test skill\r\n---\r\n# Skill Content",
|
||||||
|
expectedContent: "# Skill Content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no-frontmatter",
|
||||||
|
lineEndingType: "No frontmatter",
|
||||||
|
content: "# Skill Content\n\nSome content here.",
|
||||||
|
expectedContent: "# Skill Content\n\nSome content here.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testcases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
result := sl.stripFrontmatter(tc.content)
|
||||||
|
assert.Equal(t, tc.expectedContent, result, "Frontmatter should be stripped correctly for %s", tc.lineEndingType)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/cron"
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
@ -29,9 +30,9 @@ type CronTool struct {
|
||||||
|
|
||||||
// NewCronTool creates a new CronTool
|
// NewCronTool creates a new CronTool
|
||||||
// execTimeout: 0 means no timeout, >0 sets the timeout duration
|
// execTimeout: 0 means no timeout, >0 sets the timeout duration
|
||||||
func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *CronTool {
|
func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *CronTool {
|
||||||
execTool := NewExecTool(workspace, restrict)
|
execTool := NewExecToolWithConfig(workspace, restrict, config)
|
||||||
execTool.SetTimeout(execTimeout) // 0 means no timeout
|
execTool.SetTimeout(execTimeout)
|
||||||
return &CronTool{
|
return &CronTool{
|
||||||
cronService: cronService,
|
cronService: cronService,
|
||||||
executor: executor,
|
executor: executor,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import (
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ExecTool struct {
|
type ExecTool struct {
|
||||||
|
|
@ -21,50 +23,82 @@ type ExecTool struct {
|
||||||
restrictToWorkspace bool
|
restrictToWorkspace bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var defaultDenyPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`),
|
||||||
|
regexp.MustCompile(`\bdel\s+/[fq]\b`),
|
||||||
|
regexp.MustCompile(`\brmdir\s+/s\b`),
|
||||||
|
regexp.MustCompile(`\b(format|mkfs|diskpart)\b\s`), // Match disk wiping commands (must be followed by space/args)
|
||||||
|
regexp.MustCompile(`\bdd\s+if=`),
|
||||||
|
regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null)
|
||||||
|
regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`),
|
||||||
|
regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`),
|
||||||
|
regexp.MustCompile(`\$\([^)]+\)`),
|
||||||
|
regexp.MustCompile(`\$\{[^}]+\}`),
|
||||||
|
regexp.MustCompile("`[^`]+`"),
|
||||||
|
regexp.MustCompile(`\|\s*sh\b`),
|
||||||
|
regexp.MustCompile(`\|\s*bash\b`),
|
||||||
|
regexp.MustCompile(`;\s*rm\s+-[rf]`),
|
||||||
|
regexp.MustCompile(`&&\s*rm\s+-[rf]`),
|
||||||
|
regexp.MustCompile(`\|\|\s*rm\s+-[rf]`),
|
||||||
|
regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`),
|
||||||
|
regexp.MustCompile(`<<\s*EOF`),
|
||||||
|
regexp.MustCompile(`\$\(\s*cat\s+`),
|
||||||
|
regexp.MustCompile(`\$\(\s*curl\s+`),
|
||||||
|
regexp.MustCompile(`\$\(\s*wget\s+`),
|
||||||
|
regexp.MustCompile(`\$\(\s*which\s+`),
|
||||||
|
regexp.MustCompile(`\bsudo\b`),
|
||||||
|
regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`),
|
||||||
|
regexp.MustCompile(`\bchown\b`),
|
||||||
|
regexp.MustCompile(`\bpkill\b`),
|
||||||
|
regexp.MustCompile(`\bkillall\b`),
|
||||||
|
regexp.MustCompile(`\bkill\s+-[9]\b`),
|
||||||
|
regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`),
|
||||||
|
regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`),
|
||||||
|
regexp.MustCompile(`\bnpm\s+install\s+-g\b`),
|
||||||
|
regexp.MustCompile(`\bpip\s+install\s+--user\b`),
|
||||||
|
regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`),
|
||||||
|
regexp.MustCompile(`\byum\s+(install|remove)\b`),
|
||||||
|
regexp.MustCompile(`\bdnf\s+(install|remove)\b`),
|
||||||
|
regexp.MustCompile(`\bdocker\s+run\b`),
|
||||||
|
regexp.MustCompile(`\bdocker\s+exec\b`),
|
||||||
|
regexp.MustCompile(`\bgit\s+push\b`),
|
||||||
|
regexp.MustCompile(`\bgit\s+force\b`),
|
||||||
|
regexp.MustCompile(`\bssh\b.*@`),
|
||||||
|
regexp.MustCompile(`\beval\b`),
|
||||||
|
regexp.MustCompile(`\bsource\s+.*\.sh\b`),
|
||||||
|
}
|
||||||
|
|
||||||
func NewExecTool(workingDir string, restrict bool) *ExecTool {
|
func NewExecTool(workingDir string, restrict bool) *ExecTool {
|
||||||
denyPatterns := []*regexp.Regexp{
|
return NewExecToolWithConfig(workingDir, restrict, nil)
|
||||||
regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`),
|
}
|
||||||
regexp.MustCompile(`\bdel\s+/[fq]\b`),
|
|
||||||
regexp.MustCompile(`\brmdir\s+/s\b`),
|
func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) *ExecTool {
|
||||||
regexp.MustCompile(`\b(format|mkfs|diskpart)\b\s`), // Match disk wiping commands (must be followed by space/args)
|
denyPatterns := make([]*regexp.Regexp, 0)
|
||||||
regexp.MustCompile(`\bdd\s+if=`),
|
|
||||||
regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null)
|
enableDenyPatterns := true
|
||||||
regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`),
|
if config != nil {
|
||||||
regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`),
|
execConfig := config.Tools.Exec
|
||||||
regexp.MustCompile(`\$\([^)]+\)`),
|
enableDenyPatterns = execConfig.EnableDenyPatterns
|
||||||
regexp.MustCompile(`\$\{[^}]+\}`),
|
if enableDenyPatterns {
|
||||||
regexp.MustCompile("`[^`]+`"),
|
if len(execConfig.CustomDenyPatterns) > 0 {
|
||||||
regexp.MustCompile(`\|\s*sh\b`),
|
fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns)
|
||||||
regexp.MustCompile(`\|\s*bash\b`),
|
for _, pattern := range execConfig.CustomDenyPatterns {
|
||||||
regexp.MustCompile(`;\s*rm\s+-[rf]`),
|
re, err := regexp.Compile(pattern)
|
||||||
regexp.MustCompile(`&&\s*rm\s+-[rf]`),
|
if err != nil {
|
||||||
regexp.MustCompile(`\|\|\s*rm\s+-[rf]`),
|
fmt.Printf("Invalid custom deny pattern %q: %v\n", pattern, err)
|
||||||
regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`),
|
continue
|
||||||
regexp.MustCompile(`<<\s*EOF`),
|
}
|
||||||
regexp.MustCompile(`\$\(\s*cat\s+`),
|
denyPatterns = append(denyPatterns, re)
|
||||||
regexp.MustCompile(`\$\(\s*curl\s+`),
|
}
|
||||||
regexp.MustCompile(`\$\(\s*wget\s+`),
|
} else {
|
||||||
regexp.MustCompile(`\$\(\s*which\s+`),
|
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
||||||
regexp.MustCompile(`\bsudo\b`),
|
}
|
||||||
regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`),
|
} else {
|
||||||
regexp.MustCompile(`\bchown\b`),
|
// If deny patterns are disabled, we won't add any patterns, allowing all commands.
|
||||||
regexp.MustCompile(`\bpkill\b`),
|
fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.")
|
||||||
regexp.MustCompile(`\bkillall\b`),
|
}
|
||||||
regexp.MustCompile(`\bkill\s+-[9]\b`),
|
} else {
|
||||||
regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`),
|
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
||||||
regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`),
|
|
||||||
regexp.MustCompile(`\bnpm\s+install\s+-g\b`),
|
|
||||||
regexp.MustCompile(`\bpip\s+install\s+--user\b`),
|
|
||||||
regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`),
|
|
||||||
regexp.MustCompile(`\byum\s+(install|remove)\b`),
|
|
||||||
regexp.MustCompile(`\bdnf\s+(install|remove)\b`),
|
|
||||||
regexp.MustCompile(`\bdocker\s+run\b`),
|
|
||||||
regexp.MustCompile(`\bdocker\s+exec\b`),
|
|
||||||
regexp.MustCompile(`\bgit\s+push\b`),
|
|
||||||
regexp.MustCompile(`\bgit\s+force\b`),
|
|
||||||
regexp.MustCompile(`\bssh\b.*@`),
|
|
||||||
regexp.MustCompile(`\beval\b`),
|
|
||||||
regexp.MustCompile(`\bsource\s+.*\.sh\b`),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ExecTool{
|
return &ExecTool{
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type SpawnTool struct {
|
type SpawnTool struct {
|
||||||
manager *SubagentManager
|
manager *SubagentManager
|
||||||
originChannel string
|
originChannel string
|
||||||
originChatID string
|
originChatID string
|
||||||
callback AsyncCallback // For async completion notification
|
allowlistCheck func(targetAgentID string) bool
|
||||||
|
callback AsyncCallback // For async completion notification
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSpawnTool(manager *SubagentManager) *SpawnTool {
|
func NewSpawnTool(manager *SubagentManager) *SpawnTool {
|
||||||
|
|
@ -45,6 +46,10 @@ func (t *SpawnTool) Parameters() map[string]interface{} {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional short label for the task (for display)",
|
"description": "Optional short label for the task (for display)",
|
||||||
},
|
},
|
||||||
|
"agent_id": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional target agent ID to delegate the task to",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": []string{"task"},
|
"required": []string{"task"},
|
||||||
}
|
}
|
||||||
|
|
@ -55,6 +60,10 @@ func (t *SpawnTool) SetContext(channel, chatID string) {
|
||||||
t.originChatID = chatID
|
t.originChatID = chatID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
|
||||||
|
t.allowlistCheck = check
|
||||||
|
}
|
||||||
|
|
||||||
func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
task, ok := args["task"].(string)
|
task, ok := args["task"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -62,13 +71,21 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) *T
|
||||||
}
|
}
|
||||||
|
|
||||||
label, _ := args["label"].(string)
|
label, _ := args["label"].(string)
|
||||||
|
agentID, _ := args["agent_id"].(string)
|
||||||
|
|
||||||
|
// Check allowlist if targeting a specific agent
|
||||||
|
if agentID != "" && t.allowlistCheck != nil {
|
||||||
|
if !t.allowlistCheck(agentID) {
|
||||||
|
return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", agentID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if t.manager == nil {
|
if t.manager == nil {
|
||||||
return ErrorResult("Subagent manager not configured")
|
return ErrorResult("Subagent manager not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass callback to manager for async completion notification
|
// Pass callback to manager for async completion notification
|
||||||
result, err := t.manager.Spawn(ctx, task, label, t.originChannel, t.originChatID, t.callback)
|
result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, t.callback)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err))
|
return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ type SubagentTask struct {
|
||||||
ID string
|
ID string
|
||||||
Task string
|
Task string
|
||||||
Label string
|
Label string
|
||||||
|
AgentID string
|
||||||
OriginChannel string
|
OriginChannel string
|
||||||
OriginChatID string
|
OriginChatID string
|
||||||
Status string
|
Status string
|
||||||
|
|
@ -61,7 +62,7 @@ func (sm *SubagentManager) RegisterTool(tool Tool) {
|
||||||
sm.tools.Register(tool)
|
sm.tools.Register(tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel, originChatID string, callback AsyncCallback) (string, error) {
|
func (sm *SubagentManager) Spawn(ctx context.Context, task, label, agentID, originChannel, originChatID string, callback AsyncCallback) (string, error) {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
defer sm.mu.Unlock()
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
|
@ -72,6 +73,7 @@ func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel
|
||||||
ID: taskID,
|
ID: taskID,
|
||||||
Task: task,
|
Task: task,
|
||||||
Label: label,
|
Label: label,
|
||||||
|
AgentID: agentID,
|
||||||
OriginChannel: originChannel,
|
OriginChannel: originChannel,
|
||||||
OriginChatID: originChatID,
|
OriginChatID: originChatID,
|
||||||
Status: "running",
|
Status: "running",
|
||||||
|
|
|
||||||
179
pkg/utils/message.go
Normal file
179
pkg/utils/message.go
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SplitMessage splits long messages into chunks, preserving code block integrity.
|
||||||
|
// The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks,
|
||||||
|
// but may extend to maxLen when needed.
|
||||||
|
// Call SplitMessage with the full text content and the maximum allowed length of a single message;
|
||||||
|
// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks.
|
||||||
|
func SplitMessage(content string, maxLen int) []string {
|
||||||
|
var messages []string
|
||||||
|
|
||||||
|
// Dynamic buffer: 10% of maxLen, but at least 50 chars if possible
|
||||||
|
codeBlockBuffer := maxLen / 10
|
||||||
|
if codeBlockBuffer < 50 {
|
||||||
|
codeBlockBuffer = 50
|
||||||
|
}
|
||||||
|
if codeBlockBuffer > maxLen/2 {
|
||||||
|
codeBlockBuffer = maxLen / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(content) > 0 {
|
||||||
|
if len(content) <= maxLen {
|
||||||
|
messages = append(messages, content)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective split point: maxLen minus buffer, to leave room for code blocks
|
||||||
|
effectiveLimit := maxLen - codeBlockBuffer
|
||||||
|
if effectiveLimit < maxLen/2 {
|
||||||
|
effectiveLimit = maxLen / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find natural split point within the effective limit
|
||||||
|
msgEnd := findLastNewline(content[:effectiveLimit], 200)
|
||||||
|
if msgEnd <= 0 {
|
||||||
|
msgEnd = findLastSpace(content[:effectiveLimit], 100)
|
||||||
|
}
|
||||||
|
if msgEnd <= 0 {
|
||||||
|
msgEnd = effectiveLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this would end with an incomplete code block
|
||||||
|
candidate := content[:msgEnd]
|
||||||
|
unclosedIdx := findLastUnclosedCodeBlock(candidate)
|
||||||
|
|
||||||
|
if unclosedIdx >= 0 {
|
||||||
|
// Message would end with incomplete code block
|
||||||
|
// Try to extend up to maxLen to include the closing ```
|
||||||
|
if len(content) > msgEnd {
|
||||||
|
closingIdx := findNextClosingCodeBlock(content, msgEnd)
|
||||||
|
if closingIdx > 0 && closingIdx <= maxLen {
|
||||||
|
// Extend to include the closing ```
|
||||||
|
msgEnd = closingIdx
|
||||||
|
} else {
|
||||||
|
// Code block is too long to fit in one chunk or missing closing fence.
|
||||||
|
// Try to split inside by injecting closing and reopening fences.
|
||||||
|
headerEnd := strings.Index(content[unclosedIdx:], "\n")
|
||||||
|
if headerEnd == -1 {
|
||||||
|
headerEnd = unclosedIdx + 3
|
||||||
|
} else {
|
||||||
|
headerEnd += unclosedIdx
|
||||||
|
}
|
||||||
|
header := strings.TrimSpace(content[unclosedIdx:headerEnd])
|
||||||
|
|
||||||
|
// If we have a reasonable amount of content after the header, split inside
|
||||||
|
if msgEnd > headerEnd+20 {
|
||||||
|
// Find a better split point closer to maxLen
|
||||||
|
innerLimit := maxLen - 5 // Leave room for "\n```"
|
||||||
|
betterEnd := findLastNewline(content[:innerLimit], 200)
|
||||||
|
if betterEnd > headerEnd {
|
||||||
|
msgEnd = betterEnd
|
||||||
|
} else {
|
||||||
|
msgEnd = innerLimit
|
||||||
|
}
|
||||||
|
messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```")
|
||||||
|
content = strings.TrimSpace(header + "\n" + content[msgEnd:])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try to split before the code block starts
|
||||||
|
newEnd := findLastNewline(content[:unclosedIdx], 200)
|
||||||
|
if newEnd <= 0 {
|
||||||
|
newEnd = findLastSpace(content[:unclosedIdx], 100)
|
||||||
|
}
|
||||||
|
if newEnd > 0 {
|
||||||
|
msgEnd = newEnd
|
||||||
|
} else {
|
||||||
|
// If we can't split before, we MUST split inside (last resort)
|
||||||
|
if unclosedIdx > 20 {
|
||||||
|
msgEnd = unclosedIdx
|
||||||
|
} else {
|
||||||
|
msgEnd = maxLen - 5
|
||||||
|
messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```")
|
||||||
|
content = strings.TrimSpace(header + "\n" + content[msgEnd:])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if msgEnd <= 0 {
|
||||||
|
msgEnd = effectiveLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, content[:msgEnd])
|
||||||
|
content = strings.TrimSpace(content[msgEnd:])
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ```
|
||||||
|
// Returns the position of the opening ``` or -1 if all code blocks are complete
|
||||||
|
func findLastUnclosedCodeBlock(text string) int {
|
||||||
|
inCodeBlock := false
|
||||||
|
lastOpenIdx := -1
|
||||||
|
|
||||||
|
for i := 0; i < len(text); i++ {
|
||||||
|
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
|
||||||
|
// Toggle code block state on each fence
|
||||||
|
if !inCodeBlock {
|
||||||
|
// Entering a code block: record this opening fence
|
||||||
|
lastOpenIdx = i
|
||||||
|
}
|
||||||
|
inCodeBlock = !inCodeBlock
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if inCodeBlock {
|
||||||
|
return lastOpenIdx
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// findNextClosingCodeBlock finds the next closing ``` starting from a position
|
||||||
|
// Returns the position after the closing ``` or -1 if not found
|
||||||
|
func findNextClosingCodeBlock(text string, startIdx int) int {
|
||||||
|
for i := startIdx; i < len(text); i++ {
|
||||||
|
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
|
||||||
|
return i + 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLastNewline finds the last newline character within the last N characters
|
||||||
|
// Returns the position of the newline or -1 if not found
|
||||||
|
func findLastNewline(s string, searchWindow int) int {
|
||||||
|
searchStart := len(s) - searchWindow
|
||||||
|
if searchStart < 0 {
|
||||||
|
searchStart = 0
|
||||||
|
}
|
||||||
|
for i := len(s) - 1; i >= searchStart; i-- {
|
||||||
|
if s[i] == '\n' {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLastSpace finds the last space character within the last N characters
|
||||||
|
// Returns the position of the space or -1 if not found
|
||||||
|
func findLastSpace(s string, searchWindow int) int {
|
||||||
|
searchStart := len(s) - searchWindow
|
||||||
|
if searchStart < 0 {
|
||||||
|
searchStart = 0
|
||||||
|
}
|
||||||
|
for i := len(s) - 1; i >= searchStart; i-- {
|
||||||
|
if s[i] == ' ' || s[i] == '\t' {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
151
pkg/utils/message_test.go
Normal file
151
pkg/utils/message_test.go
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSplitMessage(t *testing.T) {
|
||||||
|
longText := strings.Repeat("a", 2500)
|
||||||
|
longCode := "```go\n" + strings.Repeat("fmt.Println(\"hello\")\n", 100) + "```" // ~2100 chars
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
maxLen int
|
||||||
|
expectChunks int // Check number of chunks
|
||||||
|
checkContent func(t *testing.T, chunks []string) // Custom validation
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Empty message",
|
||||||
|
content: "",
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Short message fits in one chunk",
|
||||||
|
content: "Hello world",
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Simple split regular text",
|
||||||
|
content: longText,
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 2,
|
||||||
|
checkContent: func(t *testing.T, chunks []string) {
|
||||||
|
if len(chunks[0]) > 2000 {
|
||||||
|
t.Errorf("Chunk 0 too large: %d", len(chunks[0]))
|
||||||
|
}
|
||||||
|
if len(chunks[0])+len(chunks[1]) != len(longText) {
|
||||||
|
t.Errorf("Total length mismatch. Got %d, want %d", len(chunks[0])+len(chunks[1]), len(longText))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Split at newline",
|
||||||
|
// 1750 chars then newline, then more chars.
|
||||||
|
// Dynamic buffer: 2000 / 10 = 200.
|
||||||
|
// Effective limit: 2000 - 200 = 1800.
|
||||||
|
// Split should happen at newline because it's at 1750 (< 1800).
|
||||||
|
// Total length must > 2000 to trigger split. 1750 + 1 + 300 = 2051.
|
||||||
|
content: strings.Repeat("a", 1750) + "\n" + strings.Repeat("b", 300),
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 2,
|
||||||
|
checkContent: func(t *testing.T, chunks []string) {
|
||||||
|
if len(chunks[0]) != 1750 {
|
||||||
|
t.Errorf("Expected chunk 0 to be 1750 length (split at newline), got %d", len(chunks[0]))
|
||||||
|
}
|
||||||
|
if chunks[1] != strings.Repeat("b", 300) {
|
||||||
|
t.Errorf("Chunk 1 content mismatch. Len: %d", len(chunks[1]))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Long code block split",
|
||||||
|
content: "Prefix\n" + longCode,
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 2,
|
||||||
|
checkContent: func(t *testing.T, chunks []string) {
|
||||||
|
// Check that first chunk ends with closing fence
|
||||||
|
if !strings.HasSuffix(chunks[0], "\n```") {
|
||||||
|
t.Error("First chunk should end with injected closing fence")
|
||||||
|
}
|
||||||
|
// Check that second chunk starts with execution header
|
||||||
|
if !strings.HasPrefix(chunks[1], "```go") {
|
||||||
|
t.Error("Second chunk should start with injected code block header")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Preserve Unicode characters",
|
||||||
|
content: strings.Repeat("\u4e16", 1000), // 3000 bytes
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 2,
|
||||||
|
checkContent: func(t *testing.T, chunks []string) {
|
||||||
|
// Just verify we didn't panic and got valid strings.
|
||||||
|
// Go strings are UTF-8, if we split mid-rune it would be bad,
|
||||||
|
// but standard slicing might do that.
|
||||||
|
// Let's assume standard behavior is acceptable or check if it produces invalid rune?
|
||||||
|
if !strings.Contains(chunks[0], "\u4e16") {
|
||||||
|
t.Error("Chunk should contain unicode characters")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := SplitMessage(tc.content, tc.maxLen)
|
||||||
|
|
||||||
|
if tc.expectChunks == 0 {
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("Expected 0 chunks, got %d", len(got))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != tc.expectChunks {
|
||||||
|
t.Errorf("Expected %d chunks, got %d", tc.expectChunks, len(got))
|
||||||
|
// Log sizes for debugging
|
||||||
|
for i, c := range got {
|
||||||
|
t.Logf("Chunk %d length: %d", i, len(c))
|
||||||
|
}
|
||||||
|
return // Stop further checks if count assumes specific split
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.checkContent != nil {
|
||||||
|
tc.checkContent(t, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitMessage_CodeBlockIntegrity(t *testing.T) {
|
||||||
|
// Focused test for the core requirement: splitting inside a code block preserves syntax highlighting
|
||||||
|
|
||||||
|
// 60 chars total approximately
|
||||||
|
content := "```go\npackage main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n```"
|
||||||
|
maxLen := 40
|
||||||
|
|
||||||
|
chunks := SplitMessage(content, maxLen)
|
||||||
|
|
||||||
|
if len(chunks) != 2 {
|
||||||
|
t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First chunk must end with "\n```"
|
||||||
|
if !strings.HasSuffix(chunks[0], "\n```") {
|
||||||
|
t.Errorf("First chunk should end with closing fence. Got: %q", chunks[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second chunk must start with the header "```go"
|
||||||
|
if !strings.HasPrefix(chunks[1], "```go") {
|
||||||
|
t.Errorf("Second chunk should start with code block header. Got: %q", chunks[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// First chunk should contain meaningful content
|
||||||
|
if len(chunks[0]) > 40 {
|
||||||
|
t.Errorf("First chunk exceeded maxLen: length %d", len(chunks[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue