Merge branch 'main' into mattermost
This commit is contained in:
commit
4657a266e7
244 changed files with 23491 additions and 6528 deletions
9
.github/workflows/create_dmg.yml
vendored
9
.github/workflows/create_dmg.yml
vendored
|
|
@ -23,6 +23,12 @@ jobs:
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: 10.33.0
|
||||||
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -30,9 +36,6 @@ jobs:
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
run: corepack enable && corepack install
|
|
||||||
|
|
||||||
# 3. Build the application bundle
|
# 3. Build the application bundle
|
||||||
- name: Build with Make
|
- name: Build with Make
|
||||||
run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }}
|
run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }}
|
||||||
|
|
|
||||||
9
.github/workflows/nightly.yml
vendored
9
.github/workflows/nightly.yml
vendored
|
|
@ -47,6 +47,12 @@ jobs:
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: 10.33.0
|
||||||
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -54,9 +60,6 @@ jobs:
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
run: corepack enable && corepack install
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
|
|
|
||||||
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
|
|
@ -65,6 +65,12 @@ jobs:
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: 10.33.0
|
||||||
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -72,9 +78,6 @@ jobs:
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
run: corepack enable && corepack install
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ Vous pouvez aussi télécharger le binaire pour votre plateforme depuis la page
|
||||||
Prérequis :
|
Prérequis :
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Node.js 22+ avec Corepack activé pour les builds Web UI / launcher
|
- Node.js 22+ et pnpm 10.33.0+ pour les builds Web UI / launcher
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -178,8 +178,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Installer le gestionnaire de paquets frontend déclaré par le dépôt
|
# Installer les dépendances frontend
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# Compiler le binaire principal
|
# Compiler le binaire principal
|
||||||
make build
|
make build
|
||||||
|
|
@ -627,4 +627,3 @@ Discord : <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
WeChat :
|
WeChat :
|
||||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ Atau, unduh binary untuk platform Anda dari halaman [GitHub Releases](https://gi
|
||||||
Prasyarat:
|
Prasyarat:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Node.js 22+ dengan Corepack aktif untuk build Web UI / launcher
|
- Node.js 22+ dan pnpm 10.33.0+ untuk build Web UI / launcher
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -175,8 +175,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Instal package manager frontend yang dideklarasikan repo
|
# Instal dependensi frontend
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# Build binary inti
|
# Build binary inti
|
||||||
make build
|
make build
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ In alternativa, scarica il binario per la tua piattaforma dalla pagina delle [Gi
|
||||||
Prerequisiti:
|
Prerequisiti:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Node.js 22+ con Corepack abilitato per le build Web UI / launcher
|
- Node.js 22+ e pnpm 10.33.0+ per le build Web UI / launcher
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -175,8 +175,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Installa il package manager frontend dichiarato dal repository
|
# Installa le dipendenze frontend
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# Compila il binario core
|
# Compila il binario core
|
||||||
make build
|
make build
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ PicoClaw はほぼすべての Linux デバイスにデプロイできます!
|
||||||
前提条件:
|
前提条件:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Web UI / launcher のビルドには Corepack を有効にした Node.js 22+
|
- Web UI / launcher のビルドには Node.js 22+ と pnpm 10.33.0+ が必要
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -175,8 +175,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# リポジトリで宣言されたフロントエンド用パッケージマネージャーをインストール
|
# フロントエンド依存関係をインストール
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# コアバイナリをビルド
|
# コアバイナリをビルド
|
||||||
make build
|
make build
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ PicoClaw는 사실상 거의 모든 Linux 장치에 배포할 수 있습니다!
|
||||||
필수 사항:
|
필수 사항:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Web UI / launcher 빌드를 위한 Corepack 활성화된 Node.js 22+
|
- Web UI / launcher 빌드에는 Node.js 22+와 pnpm 10.33.0+가 필요합니다
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -175,8 +175,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# 저장소에 선언된 프런트엔드 패키지 매니저 설치
|
# 프런트엔드 의존성 설치
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# 코어 바이너리 빌드
|
# 코어 바이너리 빌드
|
||||||
make build
|
make build
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -167,7 +167,7 @@ Alternatively, download the binary for your platform from the [GitHub Releases](
|
||||||
Prerequisites:
|
Prerequisites:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Node.js 22+ with Corepack enabled for Web UI / launcher builds
|
- Node.js 22+ and pnpm 10.33.0+ for Web UI / launcher builds
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -175,8 +175,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Install frontend package manager declared by the repo
|
# Install frontend dependencies
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# Build the core binary for the current platform
|
# Build the core binary for the current platform
|
||||||
make build
|
make build
|
||||||
|
|
@ -524,7 +524,7 @@ picoclaw skills search "web scraping"
|
||||||
picoclaw skills install <skill-name>
|
picoclaw skills install <skill-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configure ClawHub token** (optional, for higher rate limits):
|
**Configure skill registries**:
|
||||||
|
|
||||||
Add to your `config.json`:
|
Add to your `config.json`:
|
||||||
```json
|
```json
|
||||||
|
|
@ -534,6 +534,11 @@ Add to your `config.json`:
|
||||||
"registries": {
|
"registries": {
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"auth_token": "your-clawhub-token"
|
"auth_token": "your-clawhub-token"
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"base_url": "https://github.com",
|
||||||
|
"auth_token": "your-github-token",
|
||||||
|
"proxy": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -541,6 +546,8 @@ Add to your `config.json`:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`tools.skills.github.*` is deprecated. Use `tools.skills.registries.github.*` instead.
|
||||||
|
|
||||||
For more details, see [Tools Configuration - Skills](docs/tools_configuration.md#skills-tool).
|
For more details, see [Tools Configuration - Skills](docs/tools_configuration.md#skills-tool).
|
||||||
|
|
||||||
## 🔗 MCP (Model Context Protocol)
|
## 🔗 MCP (Model Context Protocol)
|
||||||
|
|
|
||||||
|
|
@ -168,15 +168,15 @@ Muat turun binari untuk platform anda dari halaman [GitHub Releases](https://git
|
||||||
Prasyarat:
|
Prasyarat:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Node.js 22+ dengan Corepack diaktifkan untuk binaan Web UI / launcher
|
- Node.js 22+ dan pnpm 10.33.0+ untuk binaan Web UI / launcher
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Pasang pengurus pakej frontend yang diisytiharkan oleh repositori
|
# Pasang dependensi frontend
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# Bina binari teras
|
# Bina binari teras
|
||||||
make build
|
make build
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ Alternativamente, baixe o binário para sua plataforma na página de [GitHub Rel
|
||||||
Pré-requisitos:
|
Pré-requisitos:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Node.js 22+ com Corepack habilitado para builds do Web UI / launcher
|
- Node.js 22+ e pnpm 10.33.0+ para builds do Web UI / launcher
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -175,8 +175,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Instalar o gerenciador de pacotes de frontend declarado pelo repositório
|
# Instalar dependências do frontend
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# Compilar o binário principal
|
# Compilar o binário principal
|
||||||
make build
|
make build
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ Ngoài ra, tải binary cho nền tảng của bạn từ trang [GitHub Releases
|
||||||
Yêu cầu:
|
Yêu cầu:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Node.js 22+ với Corepack được bật cho các bản build Web UI / launcher
|
- Node.js 22+ và pnpm 10.33.0+ cho các bản build Web UI / launcher
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -175,8 +175,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Cài đặt trình quản lý gói frontend được khai báo bởi repo
|
# Cài đặt dependencies frontend
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# Build binary lõi
|
# Build binary lõi
|
||||||
make build
|
make build
|
||||||
|
|
|
||||||
15
README.zh.md
15
README.zh.md
|
|
@ -167,7 +167,7 @@ PicoClaw 几乎可以部署在任何 Linux 设备上!
|
||||||
前置要求:
|
前置要求:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Node.js 22+,并启用 Corepack(用于 Web UI / launcher 构建)
|
- Node.js 22+ 和 pnpm 10.33.0+(用于 Web UI / launcher 构建)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -175,8 +175,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# 安装仓库声明的前端包管理器
|
# 安装前端依赖
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# 构建核心二进制文件
|
# 构建核心二进制文件
|
||||||
make build
|
make build
|
||||||
|
|
@ -516,7 +516,7 @@ picoclaw skills search "web scraping"
|
||||||
picoclaw skills install <skill-name>
|
picoclaw skills install <skill-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
**配置 ClawHub token**(可选,用于提高速率限制):
|
**配置 Skills 仓库源**:
|
||||||
|
|
||||||
在 `config.json` 中添加:
|
在 `config.json` 中添加:
|
||||||
```json
|
```json
|
||||||
|
|
@ -526,6 +526,11 @@ picoclaw skills install <skill-name>
|
||||||
"registries": {
|
"registries": {
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"auth_token": "your-clawhub-token"
|
"auth_token": "your-clawhub-token"
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"base_url": "https://github.com",
|
||||||
|
"auth_token": "your-github-token",
|
||||||
|
"proxy": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -533,6 +538,8 @@ picoclaw skills install <skill-name>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`tools.skills.github.*` 已废弃,请改用 `tools.skills.registries.github.*`。
|
||||||
|
|
||||||
更多详情请参阅 [工具配置 - Skills](docs/zh/tools_configuration.md#skills-tool)。
|
更多详情请参阅 [工具配置 - Skills](docs/zh/tools_configuration.md#skills-tool)。
|
||||||
|
|
||||||
## 🔗 MCP (Model Context Protocol)
|
## 🔗 MCP (Model Context Protocol)
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 98 KiB |
|
|
@ -36,6 +36,7 @@ type AggMetrics struct {
|
||||||
OverallHitRate float64 `json:"overallHitRate"`
|
OverallHitRate float64 `json:"overallHitRate"`
|
||||||
ByCategory map[int]*CatMetrics `json:"byCategory"`
|
ByCategory map[int]*CatMetrics `json:"byCategory"`
|
||||||
TotalQuestions int `json:"totalQuestions"`
|
TotalQuestions int `json:"totalQuestions"`
|
||||||
|
ValidF1Count int `json:"validF1Count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CatMetrics holds metrics for a single category.
|
// CatMetrics holds metrics for a single category.
|
||||||
|
|
@ -43,6 +44,7 @@ type CatMetrics struct {
|
||||||
F1 float64 `json:"f1"`
|
F1 float64 `json:"f1"`
|
||||||
HitRate float64 `json:"hitRate"`
|
HitRate float64 `json:"hitRate"`
|
||||||
QuestionCount int `json:"questionCount"`
|
QuestionCount int `json:"questionCount"`
|
||||||
|
ValidF1Count int `json:"validF1Count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvalLegacy evaluates using legacy session store (raw history + budget truncation).
|
// EvalLegacy evaluates using legacy session store (raw history + budget truncation).
|
||||||
|
|
@ -201,38 +203,64 @@ func EvalSeahorse(
|
||||||
|
|
||||||
// aggregateMetrics computes overall and per-category metrics.
|
// aggregateMetrics computes overall and per-category metrics.
|
||||||
func aggregateMetrics(qaResults []QAResult) AggMetrics {
|
func aggregateMetrics(qaResults []QAResult) AggMetrics {
|
||||||
byCat := map[int]*CatMetrics{}
|
type catAccum struct {
|
||||||
|
f1Sum float64
|
||||||
|
f1Count int
|
||||||
|
hitRateSum float64
|
||||||
|
hitRateCount int
|
||||||
|
}
|
||||||
|
byCatAcc := map[int]*catAccum{}
|
||||||
totalF1 := 0.0
|
totalF1 := 0.0
|
||||||
totalHitRate := 0.0
|
totalHitRate := 0.0
|
||||||
|
validF1Count := 0
|
||||||
for _, qr := range qaResults {
|
for _, qr := range qaResults {
|
||||||
|
// Skip sentinel -1.0 scores (LLM API/parse failures) from F1 averaging.
|
||||||
|
if qr.TokenF1 >= 0 {
|
||||||
totalF1 += qr.TokenF1
|
totalF1 += qr.TokenF1
|
||||||
|
validF1Count++
|
||||||
|
}
|
||||||
totalHitRate += qr.HitRate
|
totalHitRate += qr.HitRate
|
||||||
cat, ok := byCat[qr.Category]
|
acc, ok := byCatAcc[qr.Category]
|
||||||
if !ok {
|
if !ok {
|
||||||
cat = &CatMetrics{}
|
acc = &catAccum{}
|
||||||
byCat[qr.Category] = cat
|
byCatAcc[qr.Category] = acc
|
||||||
}
|
}
|
||||||
cat.F1 += qr.TokenF1
|
if qr.TokenF1 >= 0 {
|
||||||
cat.HitRate += qr.HitRate
|
acc.f1Sum += qr.TokenF1
|
||||||
cat.QuestionCount++
|
acc.f1Count++
|
||||||
}
|
}
|
||||||
n := len(qaResults)
|
acc.hitRateSum += qr.HitRate
|
||||||
if n == 0 {
|
acc.hitRateCount++
|
||||||
n = 1
|
|
||||||
}
|
}
|
||||||
agg := AggMetrics{
|
nHit := len(qaResults)
|
||||||
OverallF1: totalF1 / float64(n),
|
if nHit == 0 {
|
||||||
OverallHitRate: totalHitRate / float64(n),
|
nHit = 1
|
||||||
|
}
|
||||||
|
byCat := map[int]*CatMetrics{}
|
||||||
|
for cat, acc := range byCatAcc {
|
||||||
|
cm := &CatMetrics{
|
||||||
|
QuestionCount: acc.hitRateCount,
|
||||||
|
ValidF1Count: acc.f1Count,
|
||||||
|
}
|
||||||
|
if acc.f1Count > 0 {
|
||||||
|
cm.F1 = acc.f1Sum / float64(acc.f1Count)
|
||||||
|
}
|
||||||
|
if acc.hitRateCount > 0 {
|
||||||
|
cm.HitRate = acc.hitRateSum / float64(acc.hitRateCount)
|
||||||
|
}
|
||||||
|
byCat[cat] = cm
|
||||||
|
}
|
||||||
|
var overallF1 float64
|
||||||
|
if validF1Count > 0 {
|
||||||
|
overallF1 = totalF1 / float64(validF1Count)
|
||||||
|
}
|
||||||
|
return AggMetrics{
|
||||||
|
OverallF1: overallF1,
|
||||||
|
OverallHitRate: totalHitRate / float64(nHit),
|
||||||
ByCategory: byCat,
|
ByCategory: byCat,
|
||||||
TotalQuestions: len(qaResults),
|
TotalQuestions: len(qaResults),
|
||||||
|
ValidF1Count: validF1Count,
|
||||||
}
|
}
|
||||||
for _, cat := range agg.ByCategory {
|
|
||||||
if cat.QuestionCount > 0 {
|
|
||||||
cat.F1 /= float64(cat.QuestionCount)
|
|
||||||
cat.HitRate /= float64(cat.QuestionCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return agg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveResults writes per-sample eval results to JSON files.
|
// SaveResults writes per-sample eval results to JSON files.
|
||||||
|
|
@ -277,27 +305,43 @@ func SaveAggregated(results []EvalResult, outDir string) error {
|
||||||
func computeModeAgg(results []EvalResult) AggMetrics {
|
func computeModeAgg(results []EvalResult) AggMetrics {
|
||||||
agg := AggMetrics{ByCategory: map[int]*CatMetrics{}}
|
agg := AggMetrics{ByCategory: map[int]*CatMetrics{}}
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
agg.OverallF1 += r.Agg.OverallF1 * float64(r.Agg.TotalQuestions)
|
// Backward compat: old eval JSON (token mode) without ValidF1Count → use TotalQuestions.
|
||||||
|
// LLM modes may legitimately have ValidF1Count==0 (all failures).
|
||||||
|
vf1 := r.Agg.ValidF1Count
|
||||||
|
if vf1 == 0 && r.Agg.TotalQuestions > 0 && !strings.HasSuffix(r.Mode, "-llm") {
|
||||||
|
vf1 = r.Agg.TotalQuestions
|
||||||
|
}
|
||||||
|
agg.OverallF1 += r.Agg.OverallF1 * float64(vf1)
|
||||||
agg.OverallHitRate += r.Agg.OverallHitRate * float64(r.Agg.TotalQuestions)
|
agg.OverallHitRate += r.Agg.OverallHitRate * float64(r.Agg.TotalQuestions)
|
||||||
agg.TotalQuestions += r.Agg.TotalQuestions
|
agg.TotalQuestions += r.Agg.TotalQuestions
|
||||||
|
agg.ValidF1Count += vf1
|
||||||
for cat, cm := range r.Agg.ByCategory {
|
for cat, cm := range r.Agg.ByCategory {
|
||||||
existing, ok := agg.ByCategory[cat]
|
existing, ok := agg.ByCategory[cat]
|
||||||
if !ok {
|
if !ok {
|
||||||
existing = &CatMetrics{}
|
existing = &CatMetrics{}
|
||||||
agg.ByCategory[cat] = existing
|
agg.ByCategory[cat] = existing
|
||||||
}
|
}
|
||||||
existing.F1 += cm.F1 * float64(cm.QuestionCount)
|
cvf1 := cm.ValidF1Count
|
||||||
|
if cvf1 == 0 && cm.QuestionCount > 0 && !strings.HasSuffix(r.Mode, "-llm") {
|
||||||
|
cvf1 = cm.QuestionCount
|
||||||
|
}
|
||||||
|
existing.F1 += cm.F1 * float64(cvf1)
|
||||||
existing.HitRate += cm.HitRate * float64(cm.QuestionCount)
|
existing.HitRate += cm.HitRate * float64(cm.QuestionCount)
|
||||||
existing.QuestionCount += cm.QuestionCount
|
existing.QuestionCount += cm.QuestionCount
|
||||||
|
existing.ValidF1Count += cvf1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if agg.ValidF1Count > 0 {
|
||||||
|
agg.OverallF1 /= float64(agg.ValidF1Count)
|
||||||
|
}
|
||||||
if agg.TotalQuestions > 0 {
|
if agg.TotalQuestions > 0 {
|
||||||
agg.OverallF1 /= float64(agg.TotalQuestions)
|
|
||||||
agg.OverallHitRate /= float64(agg.TotalQuestions)
|
agg.OverallHitRate /= float64(agg.TotalQuestions)
|
||||||
}
|
}
|
||||||
for _, cat := range agg.ByCategory {
|
for _, cat := range agg.ByCategory {
|
||||||
|
if cat.ValidF1Count > 0 {
|
||||||
|
cat.F1 /= float64(cat.ValidF1Count)
|
||||||
|
}
|
||||||
if cat.QuestionCount > 0 {
|
if cat.QuestionCount > 0 {
|
||||||
cat.F1 /= float64(cat.QuestionCount)
|
|
||||||
cat.HitRate /= float64(cat.QuestionCount)
|
cat.HitRate /= float64(cat.QuestionCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -359,7 +403,9 @@ func printSection(title string, results []EvalResult) {
|
||||||
|
|
||||||
// PrintComparison outputs a human-readable comparison table to stdout.
|
// PrintComparison outputs a human-readable comparison table to stdout.
|
||||||
func PrintComparison(results []EvalResult, llmResults []EvalResult) {
|
func PrintComparison(results []EvalResult, llmResults []EvalResult) {
|
||||||
|
if len(results) > 0 {
|
||||||
printSection("No LLM generation", results)
|
printSection("No LLM generation", results)
|
||||||
|
}
|
||||||
if len(llmResults) > 0 {
|
if len(llmResults) > 0 {
|
||||||
printSection("With LLM", llmResults)
|
printSection("With LLM", llmResults)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
346
cmd/membench/eval_llm.go
Normal file
346
cmd/membench/eval_llm.go
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/seahorse"
|
||||||
|
)
|
||||||
|
|
||||||
|
const answerSystemPrompt = `You are a helpful assistant. Given conversation context, answer the question concisely and accurately. If the answer is not in the context, say "I don't know". Answer in 1-3 sentences maximum.`
|
||||||
|
|
||||||
|
const judgeSystemPrompt = `You are an impartial judge evaluating answer quality.
|
||||||
|
Compare the candidate answer against the reference answer.
|
||||||
|
Consider semantic equivalence — different wording expressing the same meaning should score high.
|
||||||
|
|
||||||
|
Output ONLY a single integer score from 1 to 5:
|
||||||
|
1 = completely wrong or irrelevant
|
||||||
|
2 = partially related but mostly incorrect
|
||||||
|
3 = partially correct, missing key details
|
||||||
|
4 = mostly correct with minor omissions
|
||||||
|
5 = fully correct, semantically equivalent
|
||||||
|
|
||||||
|
Output ONLY the number, nothing else.`
|
||||||
|
|
||||||
|
// generateAnswer asks the LLM to answer a question given retrieved context.
|
||||||
|
func generateAnswer(ctx context.Context, client *LLMClient, contextText, question string) (string, error) {
|
||||||
|
// Truncate context to avoid exceeding model limits while preserving valid UTF-8.
|
||||||
|
contextRunes := []rune(contextText)
|
||||||
|
if len(contextRunes) > 6000 {
|
||||||
|
contextText = string(contextRunes[:6000]) + "\n... [truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
|
userPrompt := fmt.Sprintf("## Conversation Context\n\n%s\n\n## Question\n\n%s", contextText, question)
|
||||||
|
return client.Complete(ctx, answerSystemPrompt, userPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoreRe matches the first standalone integer 1-5 in the judge response.
|
||||||
|
var scoreRe = regexp.MustCompile(`\b([1-5])\b`)
|
||||||
|
|
||||||
|
// judgeAnswer asks the LLM to score the candidate answer vs the gold answer.
|
||||||
|
// Returns a score from 0.0 to 1.0, or -1.0 on parse failure.
|
||||||
|
func judgeAnswer(
|
||||||
|
ctx context.Context,
|
||||||
|
judgeClient *LLMClient,
|
||||||
|
question, goldAnswer, candidateAnswer string,
|
||||||
|
) (float64, error) {
|
||||||
|
userPrompt := fmt.Sprintf(
|
||||||
|
"Question: %s\n\nReference Answer: %s\n\nCandidate Answer: %s\n\nScore:",
|
||||||
|
question, goldAnswer, candidateAnswer,
|
||||||
|
)
|
||||||
|
|
||||||
|
response, err := judgeClient.Complete(ctx, judgeSystemPrompt, userPrompt)
|
||||||
|
if err != nil {
|
||||||
|
return -1.0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
response = strings.TrimSpace(response)
|
||||||
|
if m := scoreRe.FindStringSubmatch(response); len(m) == 2 {
|
||||||
|
score, _ := strconv.Atoi(m[1])
|
||||||
|
return float64(score-1) / 4.0, nil // Normalize 1-5 to 0.0-1.0
|
||||||
|
}
|
||||||
|
log.Printf("WARNING: could not parse judge score from: %q, returning -1", response)
|
||||||
|
return -1.0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// qaWork describes one QA evaluation unit.
|
||||||
|
type qaWork struct {
|
||||||
|
sampleID string
|
||||||
|
qaIndex int
|
||||||
|
globalIndex int
|
||||||
|
totalQA int
|
||||||
|
qa *LocomoQA
|
||||||
|
contextText string
|
||||||
|
sample *LocomoSample
|
||||||
|
}
|
||||||
|
|
||||||
|
// qaResult collects one QA evaluation output.
|
||||||
|
type qaResultOut struct {
|
||||||
|
index int // position in the flat QA list for ordering
|
||||||
|
result QAResult
|
||||||
|
answer string
|
||||||
|
score float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// evalQAWorker processes a single QA item: generate answer + judge score.
|
||||||
|
func evalQAWorker(
|
||||||
|
ctx context.Context,
|
||||||
|
w qaWork,
|
||||||
|
answerClient, judgeClient *LLMClient,
|
||||||
|
logPrefix string,
|
||||||
|
) qaResultOut {
|
||||||
|
llmAnswer, err := generateAnswer(ctx, answerClient, w.contextText, w.qa.Question)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WARN: LLM generation failed for sample %s Q%d: %v", w.sampleID, w.qaIndex, err)
|
||||||
|
llmAnswer = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
score := -1.0
|
||||||
|
if llmAnswer != "" {
|
||||||
|
score, err = judgeAnswer(ctx, judgeClient, w.qa.Question, w.qa.AnswerString(), llmAnswer)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WARN: LLM judge failed for sample %s Q%d: %v", w.sampleID, w.qaIndex, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hitRate := RecallHitRate(w.qa.Evidence, w.sample, w.contextText)
|
||||||
|
|
||||||
|
log.Printf("[%s] sample=%s q=%d/%d score=%.2f answer=%q",
|
||||||
|
logPrefix, w.sampleID, w.globalIndex, w.totalQA, score, truncateStr(llmAnswer, 80))
|
||||||
|
|
||||||
|
return qaResultOut{
|
||||||
|
index: w.globalIndex,
|
||||||
|
result: QAResult{
|
||||||
|
Question: w.qa.Question,
|
||||||
|
Category: w.qa.Category,
|
||||||
|
GoldAnswer: w.qa.AnswerString(),
|
||||||
|
TokenF1: score,
|
||||||
|
HitRate: hitRate,
|
||||||
|
},
|
||||||
|
answer: llmAnswer,
|
||||||
|
score: score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalLegacyLLM evaluates legacy store using LLM generation + LLM-as-Judge.
|
||||||
|
func EvalLegacyLLM(
|
||||||
|
ctx context.Context,
|
||||||
|
samples []LocomoSample,
|
||||||
|
legacy *LegacyStore,
|
||||||
|
budgetTokens int,
|
||||||
|
answerClient, judgeClient *LLMClient,
|
||||||
|
concurrency int,
|
||||||
|
) []EvalResult {
|
||||||
|
if concurrency < 1 {
|
||||||
|
concurrency = 1
|
||||||
|
}
|
||||||
|
totalQA := countTotalQA(samples)
|
||||||
|
results := make([]EvalResult, 0, len(samples))
|
||||||
|
|
||||||
|
for si := range samples {
|
||||||
|
sample := &samples[si]
|
||||||
|
history := legacy.GetHistory(sample.SampleID)
|
||||||
|
|
||||||
|
allContent := make([]string, 0, len(history))
|
||||||
|
for _, msg := range history {
|
||||||
|
allContent = append(allContent, msg.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
truncated, _ := BudgetTruncate(allContent, budgetTokens)
|
||||||
|
contextText := StringListToContent(truncated)
|
||||||
|
|
||||||
|
qaResults := make([]QAResult, len(sample.QA))
|
||||||
|
|
||||||
|
if concurrency <= 1 {
|
||||||
|
for qi := range sample.QA {
|
||||||
|
out := evalQAWorker(ctx, qaWork{
|
||||||
|
sampleID: sample.SampleID, qaIndex: qi,
|
||||||
|
globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA,
|
||||||
|
qa: &sample.QA[qi], contextText: contextText, sample: sample,
|
||||||
|
}, answerClient, judgeClient, "legacy-llm")
|
||||||
|
qaResults[qi] = out.result
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sem := make(chan struct{}, concurrency)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for qi := range sample.QA {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
out := evalQAWorker(ctx, qaWork{
|
||||||
|
sampleID: sample.SampleID, qaIndex: qi,
|
||||||
|
globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA,
|
||||||
|
qa: &sample.QA[qi], contextText: contextText, sample: sample,
|
||||||
|
}, answerClient, judgeClient, "legacy-llm")
|
||||||
|
qaResults[qi] = out.result // safe: each goroutine writes distinct index
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, EvalResult{
|
||||||
|
Mode: "legacy-llm",
|
||||||
|
SampleID: sample.SampleID,
|
||||||
|
QAResults: qaResults,
|
||||||
|
Agg: aggregateMetrics(qaResults),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSeahorseContext retrieves context for a seahorse QA item.
|
||||||
|
func buildSeahorseContext(
|
||||||
|
ctx context.Context,
|
||||||
|
ir *SeahorseIngestResult,
|
||||||
|
sample *LocomoSample,
|
||||||
|
qa *LocomoQA,
|
||||||
|
budgetTokens int,
|
||||||
|
) string {
|
||||||
|
store := ir.Engine.GetRetrieval().Store()
|
||||||
|
retrieval := ir.Engine.GetRetrieval()
|
||||||
|
convID := ir.ConvMap[sample.SampleID]
|
||||||
|
|
||||||
|
keywords := ExtractKeywords(qa.Question)
|
||||||
|
bestRank := map[int64]float64{}
|
||||||
|
for _, kw := range keywords {
|
||||||
|
searchResults, err := store.SearchMessages(ctx, seahorse.SearchInput{
|
||||||
|
Pattern: kw,
|
||||||
|
ConversationID: convID,
|
||||||
|
Limit: 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, sr := range searchResults {
|
||||||
|
if sr.MessageID > 0 {
|
||||||
|
if prev, ok := bestRank[sr.MessageID]; !ok || sr.Rank < prev {
|
||||||
|
bestRank[sr.MessageID] = sr.Rank
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messageIDs := make([]int64, 0, len(bestRank))
|
||||||
|
for id := range bestRank {
|
||||||
|
messageIDs = append(messageIDs, id)
|
||||||
|
}
|
||||||
|
sort.Slice(messageIDs, func(i, j int) bool {
|
||||||
|
return bestRank[messageIDs[i]] < bestRank[messageIDs[j]]
|
||||||
|
})
|
||||||
|
|
||||||
|
var contentParts []string
|
||||||
|
if len(messageIDs) > 0 {
|
||||||
|
expandResult, err := retrieval.ExpandMessages(ctx, messageIDs)
|
||||||
|
if err == nil {
|
||||||
|
for _, msg := range expandResult.Messages {
|
||||||
|
contentParts = append(contentParts, msg.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(contentParts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
truncated, _ := BudgetTruncate(contentParts, budgetTokens)
|
||||||
|
return StringListToContent(truncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalSeahorseLLM evaluates seahorse retrieval using LLM generation + LLM-as-Judge.
|
||||||
|
func EvalSeahorseLLM(
|
||||||
|
ctx context.Context,
|
||||||
|
samples []LocomoSample,
|
||||||
|
ir *SeahorseIngestResult,
|
||||||
|
budgetTokens int,
|
||||||
|
answerClient, judgeClient *LLMClient,
|
||||||
|
concurrency int,
|
||||||
|
) []EvalResult {
|
||||||
|
if concurrency < 1 {
|
||||||
|
concurrency = 1
|
||||||
|
}
|
||||||
|
totalQA := countTotalQA(samples)
|
||||||
|
results := make([]EvalResult, 0, len(samples))
|
||||||
|
|
||||||
|
for si := range samples {
|
||||||
|
sample := &samples[si]
|
||||||
|
if _, ok := ir.ConvMap[sample.SampleID]; !ok {
|
||||||
|
log.Printf("WARN: no conversation ID for sample %s", sample.SampleID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
qaResults := make([]QAResult, len(sample.QA))
|
||||||
|
|
||||||
|
evalOne := func(qi int) {
|
||||||
|
qa := &sample.QA[qi]
|
||||||
|
contextText := buildSeahorseContext(ctx, ir, sample, qa, budgetTokens)
|
||||||
|
if contextText == "" {
|
||||||
|
qaResults[qi] = QAResult{
|
||||||
|
Question: qa.Question,
|
||||||
|
Category: qa.Category,
|
||||||
|
GoldAnswer: qa.AnswerString(),
|
||||||
|
TokenF1: 0.0,
|
||||||
|
HitRate: 0.0,
|
||||||
|
}
|
||||||
|
log.Printf("[seahorse-llm] sample=%s q=%d/%d score=0.00 answer=(no context)",
|
||||||
|
sample.SampleID, si*len(sample.QA)+qi+1, totalQA)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := evalQAWorker(ctx, qaWork{
|
||||||
|
sampleID: sample.SampleID, qaIndex: qi,
|
||||||
|
globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA,
|
||||||
|
qa: qa, contextText: contextText, sample: sample,
|
||||||
|
}, answerClient, judgeClient, "seahorse-llm")
|
||||||
|
qaResults[qi] = out.result
|
||||||
|
}
|
||||||
|
|
||||||
|
if concurrency <= 1 {
|
||||||
|
for qi := range sample.QA {
|
||||||
|
evalOne(qi)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sem := make(chan struct{}, concurrency)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for qi := range sample.QA {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
evalOne(qi)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, EvalResult{
|
||||||
|
Mode: "seahorse-llm",
|
||||||
|
SampleID: sample.SampleID,
|
||||||
|
QAResults: qaResults,
|
||||||
|
Agg: aggregateMetrics(qaResults),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func countTotalQA(samples []LocomoSample) int {
|
||||||
|
n := 0
|
||||||
|
for i := range samples {
|
||||||
|
n += len(samples[i].QA)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateStr(s string, maxLen int) string {
|
||||||
|
s = strings.ReplaceAll(s, "\n", " ")
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) > maxLen {
|
||||||
|
return string(runes[:maxLen]) + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
@ -102,3 +102,81 @@ func TestComputeModeAgg(t *testing.T) {
|
||||||
t.Errorf("TotalQuestions = %d, want 10", got.TotalQuestions)
|
t.Errorf("TotalQuestions = %d, want 10", got.TotalQuestions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAggregateMetricsSentinel(t *testing.T) {
|
||||||
|
qa := []QAResult{
|
||||||
|
{Category: 1, TokenF1: 0.8, HitRate: 0.5},
|
||||||
|
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
|
||||||
|
{Category: 1, TokenF1: 0.4, HitRate: 0.7},
|
||||||
|
}
|
||||||
|
agg := aggregateMetrics(qa)
|
||||||
|
|
||||||
|
if agg.ValidF1Count != 2 {
|
||||||
|
t.Errorf("ValidF1Count = %d, want 2", agg.ValidF1Count)
|
||||||
|
}
|
||||||
|
if agg.TotalQuestions != 3 {
|
||||||
|
t.Errorf("TotalQuestions = %d, want 3", agg.TotalQuestions)
|
||||||
|
}
|
||||||
|
wantF1 := (0.8 + 0.4) / 2.0
|
||||||
|
if math.Abs(agg.OverallF1-wantF1) > 1e-9 {
|
||||||
|
t.Errorf("OverallF1 = %.6f, want %.6f", agg.OverallF1, wantF1)
|
||||||
|
}
|
||||||
|
wantHR := (0.5 + 0.3 + 0.7) / 3.0
|
||||||
|
if math.Abs(agg.OverallHitRate-wantHR) > 1e-9 {
|
||||||
|
t.Errorf("OverallHitRate = %.6f, want %.6f", agg.OverallHitRate, wantHR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateMetricsAllSentinel(t *testing.T) {
|
||||||
|
qa := []QAResult{
|
||||||
|
{Category: 1, TokenF1: -1.0, HitRate: 0.5},
|
||||||
|
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
|
||||||
|
}
|
||||||
|
agg := aggregateMetrics(qa)
|
||||||
|
|
||||||
|
if agg.ValidF1Count != 0 {
|
||||||
|
t.Errorf("ValidF1Count = %d, want 0", agg.ValidF1Count)
|
||||||
|
}
|
||||||
|
if agg.OverallF1 != 0 {
|
||||||
|
t.Errorf("OverallF1 = %.6f, want 0", agg.OverallF1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeModeAggSentinelWeighting(t *testing.T) {
|
||||||
|
results := []EvalResult{
|
||||||
|
{
|
||||||
|
Mode: "test",
|
||||||
|
SampleID: "s1",
|
||||||
|
QAResults: []QAResult{
|
||||||
|
{Category: 1, TokenF1: 0.8, HitRate: 0.5},
|
||||||
|
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Mode: "test",
|
||||||
|
SampleID: "s2",
|
||||||
|
QAResults: []QAResult{
|
||||||
|
{Category: 1, TokenF1: 0.4, HitRate: 0.6},
|
||||||
|
{Category: 1, TokenF1: 0.6, HitRate: 0.8},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i := range results {
|
||||||
|
results[i].Agg = aggregateMetrics(results[i].QAResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := computeModeAgg(results)
|
||||||
|
|
||||||
|
// s1: ValidF1Count=1, F1=0.8; s2: ValidF1Count=2, F1=0.5
|
||||||
|
// Weighted: (0.8*1 + 0.5*2) / 3 = 1.8/3 = 0.6
|
||||||
|
wantF1 := 0.6
|
||||||
|
if math.Abs(got.OverallF1-wantF1) > 1e-9 {
|
||||||
|
t.Errorf("OverallF1 = %.6f, want %.6f", got.OverallF1, wantF1)
|
||||||
|
}
|
||||||
|
if got.ValidF1Count != 3 {
|
||||||
|
t.Errorf("ValidF1Count = %d, want 3", got.ValidF1Count)
|
||||||
|
}
|
||||||
|
if got.TotalQuestions != 4 {
|
||||||
|
t.Errorf("TotalQuestions = %d, want 4", got.TotalQuestions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
198
cmd/membench/llm_client.go
Normal file
198
cmd/membench/llm_client.go
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LLMClient wraps an OpenAI-compatible chat completion endpoint.
|
||||||
|
type LLMClient struct {
|
||||||
|
BaseURL string
|
||||||
|
Model string
|
||||||
|
APIKey string
|
||||||
|
NoThinking bool // send chat_template_kwargs to disable thinking (llama.cpp specific)
|
||||||
|
MaxRetries int // max retry attempts for transient errors (0 = no retry)
|
||||||
|
Client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLMClientOptions configures the LLM client.
|
||||||
|
type LLMClientOptions struct {
|
||||||
|
BaseURL string
|
||||||
|
Model string
|
||||||
|
APIKey string
|
||||||
|
Timeout time.Duration
|
||||||
|
NoThinking bool
|
||||||
|
MaxRetries int // max retry attempts (default 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLLMClient creates a client for an OpenAI-compatible chat completion API.
|
||||||
|
func NewLLMClient(opts LLMClientOptions) *LLMClient {
|
||||||
|
if opts.Timeout == 0 {
|
||||||
|
opts.Timeout = 120 * time.Second
|
||||||
|
}
|
||||||
|
maxRetries := opts.MaxRetries
|
||||||
|
if maxRetries < 0 {
|
||||||
|
maxRetries = 3
|
||||||
|
}
|
||||||
|
return &LLMClient{
|
||||||
|
BaseURL: strings.TrimRight(opts.BaseURL, "/"),
|
||||||
|
Model: opts.Model,
|
||||||
|
APIKey: opts.APIKey,
|
||||||
|
NoThinking: opts.NoThinking,
|
||||||
|
MaxRetries: maxRetries,
|
||||||
|
Client: &http.Client{
|
||||||
|
Timeout: opts.Timeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []chatMessage `json:"messages"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
ChatTemplateKwargs map[string]any `json:"chat_template_kwargs,omitempty"` // llama.cpp
|
||||||
|
Think *bool `json:"think,omitempty"` // Ollama
|
||||||
|
Thinking map[string]any `json:"thinking,omitempty"` // GLM (智谱)
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete sends a chat completion request and returns the assistant's reply.
|
||||||
|
func (c *LLMClient) Complete(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
|
||||||
|
sysContent := systemPrompt
|
||||||
|
if c.NoThinking && sysContent != "" {
|
||||||
|
// Prepend /no_think tag — works with Ollama /v1 endpoint and
|
||||||
|
// Qwen chat templates where the JSON think field is ignored.
|
||||||
|
sysContent = "/no_think\n" + sysContent
|
||||||
|
}
|
||||||
|
messages := []chatMessage{}
|
||||||
|
if sysContent != "" {
|
||||||
|
messages = append(messages, chatMessage{Role: "system", Content: sysContent})
|
||||||
|
}
|
||||||
|
messages = append(messages, chatMessage{Role: "user", Content: userPrompt})
|
||||||
|
|
||||||
|
body := chatRequest{
|
||||||
|
Model: c.Model,
|
||||||
|
Messages: messages,
|
||||||
|
Temperature: 0.1,
|
||||||
|
MaxTokens: 512,
|
||||||
|
}
|
||||||
|
if c.NoThinking {
|
||||||
|
// llama.cpp: chat_template_kwargs
|
||||||
|
body.ChatTemplateKwargs = map[string]any{
|
||||||
|
"enable_thinking": false,
|
||||||
|
}
|
||||||
|
// Ollama (0.9+): think field
|
||||||
|
thinkFalse := false
|
||||||
|
body.Think = &thinkFalse
|
||||||
|
// GLM (智谱): thinking field
|
||||||
|
body.Thinking = map[string]any{
|
||||||
|
"type": "disabled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := strings.TrimRight(c.BaseURL, "/") + "/chat/completions"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.APIKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
var respBody []byte
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
backoff := time.Duration(1<<(attempt-1)) * time.Second // 1s, 2s, 4s, ...
|
||||||
|
log.Printf("LLM retry %d/%d after %v: %v", attempt, c.MaxRetries, backoff, lastErr)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
// Rebuild request (body reader is consumed)
|
||||||
|
req, err = http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.APIKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp *http.Response
|
||||||
|
resp, lastErr = c.Client.Do(req)
|
||||||
|
if lastErr != nil {
|
||||||
|
continue // network/timeout error → retry
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, lastErr = io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if lastErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
|
||||||
|
lastErr = fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
continue // rate limit or server error → retry
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return "", fmt.Errorf("after %d retries: %w", c.MaxRetries, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatResp chatResponse
|
||||||
|
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||||
|
return "", fmt.Errorf("parse response: %w", err)
|
||||||
|
}
|
||||||
|
if len(chatResp.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("no choices in response")
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(chatResp.Choices[0].Message.Content)
|
||||||
|
// Strip any residual <think>...</think> blocks
|
||||||
|
if idx := strings.Index(content, "</think>"); idx >= 0 {
|
||||||
|
content = strings.TrimSpace(content[idx+len("</think>"):])
|
||||||
|
}
|
||||||
|
// Fallback: GLM/DeepSeek put thinking output in reasoning_content when thinking is enabled
|
||||||
|
if content == "" && chatResp.Choices[0].Message.ReasoningContent != "" {
|
||||||
|
content = strings.TrimSpace(chatResp.Choices[0].Message.ReasoningContent)
|
||||||
|
}
|
||||||
|
if content == "" {
|
||||||
|
return "", fmt.Errorf("empty LLM response")
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
|
@ -19,6 +20,18 @@ var (
|
||||||
flagOut string
|
flagOut string
|
||||||
flagMode string
|
flagMode string
|
||||||
flagBudget int
|
flagBudget int
|
||||||
|
flagEvalMode string
|
||||||
|
flagAPIBase string
|
||||||
|
flagAPIKey string
|
||||||
|
flagModel string
|
||||||
|
flagNoThinking bool
|
||||||
|
flagLimit int
|
||||||
|
flagTimeout int
|
||||||
|
flagRetries int
|
||||||
|
flagJudgeModel string
|
||||||
|
flagJudgeAPIBase string
|
||||||
|
flagJudgeAPIKey string
|
||||||
|
flagConcurrency int
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -48,6 +61,22 @@ func main() {
|
||||||
evalCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
evalCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||||
evalCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to evaluate: legacy, seahorse, or all")
|
evalCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to evaluate: legacy, seahorse, or all")
|
||||||
evalCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
evalCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
||||||
|
evalCmd.Flags().
|
||||||
|
StringVar(&flagEvalMode, "eval-mode", "token", "evaluation mode: token (direct match) or llm (LLM-as-Judge)")
|
||||||
|
evalCmd.Flags().
|
||||||
|
StringVar(&flagAPIBase, "api-base", "", "API base URL with version path, e.g. http://host/v1 (default: http://127.0.0.1:8080/v1, env: MEMBENCH_API_BASE)")
|
||||||
|
evalCmd.Flags().StringVar(&flagAPIKey, "api-key", "", "API key for the LLM endpoint (env: MEMBENCH_API_KEY)")
|
||||||
|
evalCmd.Flags().StringVar(&flagModel, "model", "", "model name for LLM eval (env: MEMBENCH_MODEL)")
|
||||||
|
evalCmd.Flags().
|
||||||
|
BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)")
|
||||||
|
evalCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)")
|
||||||
|
evalCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests")
|
||||||
|
evalCmd.Flags().IntVar(&flagRetries, "retries", 3, "max retry attempts for transient LLM errors (timeout/5xx/429)")
|
||||||
|
evalCmd.Flags().StringVar(&flagJudgeModel, "judge-model", "", "model for judge scoring (defaults to --model)")
|
||||||
|
evalCmd.Flags().
|
||||||
|
StringVar(&flagJudgeAPIBase, "judge-api-base", "", "API base URL for judge model (defaults to --api-base)")
|
||||||
|
evalCmd.Flags().StringVar(&flagJudgeAPIKey, "judge-api-key", "", "API key for judge model (defaults to --api-key)")
|
||||||
|
evalCmd.Flags().IntVar(&flagConcurrency, "concurrency", 1, "number of concurrent QA evaluations")
|
||||||
|
|
||||||
reportCmd := &cobra.Command{
|
reportCmd := &cobra.Command{
|
||||||
Use: "report",
|
Use: "report",
|
||||||
|
|
@ -65,6 +94,22 @@ func main() {
|
||||||
runCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
runCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||||
runCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to run: legacy, seahorse, or all")
|
runCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to run: legacy, seahorse, or all")
|
||||||
runCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
runCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
||||||
|
runCmd.Flags().
|
||||||
|
StringVar(&flagEvalMode, "eval-mode", "token", "evaluation mode: token (direct match) or llm (LLM-as-Judge)")
|
||||||
|
runCmd.Flags().
|
||||||
|
StringVar(&flagAPIBase, "api-base", "", "API base URL with version path, e.g. http://host/v1 (default: http://127.0.0.1:8080/v1, env: MEMBENCH_API_BASE)")
|
||||||
|
runCmd.Flags().StringVar(&flagAPIKey, "api-key", "", "API key for the LLM endpoint (env: MEMBENCH_API_KEY)")
|
||||||
|
runCmd.Flags().StringVar(&flagModel, "model", "", "model name for LLM eval (env: MEMBENCH_MODEL)")
|
||||||
|
runCmd.Flags().
|
||||||
|
BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)")
|
||||||
|
runCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)")
|
||||||
|
runCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests")
|
||||||
|
runCmd.Flags().IntVar(&flagRetries, "retries", 3, "max retry attempts for transient LLM errors (timeout/5xx/429)")
|
||||||
|
runCmd.Flags().StringVar(&flagJudgeModel, "judge-model", "", "model for judge scoring (defaults to --model)")
|
||||||
|
runCmd.Flags().
|
||||||
|
StringVar(&flagJudgeAPIBase, "judge-api-base", "", "API base URL for judge model (defaults to --api-base)")
|
||||||
|
runCmd.Flags().StringVar(&flagJudgeAPIKey, "judge-api-key", "", "API key for judge model (defaults to --api-key)")
|
||||||
|
runCmd.Flags().IntVar(&flagConcurrency, "concurrency", 1, "number of concurrent QA evaluations")
|
||||||
|
|
||||||
rootCmd.AddCommand(ingestCmd, evalCmd, reportCmd, runCmd)
|
rootCmd.AddCommand(ingestCmd, evalCmd, reportCmd, runCmd)
|
||||||
|
|
||||||
|
|
@ -136,7 +181,50 @@ func runEval(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
log.Printf("Loaded %d samples", len(samples))
|
log.Printf("Loaded %d samples", len(samples))
|
||||||
|
|
||||||
var allResults []EvalResult
|
if flagLimit > 0 {
|
||||||
|
for i := range samples {
|
||||||
|
if len(samples[i].QA) > flagLimit {
|
||||||
|
samples[i].QA = samples[i].QA[:flagLimit]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("Limited to %d QA per sample", flagLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
evalMode := strings.ToLower(strings.TrimSpace(flagEvalMode))
|
||||||
|
var useLLM bool
|
||||||
|
switch evalMode {
|
||||||
|
case "token":
|
||||||
|
useLLM = false
|
||||||
|
case "llm":
|
||||||
|
useLLM = true
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid --eval-mode %q: must be token or llm", flagEvalMode)
|
||||||
|
}
|
||||||
|
var answerClient, judgeClient *LLMClient
|
||||||
|
if useLLM {
|
||||||
|
opts, err := buildLLMOptions()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
answerClient = NewLLMClient(opts)
|
||||||
|
judgeClient = answerClient // default: same client
|
||||||
|
if flagJudgeModel != "" {
|
||||||
|
jOpts := opts // copy base settings
|
||||||
|
jOpts.Model = flagJudgeModel
|
||||||
|
if flagJudgeAPIBase != "" {
|
||||||
|
jOpts.BaseURL = flagJudgeAPIBase
|
||||||
|
}
|
||||||
|
if flagJudgeAPIKey != "" {
|
||||||
|
jOpts.APIKey = flagJudgeAPIKey
|
||||||
|
}
|
||||||
|
judgeClient = NewLLMClient(jOpts)
|
||||||
|
log.Printf("Judge model: model=%s base=%s no-thinking=%v", jOpts.Model, jOpts.BaseURL, jOpts.NoThinking)
|
||||||
|
}
|
||||||
|
log.Printf("LLM eval mode: model=%s base=%s no-thinking=%v concurrency=%d",
|
||||||
|
opts.Model, opts.BaseURL, opts.NoThinking, flagConcurrency)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResults, llmResults []EvalResult
|
||||||
|
|
||||||
for _, mode := range modes {
|
for _, mode := range modes {
|
||||||
switch mode {
|
switch mode {
|
||||||
|
|
@ -145,21 +233,34 @@ func runEval(cmd *cobra.Command, args []string) error {
|
||||||
for i := range samples {
|
for i := range samples {
|
||||||
legacy.IngestSample(&samples[i])
|
legacy.IngestSample(&samples[i])
|
||||||
}
|
}
|
||||||
|
if useLLM {
|
||||||
|
results := EvalLegacyLLM(ctx, samples, legacy, flagBudget, answerClient, judgeClient, flagConcurrency)
|
||||||
|
llmResults = append(llmResults, results...)
|
||||||
|
log.Printf("legacy-llm: evaluated %d samples", len(results))
|
||||||
|
} else {
|
||||||
results := EvalLegacy(ctx, samples, legacy, flagBudget)
|
results := EvalLegacy(ctx, samples, legacy, flagBudget)
|
||||||
allResults = append(allResults, results...)
|
tokenResults = append(tokenResults, results...)
|
||||||
log.Printf("legacy: evaluated %d samples", len(results))
|
log.Printf("legacy: evaluated %d samples", len(results))
|
||||||
|
}
|
||||||
case "seahorse":
|
case "seahorse":
|
||||||
dbPath := filepath.Join(flagOut, "seahorse.db")
|
dbPath := filepath.Join(flagOut, "seahorse.db")
|
||||||
ir, err := IngestSeahorse(ctx, samples, dbPath)
|
ir, err := IngestSeahorse(ctx, samples, dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ingest seahorse: %w", err)
|
return fmt.Errorf("ingest seahorse: %w", err)
|
||||||
}
|
}
|
||||||
|
if useLLM {
|
||||||
|
results := EvalSeahorseLLM(ctx, samples, ir, flagBudget, answerClient, judgeClient, flagConcurrency)
|
||||||
|
llmResults = append(llmResults, results...)
|
||||||
|
log.Printf("seahorse-llm: evaluated %d samples", len(results))
|
||||||
|
} else {
|
||||||
results := EvalSeahorse(ctx, samples, ir, flagBudget)
|
results := EvalSeahorse(ctx, samples, ir, flagBudget)
|
||||||
allResults = append(allResults, results...)
|
tokenResults = append(tokenResults, results...)
|
||||||
log.Printf("seahorse: evaluated %d samples", len(results))
|
log.Printf("seahorse: evaluated %d samples", len(results))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allResults := append(tokenResults, llmResults...)
|
||||||
if err := SaveResults(allResults, flagOut); err != nil {
|
if err := SaveResults(allResults, flagOut); err != nil {
|
||||||
return fmt.Errorf("save results: %w", err)
|
return fmt.Errorf("save results: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -167,7 +268,7 @@ func runEval(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("save aggregated: %w", err)
|
return fmt.Errorf("save aggregated: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
PrintComparison(allResults, nil)
|
PrintComparison(tokenResults, llmResults)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,10 +300,62 @@ func runReport(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("no eval results found in %s", flagOut)
|
return fmt.Errorf("no eval results found in %s", flagOut)
|
||||||
}
|
}
|
||||||
|
|
||||||
PrintComparison(allResults, nil)
|
var tokenResults, llmResults []EvalResult
|
||||||
|
for _, r := range allResults {
|
||||||
|
if strings.HasSuffix(r.Mode, "-llm") {
|
||||||
|
llmResults = append(llmResults, r)
|
||||||
|
} else {
|
||||||
|
tokenResults = append(tokenResults, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PrintComparison(tokenResults, llmResults)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runAll(cmd *cobra.Command, args []string) error {
|
func runAll(cmd *cobra.Command, args []string) error {
|
||||||
return runEval(cmd, args)
|
return runEval(cmd, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envOrFlag returns the flag value if non-empty, otherwise falls back to the
|
||||||
|
// environment variable.
|
||||||
|
func envOrFlag(flag, envKey string) string {
|
||||||
|
if flag != "" {
|
||||||
|
return flag
|
||||||
|
}
|
||||||
|
return os.Getenv(envKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildLLMOptions resolves LLM client configuration from flags and environment
|
||||||
|
// variables. Flag values take precedence over environment variables.
|
||||||
|
//
|
||||||
|
// Environment variables:
|
||||||
|
//
|
||||||
|
// MEMBENCH_API_BASE – OpenAI-compatible base URL (default http://127.0.0.1:8080/v1)
|
||||||
|
// MEMBENCH_API_KEY – Bearer token for the endpoint
|
||||||
|
// MEMBENCH_MODEL – Model name to send in the request
|
||||||
|
func buildLLMOptions() (LLMClientOptions, error) {
|
||||||
|
base := envOrFlag(flagAPIBase, "MEMBENCH_API_BASE")
|
||||||
|
if base == "" {
|
||||||
|
base = "http://127.0.0.1:8080/v1"
|
||||||
|
}
|
||||||
|
model := envOrFlag(flagModel, "MEMBENCH_MODEL")
|
||||||
|
if model == "" {
|
||||||
|
return LLMClientOptions{}, fmt.Errorf(
|
||||||
|
"--model or MEMBENCH_MODEL is required for LLM eval mode",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
apiKey := envOrFlag(flagAPIKey, "MEMBENCH_API_KEY")
|
||||||
|
|
||||||
|
if flagTimeout <= 0 {
|
||||||
|
return LLMClientOptions{}, fmt.Errorf("--timeout must be > 0, got %d", flagTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
return LLMClientOptions{
|
||||||
|
BaseURL: base,
|
||||||
|
Model: model,
|
||||||
|
APIKey: apiKey,
|
||||||
|
NoThinking: flagNoThinking,
|
||||||
|
Timeout: time.Duration(flagTimeout) * time.Second,
|
||||||
|
MaxRetries: flagRetries,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,24 +17,24 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity, antigravity"
|
||||||
defaultAnthropicModel = "claude-sonnet-4.6"
|
defaultAnthropicModel = "claude-sonnet-4.6"
|
||||||
)
|
)
|
||||||
|
|
||||||
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {
|
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool, noBrowser bool) error {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "openai":
|
case "openai":
|
||||||
return authLoginOpenAI(useDeviceCode)
|
return authLoginOpenAI(useDeviceCode, noBrowser)
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
return authLoginAnthropic(useOauth)
|
return authLoginAnthropic(useOauth)
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
return authLoginGoogleAntigravity()
|
return authLoginGoogleAntigravity(noBrowser)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
|
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func authLoginOpenAI(useDeviceCode bool) error {
|
func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error {
|
||||||
cfg := auth.OpenAIOAuthConfig()
|
cfg := auth.OpenAIOAuthConfig()
|
||||||
|
|
||||||
var cred *auth.AuthCredential
|
var cred *auth.AuthCredential
|
||||||
|
|
@ -43,7 +43,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
||||||
if useDeviceCode {
|
if useDeviceCode {
|
||||||
cred, err = auth.LoginDeviceCode(cfg)
|
cred, err = auth.LoginDeviceCode(cfg)
|
||||||
} else {
|
} else {
|
||||||
cred, err = auth.LoginBrowser(cfg)
|
cred, err = auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -92,10 +92,10 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func authLoginGoogleAntigravity() error {
|
func authLoginGoogleAntigravity(noBrowser bool) error {
|
||||||
cfg := auth.GoogleAntigravityOAuthConfig()
|
cfg := auth.GoogleAntigravityOAuthConfig()
|
||||||
|
|
||||||
cred, err := auth.LoginBrowser(cfg)
|
cred, err := auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("login failed: %w", err)
|
return fmt.Errorf("login failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ func newLoginCommand() *cobra.Command {
|
||||||
provider string
|
provider string
|
||||||
useDeviceCode bool
|
useDeviceCode bool
|
||||||
useOauth bool
|
useOauth bool
|
||||||
|
noBrowser bool
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|
@ -14,12 +15,15 @@ func newLoginCommand() *cobra.Command {
|
||||||
Short: "Login via OAuth or paste token",
|
Short: "Login via OAuth or paste token",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
return authLoginCmd(provider, useDeviceCode, useOauth)
|
return authLoginCmd(provider, useDeviceCode, useOauth, noBrowser)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
|
cmd.Flags().StringVarP(
|
||||||
|
&provider, "provider", "p", "", "Provider to login with (openai, anthropic, google-antigravity, antigravity)",
|
||||||
|
)
|
||||||
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
||||||
|
cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Do not auto-open a browser during OAuth login")
|
||||||
cmd.Flags().BoolVar(
|
cmd.Flags().BoolVar(
|
||||||
&useOauth, "setup-token", false,
|
&useOauth, "setup-token", false,
|
||||||
"Use setup-token flow for Anthropic (from `claude setup-token`)",
|
"Use setup-token flow for Anthropic (from `claude setup-token`)",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ func TestNewLoginSubCommand(t *testing.T) {
|
||||||
assert.True(t, cmd.HasFlags())
|
assert.True(t, cmd.HasFlags())
|
||||||
|
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("device-code"))
|
assert.NotNil(t, cmd.Flags().Lookup("device-code"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("no-browser"))
|
||||||
|
|
||||||
providerFlag := cmd.Flags().Lookup("provider")
|
providerFlag := cmd.Flags().Lookup("provider")
|
||||||
require.NotNil(t, providerFlag)
|
require.NotNil(t, providerFlag)
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,34 @@ package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/gateway"
|
"github.com/sipeed/picoclaw/pkg/gateway"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/netbind"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func resolveGatewayHostOverride(explicit bool, host string) (string, error) {
|
||||||
|
if !explicit {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
normalized, err := netbind.NormalizeHostInput(host)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid --host value: %w", err)
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
func NewGatewayCommand() *cobra.Command {
|
func NewGatewayCommand() *cobra.Command {
|
||||||
var debug bool
|
var debug bool
|
||||||
var noTruncate bool
|
var noTruncate bool
|
||||||
var allowEmpty bool
|
var allowEmpty bool
|
||||||
|
var host string
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "gateway",
|
Use: "gateway",
|
||||||
|
|
@ -33,7 +48,25 @@ func NewGatewayCommand() *cobra.Command {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
resolvedHost, err := resolveGatewayHostOverride(cmd.Flags().Changed("host"), host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resolvedHost != "" {
|
||||||
|
prevHost, hadPrev := os.LookupEnv(config.EnvGatewayHost)
|
||||||
|
if err := os.Setenv(config.EnvGatewayHost, resolvedHost); err != nil {
|
||||||
|
return fmt.Errorf("failed to set %s: %w", config.EnvGatewayHost, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if hadPrev {
|
||||||
|
_ = os.Setenv(config.EnvGatewayHost, prevHost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Unsetenv(config.EnvGatewayHost)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty)
|
return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +80,12 @@ func NewGatewayCommand() *cobra.Command {
|
||||||
false,
|
false,
|
||||||
"Continue starting even when no default model is configured",
|
"Continue starting even when no default model is configured",
|
||||||
)
|
)
|
||||||
|
cmd.Flags().StringVar(
|
||||||
|
&host,
|
||||||
|
"host",
|
||||||
|
"",
|
||||||
|
"Host address for gateway binding (overrides gateway.host for this run)",
|
||||||
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,38 @@ func TestNewGatewayCommand(t *testing.T) {
|
||||||
assert.True(t, cmd.HasFlags())
|
assert.True(t, cmd.HasFlags())
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
|
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("host"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGatewayHostOverride(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
explicit bool
|
||||||
|
host string
|
||||||
|
wantHost string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "implicit empty host is allowed", explicit: false, host: "", wantHost: "", wantErr: false},
|
||||||
|
{name: "explicit empty host rejected", explicit: true, host: " ", wantHost: "", wantErr: true},
|
||||||
|
{name: "explicit localhost kept", explicit: true, host: " localhost ", wantHost: "localhost", wantErr: false},
|
||||||
|
{
|
||||||
|
name: "explicit multi host normalized",
|
||||||
|
explicit: true,
|
||||||
|
host: " [::1] , 127.0.0.1 ",
|
||||||
|
wantHost: "::1,127.0.0.1",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := resolveGatewayHostOverride(tt.explicit, tt.host)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("resolveGatewayHostOverride() err = %v, wantErr %t", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if got != tt.wantHost {
|
||||||
|
t.Fatalf("resolveGatewayHostOverride() host = %q, want %q", got, tt.wantHost)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,9 @@ func copyEmbeddedToTarget(targetDir string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
|
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
|
||||||
}
|
}
|
||||||
|
if new_path == "AGENTS.md" || new_path == "IDENTITY.md" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Build target file path
|
// Build target file path
|
||||||
targetPath := filepath.Join(targetDir, new_path)
|
targetPath := filepath.Join(targetDir, new_path)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import (
|
||||||
|
|
||||||
type deps struct {
|
type deps struct {
|
||||||
workspace string
|
workspace string
|
||||||
installer *skills.SkillInstaller
|
|
||||||
skillsLoader *skills.SkillsLoader
|
skillsLoader *skills.SkillsLoader
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,15 +28,6 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
d.workspace = cfg.WorkspacePath()
|
d.workspace = cfg.WorkspacePath()
|
||||||
installer, err := skills.NewSkillInstaller(
|
|
||||||
d.workspace,
|
|
||||||
cfg.Tools.Skills.Github.Token.String(),
|
|
||||||
cfg.Tools.Skills.Github.Proxy,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error creating skills installer: %w", err)
|
|
||||||
}
|
|
||||||
d.installer = installer
|
|
||||||
|
|
||||||
// get global config directory and builtin skills directory
|
// get global config directory and builtin skills directory
|
||||||
globalDir := filepath.Dir(internal.GetConfigPath())
|
globalDir := filepath.Dir(internal.GetConfigPath())
|
||||||
|
|
@ -52,13 +42,6 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
installerFn := func() (*skills.SkillInstaller, error) {
|
|
||||||
if d.installer == nil {
|
|
||||||
return nil, fmt.Errorf("skills installer is not initialized")
|
|
||||||
}
|
|
||||||
return d.installer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
loaderFn := func() (*skills.SkillsLoader, error) {
|
loaderFn := func() (*skills.SkillsLoader, error) {
|
||||||
if d.skillsLoader == nil {
|
if d.skillsLoader == nil {
|
||||||
return nil, fmt.Errorf("skills loader is not initialized")
|
return nil, fmt.Errorf("skills loader is not initialized")
|
||||||
|
|
@ -75,10 +58,10 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
newListCommand(loaderFn),
|
newListCommand(loaderFn),
|
||||||
newInstallCommand(installerFn),
|
newInstallCommand(),
|
||||||
newInstallBuiltinCommand(workspaceFn),
|
newInstallBuiltinCommand(workspaceFn),
|
||||||
newListBuiltinCommand(),
|
newListBuiltinCommand(),
|
||||||
newRemoveCommand(installerFn),
|
newRemoveCommand(),
|
||||||
newSearchCommand(),
|
newSearchCommand(),
|
||||||
newShowCommand(loaderFn),
|
newShowCommand(loaderFn),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package skills
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -11,12 +12,23 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const skillsSearchMaxResults = 20
|
const skillsSearchMaxResults = 20
|
||||||
|
|
||||||
|
type installedSkillOriginMeta struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
OriginKind string `json:"origin_kind,omitempty"`
|
||||||
|
Registry string `json:"registry,omitempty"`
|
||||||
|
Slug string `json:"slug,omitempty"`
|
||||||
|
RegistryURL string `json:"registry_url,omitempty"`
|
||||||
|
InstalledVersion string `json:"installed_version,omitempty"`
|
||||||
|
InstalledAt int64 `json:"installed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
func skillsListCmd(loader *skills.SkillsLoader) {
|
func skillsListCmd(loader *skills.SkillsLoader) {
|
||||||
allSkills := loader.ListSkills()
|
allSkills := loader.ListSkills()
|
||||||
|
|
||||||
|
|
@ -35,61 +47,32 @@ func skillsListCmd(loader *skills.SkillsLoader) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
|
|
||||||
fmt.Printf("Installing skill from %s...\n", repo)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
|
|
||||||
return fmt.Errorf("failed to install skill: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
||||||
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {
|
func skillsInstallFromRegistry(cfg *config.Config, registryName, target string) error {
|
||||||
err := utils.ValidateSkillIdentifier(registryName)
|
err := utils.ValidateSkillIdentifier(registryName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("✗ invalid registry name: %w", err)
|
return fmt.Errorf("✗ invalid registry name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = utils.ValidateSkillIdentifier(slug)
|
registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("✗ invalid slug: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
|
||||||
|
|
||||||
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
|
||||||
ClawHub: skills.ClawHubConfig{
|
|
||||||
Enabled: clawHubConfig.Enabled,
|
|
||||||
BaseURL: clawHubConfig.BaseURL,
|
|
||||||
AuthToken: clawHubConfig.AuthToken.String(),
|
|
||||||
SearchPath: clawHubConfig.SearchPath,
|
|
||||||
SkillsPath: clawHubConfig.SkillsPath,
|
|
||||||
DownloadPath: clawHubConfig.DownloadPath,
|
|
||||||
Timeout: clawHubConfig.Timeout,
|
|
||||||
MaxZipSize: clawHubConfig.MaxZipSize,
|
|
||||||
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
registry := registryMgr.GetRegistry(registryName)
|
registry := registryMgr.GetRegistry(registryName)
|
||||||
if registry == nil {
|
if registry == nil {
|
||||||
return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
|
return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dirName, err := registry.ResolveInstallDirName(target)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("✗ invalid install target %q: %w", target, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Installing skill '%s' from %s registry...\n", target, registryName)
|
||||||
|
|
||||||
workspace := cfg.WorkspacePath()
|
workspace := cfg.WorkspacePath()
|
||||||
targetDir := filepath.Join(workspace, "skills", slug)
|
targetDir := filepath.Join(workspace, "skills", dirName)
|
||||||
|
|
||||||
if _, err = os.Stat(targetDir); err == nil {
|
if _, err = os.Stat(targetDir); err == nil {
|
||||||
return fmt.Errorf("\u2717 skill '%s' already installed at %s", slug, targetDir)
|
return fmt.Errorf("\u2717 skill '%s' already installed at %s", dirName, targetDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
|
@ -99,7 +82,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
||||||
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
|
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
|
result, err := registry.DownloadAndInstall(ctx, target, "", targetDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rmErr := os.RemoveAll(targetDir)
|
rmErr := os.RemoveAll(targetDir)
|
||||||
if rmErr != nil {
|
if rmErr != nil {
|
||||||
|
|
@ -114,14 +97,34 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
||||||
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
|
return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", target)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.IsSuspicious {
|
if result.IsSuspicious {
|
||||||
fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", slug)
|
fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", target)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", slug, result.Version)
|
if !workspaceHasValidSkillDirectory(workspace, dirName) {
|
||||||
|
_ = os.RemoveAll(targetDir)
|
||||||
|
return fmt.Errorf("✗ failed to install skill: registry archive for %q is not a valid skill", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedSlug, registryURL := skills.BuildInstallMetadataForRegistryInstance(registry, target, result.Version)
|
||||||
|
installedAt := time.Now().UnixMilli()
|
||||||
|
if err := writeInstalledSkillOriginMeta(targetDir, installedSkillOriginMeta{
|
||||||
|
Version: 1,
|
||||||
|
OriginKind: "third_party",
|
||||||
|
Registry: registry.Name(),
|
||||||
|
Slug: normalizedSlug,
|
||||||
|
RegistryURL: registryURL,
|
||||||
|
InstalledVersion: result.Version,
|
||||||
|
InstalledAt: installedAt,
|
||||||
|
}); err != nil {
|
||||||
|
_ = os.RemoveAll(targetDir)
|
||||||
|
return fmt.Errorf("✗ failed to persist skill metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", dirName, result.Version)
|
||||||
if result.Summary != "" {
|
if result.Summary != "" {
|
||||||
fmt.Printf(" %s\n", result.Summary)
|
fmt.Printf(" %s\n", result.Summary)
|
||||||
}
|
}
|
||||||
|
|
@ -129,15 +132,51 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
func writeInstalledSkillOriginMeta(targetDir string, meta installedSkillOriginMeta) error {
|
||||||
fmt.Printf("Removing skill '%s'...\n", skillName)
|
data, err := json.MarshalIndent(meta, "", " ")
|
||||||
|
if err != nil {
|
||||||
if err := installer.Uninstall(skillName); err != nil {
|
return err
|
||||||
fmt.Printf("✗ Failed to remove skill: %v\n", err)
|
}
|
||||||
os.Exit(1)
|
return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
|
func workspaceHasValidSkillDirectory(workspace, directory string) bool {
|
||||||
|
loader := skills.NewSkillsLoader(workspace, "", "")
|
||||||
|
for _, skill := range loader.ListSkills() {
|
||||||
|
if skill.Source != "workspace" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filepath.Base(filepath.Dir(skill.Path)) == directory {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillsRemoveFromWorkspace(workspace string, toolsConfig config.SkillsToolsConfig, skillName string) error {
|
||||||
|
name := strings.TrimSpace(skillName)
|
||||||
|
name = strings.Trim(name, "/")
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("skill name is required")
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "/") {
|
||||||
|
dirName, err := skills.GitHubInstallDirNameFromToolsConfig(toolsConfig, name)
|
||||||
|
if err != nil || dirName == "" {
|
||||||
|
return fmt.Errorf("invalid skill name %q", skillName)
|
||||||
|
}
|
||||||
|
name = dirName
|
||||||
|
}
|
||||||
|
if name == "." || name == ".." {
|
||||||
|
return fmt.Errorf("invalid skill name %q", skillName)
|
||||||
|
}
|
||||||
|
skillDir := filepath.Join(workspace, "skills", name)
|
||||||
|
if _, err := os.Stat(skillDir); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("skill '%s' not found", name)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(skillDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove skill '%s': %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsInstallBuiltinCmd(workspace string) {
|
func skillsInstallBuiltinCmd(workspace string) {
|
||||||
|
|
@ -237,21 +276,7 @@ func skillsSearchCmd(query string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills)
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
|
||||||
ClawHub: skills.ClawHubConfig{
|
|
||||||
Enabled: clawHubConfig.Enabled,
|
|
||||||
BaseURL: clawHubConfig.BaseURL,
|
|
||||||
AuthToken: clawHubConfig.AuthToken.String(),
|
|
||||||
SearchPath: clawHubConfig.SearchPath,
|
|
||||||
SkillsPath: clawHubConfig.SkillsPath,
|
|
||||||
DownloadPath: clawHubConfig.DownloadPath,
|
|
||||||
Timeout: clawHubConfig.Timeout,
|
|
||||||
MaxZipSize: clawHubConfig.MaxZipSize,
|
|
||||||
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
|
||||||
191
cmd/picoclaw/internal/skills/helpers_test.go
Normal file
191
cmd/picoclaw/internal/skills/helpers_test.go
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSkillsInstallFromRegistryWritesOriginMetadata(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Workspace = workspace
|
||||||
|
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v3/repos/foo/bar":
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"}))
|
||||||
|
case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
|
||||||
|
assert.Equal(t, "ref=master", r.URL.RawQuery)
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode([]map[string]any{{
|
||||||
|
"type": "file",
|
||||||
|
"name": "SKILL.md",
|
||||||
|
"download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
|
||||||
|
}}))
|
||||||
|
case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
|
||||||
|
_, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n"))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
|
||||||
|
require.True(t, ok)
|
||||||
|
githubRegistry.BaseURL = server.URL
|
||||||
|
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
|
||||||
|
|
||||||
|
target := server.URL + "/foo/bar/tree/master/.agents/skills/pr-review"
|
||||||
|
require.NoError(t, skillsInstallFromRegistry(cfg, "github", target))
|
||||||
|
|
||||||
|
metaPath := filepath.Join(workspace, "skills", "pr-review", ".skill-origin.json")
|
||||||
|
data, err := os.ReadFile(metaPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var meta installedSkillOriginMeta
|
||||||
|
require.NoError(t, json.Unmarshal(data, &meta))
|
||||||
|
assert.Equal(t, "third_party", meta.OriginKind)
|
||||||
|
assert.Equal(t, "github", meta.Registry)
|
||||||
|
assert.Equal(t, "foo/bar/.agents/skills/pr-review", meta.Slug)
|
||||||
|
assert.Equal(t, server.URL+"/foo/bar/tree/master/.agents/skills/pr-review", meta.RegistryURL)
|
||||||
|
assert.Equal(t, "master", meta.InstalledVersion)
|
||||||
|
assert.NotZero(t, meta.InstalledAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsInstallFromRegistryRejectsInvalidSkillArchive(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Workspace = workspace
|
||||||
|
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v3/repos/foo/bar":
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"}))
|
||||||
|
case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode([]map[string]any{{
|
||||||
|
"type": "file",
|
||||||
|
"name": "SKILL.md",
|
||||||
|
"download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
|
||||||
|
}}))
|
||||||
|
case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
|
||||||
|
_, _ = w.Write([]byte("---\nname: bad_skill\ndescription: Invalid skill name\n---\n# Invalid\n"))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
|
||||||
|
require.True(t, ok)
|
||||||
|
githubRegistry.BaseURL = server.URL
|
||||||
|
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
|
||||||
|
|
||||||
|
target := server.URL + "/foo/bar/tree/master/.agents/skills/pr-review"
|
||||||
|
err := skillsInstallFromRegistry(cfg, "github", target)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "is not a valid skill")
|
||||||
|
_, statErr := os.Stat(filepath.Join(workspace, "skills", "pr-review"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsRemoveFromWorkspaceRejectsDotTarget(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
skillsDir := filepath.Join(workspace, "skills")
|
||||||
|
require.NoError(t, os.MkdirAll(skillsDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(skillsDir, "keep.txt"), []byte("keep"), 0o644))
|
||||||
|
|
||||||
|
err := skillsRemoveFromWorkspace(workspace, config.DefaultConfig().Tools.Skills, ".")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid skill name")
|
||||||
|
|
||||||
|
_, statErr := os.Stat(skillsDir)
|
||||||
|
assert.NoError(t, statErr)
|
||||||
|
_, fileErr := os.Stat(filepath.Join(skillsDir, "keep.txt"))
|
||||||
|
assert.NoError(t, fileErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsRemoveFromWorkspaceUsesLastPathSegment(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
targetDir := filepath.Join(workspace, "skills", "pr-review")
|
||||||
|
require.NoError(t, os.MkdirAll(targetDir, 0o755))
|
||||||
|
|
||||||
|
err := skillsRemoveFromWorkspace(
|
||||||
|
workspace,
|
||||||
|
config.DefaultConfig().Tools.Skills,
|
||||||
|
"https://github.com/foo/bar/tree/main/.agents/skills/pr-review",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, statErr := os.Stat(targetDir)
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsRemoveFromWorkspaceSupportsRepoRootGitHubBlobURL(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
targetDir := filepath.Join(workspace, "skills", "bar")
|
||||||
|
require.NoError(t, os.MkdirAll(targetDir, 0o755))
|
||||||
|
|
||||||
|
err := skillsRemoveFromWorkspace(
|
||||||
|
workspace,
|
||||||
|
config.DefaultConfig().Tools.Skills,
|
||||||
|
"https://github.com/foo/bar/blob/feature/skills-registry/SKILL.md",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, statErr := os.Stat(targetDir)
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsRemoveFromWorkspaceSupportsGitHubEnterpriseURL(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
targetDir := filepath.Join(workspace, "skills", "pr-review")
|
||||||
|
require.NoError(t, os.MkdirAll(targetDir, 0o755))
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
|
||||||
|
require.True(t, ok)
|
||||||
|
githubRegistry.BaseURL = "https://ghe.example.com/git"
|
||||||
|
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
|
||||||
|
|
||||||
|
err := skillsRemoveFromWorkspace(
|
||||||
|
workspace,
|
||||||
|
cfg.Tools.Skills,
|
||||||
|
"https://ghe.example.com/git/foo/bar/tree/main/.agents/skills/pr-review",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, statErr := os.Stat(targetDir)
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsRemoveFromWorkspaceDoesNotRequireEnabledGitHubRegistry(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
targetDir := filepath.Join(workspace, "skills", "pr-review")
|
||||||
|
require.NoError(t, os.MkdirAll(targetDir, 0o755))
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
|
||||||
|
require.True(t, ok)
|
||||||
|
githubRegistry.Enabled = false
|
||||||
|
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
|
||||||
|
|
||||||
|
err := skillsRemoveFromWorkspace(
|
||||||
|
workspace,
|
||||||
|
cfg.Tools.Skills,
|
||||||
|
"https://github.com/foo/bar/tree/main/.agents/skills/pr-review",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, statErr := os.Stat(targetDir)
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
@ -6,15 +6,14 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
func newInstallCommand() *cobra.Command {
|
||||||
var registry string
|
var registry string
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "install",
|
Use: "install",
|
||||||
Short: "Install skill from GitHub",
|
Short: "Install skill from GitHub or a registry",
|
||||||
Example: `
|
Example: `
|
||||||
picoclaw skills install sipeed/picoclaw-skills/weather
|
picoclaw skills install sipeed/picoclaw-skills/weather
|
||||||
picoclaw skills install --registry clawhub github
|
picoclaw skills install --registry clawhub github
|
||||||
|
|
@ -34,21 +33,15 @@ picoclaw skills install --registry clawhub github
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
RunE: func(_ *cobra.Command, args []string) error {
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
installer, err := installerFn()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if registry != "" {
|
|
||||||
cfg, err := internal.LoadConfig()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if registry != "" {
|
||||||
return skillsInstallFromRegistry(cfg, registry, args[0])
|
return skillsInstallFromRegistry(cfg, registry, args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
return skillsInstallCmd(installer, args[0])
|
return skillsInstallFromRegistry(cfg, "github", args[0])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewInstallSubcommand(t *testing.T) {
|
func TestNewInstallSubcommand(t *testing.T) {
|
||||||
cmd := newInstallCommand(nil)
|
cmd := newInstallCommand()
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
assert.Equal(t, "install", cmd.Use)
|
assert.Equal(t, "install", cmd.Use)
|
||||||
assert.Equal(t, "Install skill from GitHub", cmd.Short)
|
assert.Equal(t, "Install skill from GitHub or a registry", cmd.Short)
|
||||||
|
|
||||||
assert.Nil(t, cmd.Run)
|
assert.Nil(t, cmd.Run)
|
||||||
assert.NotNil(t, cmd.RunE)
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
@ -79,7 +79,7 @@ func TestInstallCommandArgs(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
cmd := newInstallCommand(nil)
|
cmd := newInstallCommand()
|
||||||
|
|
||||||
if tt.registry != "" {
|
if tt.registry != "" {
|
||||||
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ package skills
|
||||||
import (
|
import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
func newRemoveCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "remove",
|
Use: "remove",
|
||||||
Aliases: []string{"rm", "uninstall"},
|
Aliases: []string{"rm", "uninstall"},
|
||||||
|
|
@ -14,12 +14,11 @@ func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
Example: `picoclaw skills remove weather`,
|
Example: `picoclaw skills remove weather`,
|
||||||
RunE: func(_ *cobra.Command, args []string) error {
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
installer, err := installerFn()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
skillsRemoveCmd(installer, args[0])
|
return skillsRemoveFromWorkspace(cfg.WorkspacePath(), cfg.Tools.Skills, args[0])
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewRemoveSubcommand(t *testing.T) {
|
func TestNewRemoveSubcommand(t *testing.T) {
|
||||||
cmd := newRemoveCommand(nil)
|
cmd := newRemoveCommand()
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -286,10 +286,15 @@
|
||||||
"base_url": "",
|
"base_url": "",
|
||||||
"max_results": 0
|
"max_results": 0
|
||||||
},
|
},
|
||||||
"duckduckgo": {
|
"provider": "auto",
|
||||||
|
"sogou": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
|
"duckduckgo": {
|
||||||
|
"enabled": false,
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
"perplexity": {
|
"perplexity": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "pplx-xxx",
|
"api_key": "pplx-xxx",
|
||||||
|
|
@ -399,9 +404,16 @@
|
||||||
"timeout": 0,
|
"timeout": 0,
|
||||||
"max_zip_size": 0,
|
"max_zip_size": 0,
|
||||||
"max_response_size": 0
|
"max_response_size": 0
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"enabled": true,
|
||||||
|
"base_url": "https://github.com",
|
||||||
|
"auth_token": "",
|
||||||
|
"proxy": "http://127.0.0.1:7891"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"github": {
|
"github": {
|
||||||
|
"base_url": "https://github.com",
|
||||||
"proxy": "http://127.0.0.1:7891",
|
"proxy": "http://127.0.0.1:7891",
|
||||||
"token": ""
|
"token": ""
|
||||||
},
|
},
|
||||||
|
|
@ -482,7 +494,7 @@
|
||||||
},
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
|
"_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
|
||||||
"host": "127.0.0.1",
|
"host": "localhost",
|
||||||
"port": 18790,
|
"port": 18790,
|
||||||
"hot_reload": false,
|
"hot_reload": false,
|
||||||
"log_level": "fatal"
|
"log_level": "fatal"
|
||||||
|
|
|
||||||
86
docs/agent-refactor/loop-split.md
Normal file
86
docs/agent-refactor/loop-split.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# AgentLoop File Split
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `pkg/agent/loop.go` file (originally 4384 lines) has been split into 12 focused source files. This is a pure refactoring with no behavioral changes.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Reduce cognitive load when navigating agent loop code
|
||||||
|
- Enable parallel work by decoupling concerns
|
||||||
|
- Maintain all existing functionality and tests
|
||||||
|
- Keep imports minimal per file
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Lines | Responsibility |
|
||||||
|
|------|-------|----------------|
|
||||||
|
| `loop.go` | ~650 | Core `AgentLoop` struct, `Run`, `Stop`, `Close`, `ReloadProviderAndConfig`, `runAgentLoop` |
|
||||||
|
| `loop_turn.go` | ~1880 | Turn execution: `runTurn`, `abortTurn`, `selectCandidates`, `askSideQuestion`, `isolatedSideQuestionProvider`, side question model config |
|
||||||
|
| `loop_utils.go` | ~480 | Standalone utility functions: formatters, cloners, helpers (no receiver) |
|
||||||
|
| `loop_init.go` | ~355 | `NewAgentLoop` constructor and `registerSharedTools` |
|
||||||
|
| `loop_message.go` | ~300 | Message handling: `processMessage`, `processSystemMessage`, routing helpers, `ProcessDirect`, `ProcessHeartbeat` |
|
||||||
|
| `loop_command.go` | ~265 | Command processing: `handleCommand`, `applyExplicitSkillCommand`, pending skills management |
|
||||||
|
| `loop_mcp.go` | ~235 | MCP runtime: `ensureMCPInitialized`, server discovery, deferred server handling |
|
||||||
|
| `loop_event.go` | ~205 | Event system helpers: `emitEvent`, `logEvent`, `hookAbortError`, `newTurnEventScope`, `MountHook`, `SubscribeEvents` |
|
||||||
|
| `loop_media.go` | ~198 | Media resolution: `resolveMediaRefs`, artifact building, MIME detection |
|
||||||
|
| `loop_outbound.go` | ~165 | Response publishing: `PublishResponseIfNeeded`, `publishPicoReasoning`, `handleReasoning` |
|
||||||
|
| `loop_transcribe.go` | ~110 | Audio transcription: `transcribeAudioInMessage`, `sendTranscriptionFeedback` |
|
||||||
|
| `loop_steering.go` | ~97 | Steering queue: `runTurnWithSteering`, `processMessageSync`, `resolveSteeringTarget` |
|
||||||
|
| `loop_inject.go` | ~104 | Setter injection: `SetChannelManager`, `SetMediaStore`, `SetTranscriber`, `GetRegistry`, `GetConfig`, `RecordLastChannel` |
|
||||||
|
|
||||||
|
## Core Principles Applied
|
||||||
|
|
||||||
|
### 1. Same Package, Independent Files
|
||||||
|
All files belong to the `agent` package and compile together. This preserves the original visibility rules — no interface abstraction was introduced in this phase.
|
||||||
|
|
||||||
|
### 2. No Logic Changes
|
||||||
|
All functions were moved verbatim (except updating import statements). The extraction script used the original `loop.go.backup` as source of truth to ensure no drift.
|
||||||
|
|
||||||
|
### 3. Shared Types Remain in loop.go
|
||||||
|
The `AgentLoop` struct, `processOptions`, `continuationTarget`, and all hook/event types stay in `loop.go` since they are referenced across files.
|
||||||
|
|
||||||
|
### 4. Turn State Is Central
|
||||||
|
`loop_turn.go` is the largest file because the turn lifecycle (`runTurn`) is inherently large. It contains the core LLM interaction loop, tool execution, subturn spawning, and steering injection.
|
||||||
|
|
||||||
|
## What's Left in loop.go
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Core struct
|
||||||
|
type AgentLoop struct { ... }
|
||||||
|
|
||||||
|
// Main lifecycle
|
||||||
|
func (al *AgentLoop) Run(ctx context.Context) error
|
||||||
|
func (al *AgentLoop) Stop()
|
||||||
|
func (al *AgentLoop) Close()
|
||||||
|
func (al *AgentLoop) ReloadProviderAndConfig(ctx, provider, cfg)
|
||||||
|
|
||||||
|
// Turn orchestration (calls into loop_turn.go)
|
||||||
|
func (al *AgentLoop) runAgentLoop(ctx, agent, opts) (string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extraction Method
|
||||||
|
|
||||||
|
The split was done programmatically using Node.js to:
|
||||||
|
1. Identify function boundaries using brace counting
|
||||||
|
2. Extract each function to its target file
|
||||||
|
3. Add necessary imports to each file
|
||||||
|
4. Remove the extracted function from loop.go
|
||||||
|
5. Run `go fmt` and `go vet` to verify
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
All existing tests pass. The 5 failing tests (`TestGlobalSkillFileContentChange` and 4 Seahorse tests) are pre-existing failures unrelated to this refactor (database file locking issues on Windows).
|
||||||
|
|
||||||
|
Build status: `go build ./pkg/agent/...` passes with no errors.
|
||||||
|
|
||||||
|
## Phase 2: Dependency Inversion (Planned)
|
||||||
|
|
||||||
|
A future phase will introduce interface types to decouple `AgentLoop` from its dependencies, enabling:
|
||||||
|
- Easier testing with mock dependencies
|
||||||
|
- Alternative runtime configurations
|
||||||
|
- Cleaner boundaries for MCP and other extensions
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [context.md](context.md) — context management and session handling
|
||||||
|
|
@ -62,7 +62,7 @@ picoclaw gateway
|
||||||
|
|
||||||
**4. Telegram command menu (auto-registered at startup)**
|
**4. Telegram command menu (auto-registered at startup)**
|
||||||
|
|
||||||
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`, `/use`) so command menu and runtime behavior stay in sync.
|
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) so command menu and runtime behavior stay in sync.
|
||||||
Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
|
Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
|
||||||
|
|
||||||
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
|
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
|
||||||
|
|
@ -73,6 +73,7 @@ You can also manage installed skills directly from Telegram:
|
||||||
- `/use <skill> <message>`
|
- `/use <skill> <message>`
|
||||||
- `/use <skill>` and then send the actual request in the next message
|
- `/use <skill>` and then send the actual request in the next message
|
||||||
- `/use clear`
|
- `/use clear`
|
||||||
|
- `/btw <question>` to ask an immediate side question without changing the active session history; `/btw` is handled as a no-tool query and does not enter the normal tool-execution flow
|
||||||
|
|
||||||
**4. Advanced Formatting**
|
**4. Advanced Formatting**
|
||||||
You can set use_markdown_v2: true to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks.
|
You can set use_markdown_v2: true to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks.
|
||||||
|
|
|
||||||
|
|
@ -103,12 +103,14 @@ Once skills are installed, you can inspect and force them directly from a chat c
|
||||||
- `/use <skill> <message>` forces a specific skill for a single request.
|
- `/use <skill> <message>` forces a specific skill for a single request.
|
||||||
- `/use <skill>` arms that skill for your next message in the same chat session.
|
- `/use <skill>` arms that skill for your next message in the same chat session.
|
||||||
- `/use clear` cancels a pending skill override created by `/use <skill>`.
|
- `/use clear` cancels a pending skill override created by `/use <skill>`.
|
||||||
|
- `/btw <question>` asks an immediate side question without changing the current session history. `/btw` is handled as a no-tool query and does not enter the normal tool-execution flow.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/list skills
|
/list skills
|
||||||
/use git explain how to squash the last 3 commits
|
/use git explain how to squash the last 3 commits
|
||||||
|
/btw remind me what we already decided about the deploy plan
|
||||||
/use italiapersonalfinance
|
/use italiapersonalfinance
|
||||||
dammi le ultime news
|
dammi le ultime news
|
||||||
```
|
```
|
||||||
|
|
@ -116,137 +118,82 @@ dammi le ultime news
|
||||||
### Unified Command Execution Policy
|
### Unified Command Execution Policy
|
||||||
|
|
||||||
- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.
|
- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.
|
||||||
- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup.
|
- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands such as `/start`, `/help`, `/show`, `/list`, `/use`, and `/btw` at startup.
|
||||||
- Unknown slash command (for example `/foo`) passes through to normal LLM processing.
|
- Unknown slash command (for example `/foo`) passes through to normal LLM processing.
|
||||||
- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing.
|
- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing.
|
||||||
|
|
||||||
### Agent Bindings (Route messages to specific agents)
|
### Routing
|
||||||
|
|
||||||
Use `bindings` in `config.json` to route incoming messages to different agents by channel/account/context.
|
Routing is configured through `agents.dispatch.rules`.
|
||||||
|
|
||||||
|
Each rule matches against the normalized inbound context produced by channels.
|
||||||
|
Rules are evaluated from top to bottom. The first matching rule wins. If no
|
||||||
|
rule matches, PicoClaw falls back to the configured default agent.
|
||||||
|
|
||||||
|
Supported match fields:
|
||||||
|
|
||||||
|
* `channel`
|
||||||
|
* `account`
|
||||||
|
* `space`
|
||||||
|
* `chat`
|
||||||
|
* `topic`
|
||||||
|
* `sender`
|
||||||
|
* `mentioned`
|
||||||
|
|
||||||
|
Match values use the same scope vocabulary as the session system:
|
||||||
|
|
||||||
|
* `space`: `workspace:t001`, `guild:123456`
|
||||||
|
* `chat`: `direct:user123`, `group:-100123`, `channel:c123`
|
||||||
|
* `topic`: `topic:42`
|
||||||
|
* `sender`: a normalized sender identifier for the platform
|
||||||
|
|
||||||
|
Rules may optionally override the global `session.dimensions` value through
|
||||||
|
`session_dimensions`. This allows routing and session allocation to stay aligned
|
||||||
|
without reintroducing the old `bindings` or `dm_scope` formats.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
|
||||||
"workspace": "~/.picoclaw/workspace",
|
|
||||||
"model_name": "gpt-4o-mini"
|
|
||||||
},
|
|
||||||
"list": [
|
"list": [
|
||||||
{ "id": "main", "default": true, "name": "Main Assistant" },
|
{ "id": "main", "default": true },
|
||||||
{ "id": "support", "name": "Support Assistant" },
|
{ "id": "support" },
|
||||||
{ "id": "sales", "name": "Sales Assistant" }
|
{ "id": "sales" }
|
||||||
]
|
],
|
||||||
},
|
"dispatch": {
|
||||||
"bindings": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"agent_id": "support",
|
"name": "vip in support group",
|
||||||
"match": {
|
"agent": "sales",
|
||||||
|
"when": {
|
||||||
"channel": "telegram",
|
"channel": "telegram",
|
||||||
"account_id": "*",
|
"chat": "group:-1001234567890",
|
||||||
"peer": { "kind": "direct", "id": "user123" }
|
"sender": "12345"
|
||||||
}
|
},
|
||||||
|
"session_dimensions": ["chat", "sender"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"agent_id": "sales",
|
"name": "telegram support group",
|
||||||
"match": {
|
"agent": "support",
|
||||||
"channel": "discord",
|
"when": {
|
||||||
"account_id": "my-discord-bot",
|
"channel": "telegram",
|
||||||
"guild_id": "987654321"
|
"chat": "group:-1001234567890"
|
||||||
}
|
},
|
||||||
|
"session_dimensions": ["chat"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
},
|
||||||
|
"session": {
|
||||||
#### `bindings` fields
|
"dimensions": ["chat"]
|
||||||
|
|
||||||
| Field | Required | Description |
|
|
||||||
|-------|----------|-------------|
|
|
||||||
| `agent_id` | Yes | Target agent id in `agents.list` |
|
|
||||||
| `match.channel` | Yes | Channel name (e.g. `telegram`, `discord`) |
|
|
||||||
| `match.account_id` | No | Channel account filter. Use `"*"` for all accounts of that channel. If omitted, only default account is matched |
|
|
||||||
| `match.peer.kind` + `match.peer.id` | No | Exact peer match (e.g. direct chat / topic / group id) |
|
|
||||||
| `match.guild_id` | No | Guild/server-level match |
|
|
||||||
| `match.team_id` | No | Team/workspace-level match |
|
|
||||||
|
|
||||||
#### Matching priority
|
|
||||||
|
|
||||||
When multiple bindings exist, PicoClaw resolves in this order:
|
|
||||||
|
|
||||||
1. `peer`
|
|
||||||
2. `parent_peer` (for thread/topic parent contexts)
|
|
||||||
3. `guild_id`
|
|
||||||
4. `team_id`
|
|
||||||
5. `account_id` (non-wildcard)
|
|
||||||
6. channel wildcard (`account_id: "*"`)
|
|
||||||
7. default agent
|
|
||||||
|
|
||||||
If a binding points to a missing `agent_id`, PicoClaw falls back to the default agent.
|
|
||||||
|
|
||||||
#### How matching works (step-by-step)
|
|
||||||
|
|
||||||
1. PicoClaw first filters bindings by `match.channel` (must equal current channel).
|
|
||||||
2. It then filters by `match.account_id`:
|
|
||||||
- omitted: match only the channel's default account
|
|
||||||
- `"*"`: match all accounts on this channel
|
|
||||||
- explicit value: exact account id match (case-insensitive)
|
|
||||||
3. From the remaining candidates, it applies the priority chain above and stops at the first hit.
|
|
||||||
|
|
||||||
In other words: **channel + account form the candidate set; peer/guild/team then decide final winner**.
|
|
||||||
|
|
||||||
#### Common recipes
|
|
||||||
|
|
||||||
**1) Route one specific DM user to a specialist agent**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agent_id": "support",
|
|
||||||
"match": {
|
|
||||||
"channel": "telegram",
|
|
||||||
"account_id": "*",
|
|
||||||
"peer": { "kind": "direct", "id": "user123" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**2) Route one Discord server (guild) to a dedicated agent**
|
In the example above, the VIP rule must appear before the broader group rule.
|
||||||
|
Because routing is strictly ordered, more specific rules should be placed
|
||||||
```json
|
earlier and broader fallback rules later.
|
||||||
{
|
|
||||||
"agent_id": "sales",
|
|
||||||
"match": {
|
|
||||||
"channel": "discord",
|
|
||||||
"account_id": "my-discord-bot",
|
|
||||||
"guild_id": "987654321"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**3) Route all remaining traffic of a channel to a fallback agent**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agent_id": "main",
|
|
||||||
"match": {
|
|
||||||
"channel": "discord",
|
|
||||||
"account_id": "*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Authoring guidelines (important)
|
|
||||||
|
|
||||||
- Keep exactly one clear default agent in `agents.list` (`"default": true`).
|
|
||||||
- Put specific rules (`peer`, `guild_id`, `team_id`) and broad rules (`account_id: "*"` only) together safely; priority already guarantees specific rules win.
|
|
||||||
- Avoid duplicate rules with the same specificity and match values. If duplicates exist, the first matching entry in the config array wins.
|
|
||||||
- Ensure every `agent_id` exists in `agents.list`; unknown IDs silently fall back to default.
|
|
||||||
|
|
||||||
#### Troubleshooting checklist
|
|
||||||
|
|
||||||
- **Rule not taking effect?** Check `match.channel` spelling first (must be exact).
|
|
||||||
- **Expected account-specific routing but still using default?** Verify `match.account_id` equals actual runtime account id.
|
|
||||||
- **Wildcard catches too much traffic?** Add more specific `peer/guild/team` rules for critical paths.
|
|
||||||
- **Unexpected default fallback?** Confirm `agent_id` exists and is not misspelled.
|
|
||||||
|
|
||||||
### 🔒 Security Sandbox
|
### 🔒 Security Sandbox
|
||||||
|
|
||||||
|
|
@ -878,7 +825,8 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m
|
||||||
"model": "glm-4.7",
|
"model": "glm-4.7",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20,
|
||||||
|
"max_parallel_turns": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
@ -891,6 +839,8 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note**: The `providers` format is deprecated. Use the new `model_list` format with `.security.yml` for better security.
|
> **Note**: The `providers` format is deprecated. Use the new `model_list` format with `.security.yml` for better security.
|
||||||
|
>
|
||||||
|
> **`max_parallel_turns`**: Controls concurrent processing of messages from different sessions. `1` (default) = sequential; `>1` = parallel. Messages from the same session are always serialized. See [Steering docs](../steering.md) for details.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ graph TD
|
||||||
|
|
||||||
subgraph AgentLoop
|
subgraph AgentLoop
|
||||||
BUS[MessageBus]
|
BUS[MessageBus]
|
||||||
DRAIN[drainBusToSteering goroutine]
|
ROUTE{Session Routing}
|
||||||
|
WP[Worker Pool]
|
||||||
SQ[steeringQueue]
|
SQ[steeringQueue]
|
||||||
RLI[runLLMIteration]
|
RLI[runLLMIteration]
|
||||||
TE[Tool Execution Loop]
|
TE[Tool Execution Loop]
|
||||||
|
|
@ -37,8 +38,11 @@ graph TD
|
||||||
DC -->|PublishInbound| BUS
|
DC -->|PublishInbound| BUS
|
||||||
SL -->|PublishInbound| BUS
|
SL -->|PublishInbound| BUS
|
||||||
|
|
||||||
BUS -->|ConsumeInbound while busy| DRAIN
|
BUS -->|ConsumeInbound| ROUTE
|
||||||
DRAIN -->|Steer| SQ
|
ROUTE -->|no active turn| WP
|
||||||
|
ROUTE -->|active turn exists| SQ
|
||||||
|
WP -->|Steer| SQ
|
||||||
|
WP -->|process| RLI
|
||||||
|
|
||||||
RLI -->|1. initial poll| SQ
|
RLI -->|1. initial poll| SQ
|
||||||
TE -->|2. poll after each tool| SQ
|
TE -->|2. poll after each tool| SQ
|
||||||
|
|
@ -47,32 +51,34 @@ graph TD
|
||||||
RLI -->|inject into context| LLM
|
RLI -->|inject into context| LLM
|
||||||
```
|
```
|
||||||
|
|
||||||
### Bus drain mechanism
|
### Message routing and worker pool
|
||||||
|
|
||||||
Channels (Telegram, Discord, etc.) publish messages to the `MessageBus` via `PublishInbound`. Without additional wiring, these messages would sit in the bus buffer until the current `processMessage` finishes — meaning steering would never work for real users.
|
Channels (Telegram, Discord, etc.) publish messages to the `MessageBus` via `PublishInbound`. The `Run()` loop consumes messages from the bus and routes each one based on its **session key**:
|
||||||
|
|
||||||
The solution: when `Run()` starts processing a message, it spawns a **drain goroutine** (`drainBusToSteering`) that keeps consuming from the bus and calling `Steer()`. When `processMessage` returns, the drain is canceled and normal consumption resumes.
|
- **No active turn for the session**: The session key is atomically reserved via `LoadOrStore(sessionKey, struct{}{})`, and a **worker goroutine** is spawned to process the full turn lifecycle.
|
||||||
|
- **Active turn exists for the session**: The message is enqueued directly into the steering queue via `enqueueSteeringMessage`. It will be picked up by the existing worker's steering drain loop.
|
||||||
|
- **Non-routable (system)**: Processed synchronously in the main loop.
|
||||||
|
|
||||||
|
This enables **parallel processing of messages from different sessions** (up to `max_parallel_turns`) while keeping same-session messages strictly sequential.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant Bus
|
participant Bus
|
||||||
participant Run
|
participant Run
|
||||||
participant Drain
|
participant Worker
|
||||||
participant AgentLoop
|
participant SQ
|
||||||
|
|
||||||
Run->>Bus: ConsumeInbound() → msg
|
Run->>Bus: ConsumeInbound() → msg
|
||||||
Run->>Drain: spawn drainBusToSteering(ctx)
|
Run->>Run: resolveSteeringTarget(msg) → sessionKey
|
||||||
Run->>Run: processMessage(msg)
|
|
||||||
|
|
||||||
Note over Drain: running concurrently
|
alt no active turn
|
||||||
|
Run->>Run: LoadOrStore(sessionKey, sentinel)
|
||||||
Bus-->>Drain: ConsumeInbound() → newMsg
|
Run->>Worker: spawn worker goroutine
|
||||||
Drain->>AgentLoop: al.transcribeAudioInMessage(ctx, newMsg)
|
Worker->>Worker: processMessage(msg)
|
||||||
Drain->>AgentLoop: Steer(providers.Message{Content: newMsg.Content})
|
Worker->>SQ: drain steering after turn
|
||||||
|
else active turn exists
|
||||||
Run->>Run: processMessage returns
|
Run->>SQ: enqueueSteeringMessage(msg)
|
||||||
Run->>Drain: cancel context
|
end
|
||||||
Note over Drain: exits
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data Structures
|
## Data Structures
|
||||||
|
|
@ -121,7 +127,7 @@ A new field was added to `processOptions`:
|
||||||
| `Steer` | `Steer(msg providers.Message) error` | Enqueues a steering message. Returns an error if the queue is full or not initialized. Thread-safe, can be called from any goroutine. |
|
| `Steer` | `Steer(msg providers.Message) error` | Enqueues a steering message. Returns an error if the queue is full or not initialized. Thread-safe, can be called from any goroutine. |
|
||||||
| `SteeringMode` | `SteeringMode() SteeringMode` | Returns the current dequeue mode. |
|
| `SteeringMode` | `SteeringMode() SteeringMode` | Returns the current dequeue mode. |
|
||||||
| `SetSteeringMode` | `SetSteeringMode(mode SteeringMode)` | Changes the dequeue mode at runtime. |
|
| `SetSteeringMode` | `SetSteeringMode(mode SteeringMode)` | Changes the dequeue mode at runtime. |
|
||||||
| `Continue` | `Continue(ctx, sessionKey, channel, chatID) (string, error)` | Resumes an idle agent using pending steering messages. Returns `""` if queue is empty. |
|
| `Continue` | `Continue(ctx, sessionKey, channel, chatID) (string, error)` | Resumes an idle agent using pending steering messages for the given session. Returns `""` if queue is empty. Uses session-aware active turn checking (won't block on unrelated sessions). |
|
||||||
|
|
||||||
## Integration into the Agent Loop
|
## Integration into the Agent Loop
|
||||||
|
|
||||||
|
|
@ -280,15 +286,17 @@ flowchart TD
|
||||||
{
|
{
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"steering_mode": "one-at-a-time"
|
"steering_mode": "one-at-a-time",
|
||||||
|
"max_parallel_turns": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Default | Env var |
|
| Field | Type | Default | Env var | Description |
|
||||||
|-------|------|---------|---------|
|
|-------|------|---------|---------|-------------|
|
||||||
| `steering_mode` | `string` | `"one-at-a-time"` | `PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE` |
|
| `steering_mode` | `string` | `"one-at-a-time"` | `PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE` | How the steering queue is drained per poll |
|
||||||
|
| `max_parallel_turns` | `int` | `1` | `PICOCLAW_AGENTS_DEFAULTS_MAX_PARALLEL_TURNS` | Max concurrent turns. `0` or `1` = sequential; `>1` = parallel across sessions |
|
||||||
|
|
||||||
|
|
||||||
## Design decisions and trade-offs
|
## Design decisions and trade-offs
|
||||||
|
|
@ -300,7 +308,8 @@ flowchart TD
|
||||||
| `one-at-a-time` as default | Gives the model a chance to react to each steering message individually. More predictable behavior than dumping all messages at once. |
|
| `one-at-a-time` as default | Gives the model a chance to react to each steering message individually. More predictable behavior than dumping all messages at once. |
|
||||||
| Skipped tools get explicit error results | The LLM protocol requires a tool result for every tool call in the assistant message. Omitting them would cause API errors. The skip message also informs the model about what was not done. |
|
| Skipped tools get explicit error results | The LLM protocol requires a tool result for every tool call in the assistant message. Omitting them would cause API errors. The skip message also informs the model about what was not done. |
|
||||||
| `Continue()` uses `SkipInitialSteeringPoll` | Prevents race conditions and double-dequeuing when resuming an idle agent. |
|
| `Continue()` uses `SkipInitialSteeringPoll` | Prevents race conditions and double-dequeuing when resuming an idle agent. |
|
||||||
| Queue stored on `AgentLoop`, not `AgentInstance` | Steering is a loop-level concern (it affects the iteration flow), not a per-agent concern. All agents share the same steering queue since `processMessage` is sequential. |
|
| Queue stored on `AgentLoop`, not `AgentInstance` | Steering is a loop-level concern (it affects the iteration flow), not a per-agent concern. All agents share the steering queue since `processMessage` is sequential. |
|
||||||
| Bus drain goroutine in `Run()` | Channels (Telegram, Discord, etc.) publish to the bus via `PublishInbound`. Without the drain, messages would queue in the bus channel buffer and only be consumed after `processMessage` returns — defeating the purpose of steering. The drain goroutine bridges the gap by consuming new bus messages and calling `Steer()` while the agent is busy. |
|
| Worker pool dispatch in `Run()` | Messages are dispatched to a worker pool instead of a single sequential loop. The session key is atomically reserved via `LoadOrStore` before the worker starts, preventing TOCTOU races. Messages from the same session are serialized; different sessions are processed in parallel (up to `max_parallel_turns`). |
|
||||||
| Audio transcription before steering | The drain goroutine calls `al.transcribeAudioInMessage(ctx, msg)` before steering, so voice messages are converted to text before the agent sees them. If transcription fails, the error is silently discarded and the original message is steered as-is. |
|
| No bus drain goroutine | The old `drainBusToSteering` goroutine has been removed. The main `Run()` loop now checks `activeTurnStates` for each inbound message: if a turn is active for the session, the message is enqueued directly to the steering queue; otherwise a new worker is spawned. This eliminates the complexity of drain cancellation and requeuing. |
|
||||||
|
| Audio transcription in worker | Audio is transcribed within the worker that processes the turn, not in a separate drain goroutine. |
|
||||||
| `MaxQueueSize = 10` | Prevents unbounded memory growth if a user sends many messages while the agent is busy. Excess messages are dropped with a warning. |
|
| `MaxQueueSize = 10` | Prevents unbounded memory growth if a user sends many messages while the agent is busy. Excess messages are dropped with a warning. |
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,19 @@ picoclaw gateway
|
||||||
|
|
||||||
**4. Menu de commandes Telegram (enregistré automatiquement au démarrage)**
|
**4. Menu de commandes Telegram (enregistré automatiquement au démarrage)**
|
||||||
|
|
||||||
PicoClaw conserve les définitions de commandes dans un registre partagé unique. Au démarrage, Telegram enregistre automatiquement les commandes bot prises en charge (par exemple `/start`, `/help`, `/show`, `/list`) afin que le menu de commandes et le comportement à l'exécution restent synchronisés.
|
PicoClaw conserve les définitions de commandes dans un registre partagé unique. Au démarrage, Telegram enregistre automatiquement les commandes bot prises en charge (par exemple `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) afin que le menu de commandes et le comportement à l'exécution restent synchronisés.
|
||||||
L'enregistrement du menu de commandes Telegram reste une découverte UX locale au canal ; l'exécution générique des commandes est gérée de manière centralisée dans la boucle agent via l'exécuteur de commandes.
|
L'enregistrement du menu de commandes Telegram reste une découverte UX locale au canal ; l'exécution générique des commandes est gérée de manière centralisée dans la boucle agent via l'exécuteur de commandes.
|
||||||
|
|
||||||
Si l'enregistrement des commandes échoue (erreurs transitoires réseau/API), le canal démarre quand même et PicoClaw réessaie l'enregistrement en arrière-plan.
|
Si l'enregistrement des commandes échoue (erreurs transitoires réseau/API), le canal démarre quand même et PicoClaw réessaie l'enregistrement en arrière-plan.
|
||||||
|
|
||||||
|
Vous pouvez aussi gerer les competences installees directement depuis Telegram :
|
||||||
|
|
||||||
|
- `/list skills`
|
||||||
|
- `/use <skill> <message>`
|
||||||
|
- `/use <skill>` puis envoyer la vraie requete dans le message suivant
|
||||||
|
- `/use clear`
|
||||||
|
- `/btw <question>` pour poser une question annexe immediate sans modifier l'historique actif de la session ; `/btw` est traite comme une requete directe sans outils et n'entre pas dans le flux normal d'execution des outils
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<a id="discord"></a>
|
<a id="discord"></a>
|
||||||
|
|
|
||||||
|
|
@ -80,10 +80,30 @@ Pour les configurations avancées/de test, vous pouvez remplacer la racine des c
|
||||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Utiliser les Commandes Depuis les Canaux de Chat
|
||||||
|
|
||||||
|
Une fois les compétences installées, vous pouvez aussi les inspecter et les activer directement depuis un canal de chat :
|
||||||
|
|
||||||
|
- `/list skills` affiche les noms des compétences installées visibles pour l'agent courant.
|
||||||
|
- `/use <skill> <message>` force une compétence pour une seule requête.
|
||||||
|
- `/use <skill>` prépare cette compétence pour votre prochain message dans la meme conversation.
|
||||||
|
- `/use clear` annule une surcharge de compétence en attente creee via `/use <skill>`.
|
||||||
|
- `/btw <question>` pose une question annexe immediate sans modifier l'historique courant de la session. `/btw` est traite comme une requete directe sans outils et n'entre pas dans le flux normal d'execution des outils.
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
```text
|
||||||
|
/list skills
|
||||||
|
/use git explique comment squash les 3 derniers commits
|
||||||
|
/btw rappelle-moi ce qu'on a deja decide pour le plan de deploiement
|
||||||
|
/use italiapersonalfinance
|
||||||
|
dammi le ultime news
|
||||||
|
```
|
||||||
|
|
||||||
### Politique Unifiée d'Exécution des Commandes
|
### Politique Unifiée d'Exécution des Commandes
|
||||||
|
|
||||||
- Les commandes slash génériques sont exécutées via un chemin unique dans `pkg/agent/loop.go` via `commands.Executor`.
|
- Les commandes slash génériques sont exécutées via un chemin unique dans `pkg/agent/loop.go` via `commands.Executor`.
|
||||||
- Les adaptateurs de canaux ne consomment plus les commandes génériques localement ; ils transmettent le texte entrant au chemin bus/agent. Telegram enregistre toujours automatiquement les commandes prises en charge au démarrage.
|
- Les adaptateurs de canaux ne consomment plus les commandes génériques localement ; ils transmettent le texte entrant au chemin bus/agent. Telegram enregistre toujours automatiquement au démarrage les commandes prises en charge, comme `/start`, `/help`, `/show`, `/list`, `/use` et `/btw`.
|
||||||
- Une commande slash inconnue (par exemple `/foo`) passe au traitement LLM normal.
|
- Une commande slash inconnue (par exemple `/foo`) passe au traitement LLM normal.
|
||||||
- Une commande enregistrée mais non prise en charge sur le canal actuel (par exemple `/show` sur WhatsApp) renvoie une erreur explicite à l'utilisateur et arrête le traitement ultérieur.
|
- Une commande enregistrée mais non prise en charge sur le canal actuel (par exemple `/show` sur WhatsApp) renvoie une erreur explicite à l'utilisateur et arrête le traitement ultérieur.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ picoclaw gateway
|
||||||
|
|
||||||
**4. Telegram コマンドメニュー(起動時に自動登録)**
|
**4. Telegram コマンドメニュー(起動時に自動登録)**
|
||||||
|
|
||||||
PicoClaw は統一されたコマンド定義を使用します。起動時に Telegram がサポートするコマンド(例: `/start`、`/help`、`/show`、`/list`)を Bot コマンドメニューに自動登録し、メニュー表示と実際の動作を一致させます。
|
PicoClaw は統一されたコマンド定義を使用します。起動時に Telegram がサポートするコマンド(例: `/start`、`/help`、`/show`、`/list`、`/use`、`/btw`)を Bot コマンドメニューに自動登録し、メニュー表示と実際の動作を一致させます。
|
||||||
Telegram 側はコマンドメニュー登録機能を保持し、汎用コマンドの実行は Agent Loop 内の commands executor で統一的に処理されます。
|
Telegram 側はコマンドメニュー登録機能を保持し、汎用コマンドの実行は Agent Loop 内の commands executor で統一的に処理されます。
|
||||||
|
|
||||||
ネットワークや API の一時的なエラーで登録に失敗しても、チャネルの起動はブロックされません。システムがバックグラウンドで自動リトライします。
|
ネットワークや API の一時的なエラーで登録に失敗しても、チャネルの起動はブロックされません。システムがバックグラウンドで自動リトライします。
|
||||||
|
|
|
||||||
|
|
@ -81,10 +81,30 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw
|
||||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### チャットチャネルからスキルとコマンドを使う
|
||||||
|
|
||||||
|
スキルをインストールすると、チャットチャネルから直接確認したり明示的に適用したりできます:
|
||||||
|
|
||||||
|
- `/list skills` は現在の Agent から見えるインストール済みスキル名を表示します。
|
||||||
|
- `/use <skill> <message>` は 1 回のリクエストだけそのスキルを強制します。
|
||||||
|
- `/use <skill>` は同じチャット内の次のメッセージにそのスキルを予約します。
|
||||||
|
- `/use clear` は `/use <skill>` で設定した保留中のスキル上書きを解除します。
|
||||||
|
- `/btw <question>` は現在のセッション履歴を変更せずに即時の横道の質問を送ります。`/btw` はツールなしの直接質問として処理され、通常のツール実行フローには入りません。
|
||||||
|
|
||||||
|
例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/list skills
|
||||||
|
/use git 直近 3 つのコミットを squash する方法を教えて
|
||||||
|
/btw さっきのデプロイ方針の結論だけもう一度教えて
|
||||||
|
/use italiapersonalfinance
|
||||||
|
dammi le ultime news
|
||||||
|
```
|
||||||
|
|
||||||
### 統一コマンド実行ポリシー
|
### 統一コマンド実行ポリシー
|
||||||
|
|
||||||
- 汎用スラッシュコマンドは `pkg/agent/loop.go` 内の `commands.Executor` を通じて統一的に実行されます。
|
- 汎用スラッシュコマンドは `pkg/agent/loop.go` 内の `commands.Executor` を通じて統一的に実行されます。
|
||||||
- チャネルアダプターはローカルで汎用コマンドを消費しなくなりました。受信テキストを bus/agent パスに転送するだけです。Telegram は起動時にサポートするコマンドメニューを自動登録します。
|
- チャネルアダプターはローカルで汎用コマンドを消費しなくなりました。受信テキストを bus/agent パスに転送するだけです。Telegram は起動時に `/start`、`/help`、`/show`、`/list`、`/use`、`/btw` などのサポート済みコマンドを自動登録します。
|
||||||
- 未登録のスラッシュコマンド(例: `/foo`)は通常の LLM 処理にパススルーされます。
|
- 未登録のスラッシュコマンド(例: `/foo`)は通常の LLM 処理にパススルーされます。
|
||||||
- 登録済みだが現在のチャネルでサポートされていないコマンド(例: WhatsApp での `/show`)は、明示的なユーザー向けエラーを返し、以降の処理を停止します。
|
- 登録済みだが現在のチャネルでサポートされていないコマンド(例: WhatsApp での `/show`)は、明示的なユーザー向けエラーを返し、以降の処理を停止します。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,11 +60,19 @@ picoclaw gateway
|
||||||
|
|
||||||
**4. Menu arahan Telegram (auto-register semasa startup)**
|
**4. Menu arahan Telegram (auto-register semasa startup)**
|
||||||
|
|
||||||
PicoClaw kini menyimpan definisi arahan dalam satu registry bersama. Semasa startup, Telegram akan mendaftarkan arahan bot yang disokong secara automatik (contohnya `/start`, `/help`, `/show`, `/list`) supaya menu arahan dan tingkah laku runtime sentiasa selari.
|
PicoClaw kini menyimpan definisi arahan dalam satu registry bersama. Semasa startup, Telegram akan mendaftarkan arahan bot yang disokong secara automatik (contohnya `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) supaya menu arahan dan tingkah laku runtime sentiasa selari.
|
||||||
Pendaftaran menu arahan Telegram kekal sebagai UX penemuan setempat saluran; pelaksanaan arahan generik dikendalikan secara berpusat dalam gelung agen melalui commands executor.
|
Pendaftaran menu arahan Telegram kekal sebagai UX penemuan setempat saluran; pelaksanaan arahan generik dikendalikan secara berpusat dalam gelung agen melalui commands executor.
|
||||||
|
|
||||||
Jika pendaftaran arahan gagal (ralat sementara rangkaian/API), saluran tetap akan bermula dan PicoClaw akan mencuba semula pendaftaran di latar belakang.
|
Jika pendaftaran arahan gagal (ralat sementara rangkaian/API), saluran tetap akan bermula dan PicoClaw akan mencuba semula pendaftaran di latar belakang.
|
||||||
|
|
||||||
|
Anda juga boleh mengurus skill yang dipasang terus dari Telegram:
|
||||||
|
|
||||||
|
- `/list skills`
|
||||||
|
- `/use <skill> <message>`
|
||||||
|
- `/use <skill>` kemudian hantar permintaan sebenar dalam mesej seterusnya
|
||||||
|
- `/use clear`
|
||||||
|
- `/btw <question>` untuk bertanya soalan sampingan segera tanpa mengubah sejarah sesi aktif; `/btw` dikendalikan sebagai pertanyaan langsung tanpa tool dan tidak memasuki aliran pelaksanaan tool biasa
|
||||||
|
|
||||||
**4. Pemformatan Lanjutan**
|
**4. Pemformatan Lanjutan**
|
||||||
Anda boleh menetapkan `use_markdown_v2: true` untuk mengaktifkan pilihan pemformatan yang lebih maju. Ini membolehkan bot menggunakan keseluruhan set ciri Telegram MarkdownV2, termasuk gaya bersarang, spoiler, dan blok lebar tetap tersuai.
|
Anda boleh menetapkan `use_markdown_v2: true` untuk mengaktifkan pilihan pemformatan yang lebih maju. Ini membolehkan bot menggunakan keseluruhan set ciri Telegram MarkdownV2, termasuk gaya bersarang, spoiler, dan blok lebar tetap tersuai.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,10 +63,30 @@ Untuk setup lanjutan/ujian, anda boleh menindih root builtin skills dengan:
|
||||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Menggunakan Skill dan Arahan Dari Saluran Chat
|
||||||
|
|
||||||
|
Selepas skill dipasang, anda boleh menyemak dan memaksanya terus dari saluran chat:
|
||||||
|
|
||||||
|
- `/list skills` memaparkan nama skill dipasang yang kelihatan kepada agen semasa.
|
||||||
|
- `/use <skill> <message>` memaksa satu skill untuk satu permintaan sahaja.
|
||||||
|
- `/use <skill>` menyediakan skill itu untuk mesej anda yang seterusnya dalam chat yang sama.
|
||||||
|
- `/use clear` membatalkan skill override tertunda yang dibuat melalui `/use <skill>`.
|
||||||
|
- `/btw <question>` bertanya soalan sampingan segera tanpa mengubah sejarah sesi semasa. `/btw` dikendalikan sebagai pertanyaan langsung tanpa tool dan tidak memasuki aliran pelaksanaan tool biasa.
|
||||||
|
|
||||||
|
Contoh:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/list skills
|
||||||
|
/use git terangkan cara squash 3 commit terakhir
|
||||||
|
/btw ingatkan saya semula apa keputusan tadi untuk pelan deploy
|
||||||
|
/use italiapersonalfinance
|
||||||
|
dammi le ultime news
|
||||||
|
```
|
||||||
|
|
||||||
### Polisi Pelaksanaan Arahan Bersepadu
|
### Polisi Pelaksanaan Arahan Bersepadu
|
||||||
|
|
||||||
- Generic slash command dilaksanakan melalui satu laluan dalam `pkg/agent/loop.go` melalui `commands.Executor`.
|
- Generic slash command dilaksanakan melalui satu laluan dalam `pkg/agent/loop.go` melalui `commands.Executor`.
|
||||||
- Adapter saluran tidak lagi menggunakan generic command secara setempat; ia memajukan teks masuk ke laluan bus/agent. Telegram masih auto-register arahan yang disokong semasa startup.
|
- Adapter saluran tidak lagi menggunakan generic command secara setempat; ia memajukan teks masuk ke laluan bus/agent. Telegram masih auto-register arahan yang disokong semasa startup seperti `/start`, `/help`, `/show`, `/list`, `/use`, dan `/btw`.
|
||||||
- Slash command yang tidak dikenali (contohnya `/foo`) akan diteruskan ke pemprosesan LLM biasa.
|
- Slash command yang tidak dikenali (contohnya `/foo`) akan diteruskan ke pemprosesan LLM biasa.
|
||||||
- Arahan yang didaftarkan tetapi tidak disokong pada saluran semasa (contohnya `/show` di WhatsApp) akan memulangkan ralat yang jelas kepada pengguna dan menghentikan pemprosesan lanjut.
|
- Arahan yang didaftarkan tetapi tidak disokong pada saluran semasa (contohnya `/show` di WhatsApp) akan memulangkan ralat yang jelas kepada pengguna dan menghentikan pemprosesan lanjut.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,19 @@ picoclaw gateway
|
||||||
|
|
||||||
**4. Menu de comandos do Telegram (registrado automaticamente na inicialização)**
|
**4. Menu de comandos do Telegram (registrado automaticamente na inicialização)**
|
||||||
|
|
||||||
O PicoClaw agora mantém definições de comandos em um registro compartilhado. Na inicialização, o Telegram registrará automaticamente os comandos de bot suportados (por exemplo `/start`, `/help`, `/show`, `/list`) para que o menu de comandos e o comportamento em tempo de execução permaneçam sincronizados.
|
O PicoClaw agora mantém definições de comandos em um registro compartilhado. Na inicialização, o Telegram registrará automaticamente os comandos de bot suportados (por exemplo `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) para que o menu de comandos e o comportamento em tempo de execução permaneçam sincronizados.
|
||||||
O registro do menu de comandos do Telegram permanece como descoberta UX local do canal; a execução genérica de comandos é tratada centralmente no loop do agente via commands executor.
|
O registro do menu de comandos do Telegram permanece como descoberta UX local do canal; a execução genérica de comandos é tratada centralmente no loop do agente via commands executor.
|
||||||
|
|
||||||
Se o registro de comandos falhar (erros transitórios de rede/API), o canal ainda inicia e o PicoClaw tenta novamente o registro em segundo plano.
|
Se o registro de comandos falhar (erros transitórios de rede/API), o canal ainda inicia e o PicoClaw tenta novamente o registro em segundo plano.
|
||||||
|
|
||||||
|
Voce tambem pode gerenciar skills instaladas diretamente pelo Telegram:
|
||||||
|
|
||||||
|
- `/list skills`
|
||||||
|
- `/use <skill> <message>`
|
||||||
|
- `/use <skill>` e depois enviar a solicitacao real na proxima mensagem
|
||||||
|
- `/use clear`
|
||||||
|
- `/btw <question>` para fazer uma pergunta lateral imediata sem alterar o historico ativo da sessao; `/btw` e tratado como uma consulta direta sem ferramentas e nao entra no fluxo normal de execucao de ferramentas
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<a id="discord"></a>
|
<a id="discord"></a>
|
||||||
|
|
|
||||||
|
|
@ -81,10 +81,30 @@ Para configurações avançadas/de teste, você pode substituir o diretório rai
|
||||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Usando Skills e Comandos em Canais de Chat
|
||||||
|
|
||||||
|
Depois que as skills estiverem instaladas, voce pode inspeciona-las e aplica-las diretamente de um canal de chat:
|
||||||
|
|
||||||
|
- `/list skills` mostra os nomes das skills instaladas visiveis para o agente atual.
|
||||||
|
- `/use <skill> <message>` força uma skill para uma unica requisicao.
|
||||||
|
- `/use <skill>` prepara essa skill para a sua proxima mensagem no mesmo chat.
|
||||||
|
- `/use clear` cancela uma substituicao pendente criada por `/use <skill>`.
|
||||||
|
- `/btw <question>` faz uma pergunta lateral imediata sem alterar o historico atual da sessao. `/btw` e tratado como uma consulta direta sem ferramentas e nao entra no fluxo normal de execucao de ferramentas.
|
||||||
|
|
||||||
|
Exemplos:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/list skills
|
||||||
|
/use git explique como fazer squash dos ultimos 3 commits
|
||||||
|
/btw me relembre o que ja decidimos sobre o plano de deploy
|
||||||
|
/use italiapersonalfinance
|
||||||
|
dammi le ultime news
|
||||||
|
```
|
||||||
|
|
||||||
### Política Unificada de Execução de Comandos
|
### Política Unificada de Execução de Comandos
|
||||||
|
|
||||||
- Comandos slash genéricos são executados através de um único caminho em `pkg/agent/loop.go` via `commands.Executor`.
|
- Comandos slash genéricos são executados através de um único caminho em `pkg/agent/loop.go` via `commands.Executor`.
|
||||||
- Os adaptadores de canal não consomem mais comandos genéricos localmente; eles encaminham o texto de entrada para o caminho bus/agent. O Telegram ainda registra automaticamente os comandos suportados na inicialização.
|
- Os adaptadores de canal não consomem mais comandos genéricos localmente; eles encaminham o texto de entrada para o caminho bus/agent. O Telegram ainda registra automaticamente na inicialização comandos suportados como `/start`, `/help`, `/show`, `/list`, `/use` e `/btw`.
|
||||||
- Comando slash desconhecido (por exemplo `/foo`) passa para o processamento normal do LLM.
|
- Comando slash desconhecido (por exemplo `/foo`) passa para o processamento normal do LLM.
|
||||||
- Comando registrado mas não suportado no canal atual (por exemplo `/show` no WhatsApp) retorna um erro explícito ao usuário e interrompe o processamento.
|
- Comando registrado mas não suportado no canal atual (por exemplo `/show` no WhatsApp) retorna um erro explícito ao usuário e interrompe o processamento.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,13 +170,19 @@ This is saved to the session via `AddFullMessage` and sent to the model, so it i
|
||||||
|
|
||||||
## Automatic bus drain
|
## Automatic bus drain
|
||||||
|
|
||||||
When the agent loop (`Run()`) starts processing a message, it spawns a background goroutine that keeps consuming new inbound messages from the bus. These messages are automatically redirected into the steering queue via `Steer()`. This means:
|
When the agent loop (`Run()`) starts, it reads inbound messages from a shared message bus. The routing logic determines how each message is handled:
|
||||||
|
|
||||||
- Users on any channel (Telegram, Discord, etc.) don't need to do anything special — their messages are automatically captured as steering when the agent is busy
|
1. **No active turn for the message's session** — the message is dispatched to a **worker goroutine** that processes the full turn (LLM calls, tool execution, steering drain)
|
||||||
- Audio messages are transcribed before being steered, so the agent receives text. If transcription fails, the original (non-transcribed) message is steered as-is
|
2. **An active turn already exists for the same session** — the message is enqueued directly into that session's **steering queue** via `enqueueSteeringMessage`. No background drain goroutine is needed
|
||||||
- Only messages that resolve to the **same steering scope** as the active turn are redirected. Messages for other chats/sessions are requeued onto the inbound bus so they can be processed normally
|
3. **Non-routable message** (e.g. `system`) — processed synchronously in the main loop
|
||||||
- `system` inbound messages are not treated as steering input
|
|
||||||
- When `processMessage` finishes, the drain goroutine is canceled and normal message consumption resumes
|
This design enables **parallel processing of messages from different sessions** while keeping same-session messages strictly sequential. Key implications:
|
||||||
|
|
||||||
|
- Messages from different users/channels are processed **concurrently** (up to `max_parallel_turns`)
|
||||||
|
- Messages from the same session are **serialized** — subsequent messages go to the steering queue
|
||||||
|
- Users don't need to do anything special — their messages are automatically captured as steering when the agent is busy for their session
|
||||||
|
- Audio messages are transcribed within the worker that processes the turn, so the agent receives text
|
||||||
|
- `system` inbound messages are processed immediately and do not trigger steering
|
||||||
|
|
||||||
## Steering with media
|
## Steering with media
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,13 +112,17 @@ When the parent task is forcefully aborted (e.g., user interrupts with `/stop`):
|
||||||
|
|
||||||
## Agent Loop Integration
|
## Agent Loop Integration
|
||||||
|
|
||||||
### Bus Draining During Processing
|
### Message Routing and Steering
|
||||||
|
|
||||||
When a message enters the `Run()` loop, the agent starts a `drainBusToSteering` goroutine before calling `processMessage`. This goroutine runs concurrently with the entire processing lifecycle and continuously consumes any new inbound messages from the bus, redirecting them into the **steering queue** instead of dropping them.
|
When a message enters the `Run()` loop, the agent determines whether to start a new worker or enqueue to steering:
|
||||||
|
|
||||||
This ensures that if a user sends a follow-up message while the agent is processing (including during SubTurn execution), the message is not lost — it will be picked up between tool call iterations via `dequeueSteeringMessages`.
|
- If **no active turn** exists for the message's session key, the session is atomically reserved and a **worker goroutine** is spawned. The worker processes the full turn lifecycle: `processMessage` → tool execution → steering drain → `Continue` for queued messages.
|
||||||
|
- If an **active turn already exists** for the same session, the message is enqueued directly into that session's steering queue. It will be picked up by the existing worker's steering drain loop.
|
||||||
|
|
||||||
The drain goroutine stops automatically when `processMessage` returns (via a cancellable context).
|
This ensures that:
|
||||||
|
- Messages from **different sessions** are processed **in parallel** (up to `max_parallel_turns` concurrent workers)
|
||||||
|
- Messages from the **same session** are strictly **serialized** — they go to the steering queue and are processed sequentially within the active turn
|
||||||
|
- No background drain goroutine is needed; steering is handled by the worker itself after processing
|
||||||
|
|
||||||
### Pending Result Polling
|
### Pending Result Polling
|
||||||
|
|
||||||
|
|
@ -129,7 +133,7 @@ The agent loop polls for async SubTurn results at two points per iteration:
|
||||||
|
|
||||||
### Turn State Tracking
|
### Turn State Tracking
|
||||||
|
|
||||||
All active root turns are registered in `AgentLoop.activeTurnStates` (`sync.Map`, keyed by session key). This allows `HardAbort` and `/subagents` observability commands to find and operate on active turns.
|
All active turns are registered in `AgentLoop.activeTurnStates` (`sync.Map`, keyed by session key). A reservation sentinel is stored atomically via `LoadOrStore` before the worker starts, then replaced with the real `*turnState` when `runTurn` registers. This prevents a TOCTOU race where multiple messages for the same session could spawn concurrent workers. The sentinel is cleaned up by the worker's deferred cleanup. This allows `HardAbort` and `/subagents` observability commands to find and operate on active turns.
|
||||||
|
|
||||||
## Event Bus Integration
|
## Event Bus Integration
|
||||||
|
|
||||||
|
|
@ -181,10 +185,10 @@ Creates a new spawner instance for the given AgentLoop. Pass the returned value
|
||||||
### Continue
|
### Continue
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func (al *AgentLoop) Continue(ctx context.Context, sessionKey string) error
|
func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error)
|
||||||
```
|
```
|
||||||
|
|
||||||
Resumes an idle agent turn by injecting any queued steering messages as a new LLM iteration. Used when the agent is waiting and a deferred steering message needs to be processed without a new inbound message arriving.
|
Resumes an idle agent turn by dequeuing steering messages for the given session and running them through the agent loop. Returns the response string if processing occurred, or empty string if no steering messages were pending. Uses session-aware active turn checking — it only blocks if a turn is active for the *same* session, not for unrelated sessions.
|
||||||
|
|
||||||
## Context Propagation
|
## Context Propagation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -460,7 +460,7 @@ default (deferred). `aws` explicitly opts in to deferred mode even though it is
|
||||||
|
|
||||||
## Skills Tool
|
## Skills Tool
|
||||||
|
|
||||||
The skills tool configures skill discovery and installation via registries like ClawHub.
|
The skills tool configures skill discovery and installation via registries like ClawHub and GitHub.
|
||||||
|
|
||||||
### Registries
|
### Registries
|
||||||
|
|
||||||
|
|
@ -475,13 +475,20 @@ The skills tool configures skill discovery and installation via registries like
|
||||||
| `registries.clawhub.timeout` | int | 0 | Request timeout in seconds (0 = default) |
|
| `registries.clawhub.timeout` | int | 0 | Request timeout in seconds (0 = default) |
|
||||||
| `registries.clawhub.max_zip_size` | int | 0 | Max skill zip size in bytes (0 = default) |
|
| `registries.clawhub.max_zip_size` | int | 0 | Max skill zip size in bytes (0 = default) |
|
||||||
| `registries.clawhub.max_response_size` | int | 0 | Max API response size in bytes (0 = default) |
|
| `registries.clawhub.max_response_size` | int | 0 | Max API response size in bytes (0 = default) |
|
||||||
|
| `registries.github.enabled` | bool | true | Enable GitHub installs via registry config |
|
||||||
|
| `registries.github.base_url` | string | `https://github.com` | GitHub or GitHub Enterprise base URL |
|
||||||
|
| `registries.github.auth_token` | string | `""` | GitHub personal access token |
|
||||||
|
| `registries.github.proxy` | string | `""` | HTTP proxy for GitHub API requests |
|
||||||
|
|
||||||
### GitHub Integration
|
### Legacy GitHub Config
|
||||||
|
|
||||||
|
`github.*` is deprecated. Use `registries.github.*` instead. The legacy fields are still supported for compatibility and will be removed later.
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|------------------|--------|---------|--------------------------------------|
|
|--------------------|--------|----------------------|--------------------------------|
|
||||||
| `github.proxy` | string | `""` | HTTP proxy for GitHub API requests |
|
| `github.base_url` | string | `https://github.com` | Deprecated GitHub base URL |
|
||||||
| `github.token` | string | `""` | GitHub personal access token |
|
| `github.proxy` | string | `""` | Deprecated GitHub proxy |
|
||||||
|
| `github.token` | string | `""` | Deprecated GitHub token |
|
||||||
|
|
||||||
### Search Settings
|
### Search Settings
|
||||||
|
|
||||||
|
|
@ -501,10 +508,23 @@ The skills tool configures skill discovery and installation via registries like
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"base_url": "https://clawhub.ai",
|
"base_url": "https://clawhub.ai",
|
||||||
"auth_token": ""
|
"auth_token": "",
|
||||||
|
"search_path": "",
|
||||||
|
"skills_path": "",
|
||||||
|
"download_path": "",
|
||||||
|
"timeout": 0,
|
||||||
|
"max_zip_size": 0,
|
||||||
|
"max_response_size": 0
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"enabled": true,
|
||||||
|
"base_url": "https://github.com",
|
||||||
|
"auth_token": "",
|
||||||
|
"proxy": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"github": {
|
"github": {
|
||||||
|
"base_url": "https://github.com",
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
"token": ""
|
"token": ""
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,19 @@ picoclaw gateway
|
||||||
|
|
||||||
**4. Menu lệnh Telegram (tự động đăng ký khi khởi động)**
|
**4. Menu lệnh Telegram (tự động đăng ký khi khởi động)**
|
||||||
|
|
||||||
PicoClaw hiện lưu trữ định nghĩa lệnh trong một registry chung. Khi khởi động, Telegram sẽ tự động đăng ký các lệnh bot được hỗ trợ (ví dụ `/start`, `/help`, `/show`, `/list`) để menu lệnh và hành vi runtime luôn đồng bộ.
|
PicoClaw hiện lưu trữ định nghĩa lệnh trong một registry chung. Khi khởi động, Telegram sẽ tự động đăng ký các lệnh bot được hỗ trợ (ví dụ `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) để menu lệnh và hành vi runtime luôn đồng bộ.
|
||||||
Đăng ký menu lệnh Telegram vẫn là UX khám phá cục bộ của kênh; thực thi lệnh chung được xử lý tập trung trong vòng lặp agent qua commands executor.
|
Đăng ký menu lệnh Telegram vẫn là UX khám phá cục bộ của kênh; thực thi lệnh chung được xử lý tập trung trong vòng lặp agent qua commands executor.
|
||||||
|
|
||||||
Nếu đăng ký lệnh thất bại (lỗi tạm thời mạng/API), kênh vẫn khởi động và PicoClaw thử lại đăng ký trong nền.
|
Nếu đăng ký lệnh thất bại (lỗi tạm thời mạng/API), kênh vẫn khởi động và PicoClaw thử lại đăng ký trong nền.
|
||||||
|
|
||||||
|
Ban cung co the quan ly skill da cai dat truc tiep tu Telegram:
|
||||||
|
|
||||||
|
- `/list skills`
|
||||||
|
- `/use <skill> <message>`
|
||||||
|
- `/use <skill>` roi gui yeu cau that o tin nhan tiep theo
|
||||||
|
- `/use clear`
|
||||||
|
- `/btw <question>` de hoi them mot cau ngoai le ngay lap tuc ma khong thay doi lich su phien dang hoat dong; `/btw` duoc xu ly nhu mot truy van truc tiep khong dung cong cu va khong di vao luong thuc thi cong cu thong thuong
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<a id="discord"></a>
|
<a id="discord"></a>
|
||||||
|
|
|
||||||
|
|
@ -81,10 +81,30 @@ Cho thiết lập nâng cao/test, bạn có thể ghi đè thư mục gốc skil
|
||||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Dung Skill va Lenh Tu Kenh Chat
|
||||||
|
|
||||||
|
Sau khi cai dat skill, ban co the xem va ep dung truc tiep tu kenh chat:
|
||||||
|
|
||||||
|
- `/list skills` hien ten cac skill da cai dat ma agent hien tai co the dung.
|
||||||
|
- `/use <skill> <message>` ep dung mot skill cho duy nhat mot yeu cau.
|
||||||
|
- `/use <skill>` dat san skill do cho tin nhan tiep theo trong cung cuoc tro chuyen.
|
||||||
|
- `/use clear` huy skill override dang cho duoc tao boi `/use <skill>`.
|
||||||
|
- `/btw <question>` dat cau hoi phu ngay lap tuc ma khong thay doi lich su phien hien tai. `/btw` duoc xu ly nhu mot truy van truc tiep khong dung cong cu va khong di vao luong thuc thi cong cu thong thuong.
|
||||||
|
|
||||||
|
Vi du:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/list skills
|
||||||
|
/use git giai thich cach squash 3 commit cuoi
|
||||||
|
/btw nhac lai giup toi chung ta da chot gi cho ke hoach deploy
|
||||||
|
/use italiapersonalfinance
|
||||||
|
dammi le ultime news
|
||||||
|
```
|
||||||
|
|
||||||
### Chính Sách Thực Thi Lệnh Thống Nhất
|
### Chính Sách Thực Thi Lệnh Thống Nhất
|
||||||
|
|
||||||
- Lệnh slash chung được thực thi qua một đường dẫn duy nhất trong `pkg/agent/loop.go` qua `commands.Executor`.
|
- Lệnh slash chung được thực thi qua một đường dẫn duy nhất trong `pkg/agent/loop.go` qua `commands.Executor`.
|
||||||
- Adapter kênh không còn xử lý lệnh chung cục bộ; chúng chuyển tiếp văn bản đầu vào đến đường dẫn bus/agent. Telegram vẫn tự động đăng ký lệnh được hỗ trợ khi khởi động.
|
- Adapter kênh không còn xử lý lệnh chung cục bộ; chúng chuyển tiếp văn bản đầu vào đến đường dẫn bus/agent. Telegram vẫn tự động đăng ký khi khởi động các lệnh được hỗ trợ như `/start`, `/help`, `/show`, `/list`, `/use`, va `/btw`.
|
||||||
- Lệnh slash không xác định (ví dụ `/foo`) được chuyển sang xử lý LLM bình thường.
|
- Lệnh slash không xác định (ví dụ `/foo`) được chuyển sang xử lý LLM bình thường.
|
||||||
- Lệnh đã đăng ký nhưng không được hỗ trợ trên kênh hiện tại (ví dụ `/show` trên WhatsApp) trả về lỗi rõ ràng cho người dùng và dừng xử lý tiếp.
|
- Lệnh đã đăng ký nhưng không được hỗ trợ trên kênh hiện tại (ví dụ `/show` trên WhatsApp) trả về lỗi rõ ràng cho người dùng và dừng xử lý tiếp.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ picoclaw gateway
|
||||||
|
|
||||||
**4. Telegram 命令菜单(启动时自动注册)**
|
**4. Telegram 命令菜单(启动时自动注册)**
|
||||||
|
|
||||||
PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`、`/use`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
|
PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`、`/use`、`/btw`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
|
||||||
Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。
|
Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。
|
||||||
|
|
||||||
如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
|
如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
|
||||||
|
|
@ -76,6 +76,7 @@ Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行
|
||||||
- `/use <skill> <message>`
|
- `/use <skill> <message>`
|
||||||
- `/use <skill>`,然后在下一条消息里发送真正的请求
|
- `/use <skill>`,然后在下一条消息里发送真正的请求
|
||||||
- `/use clear`
|
- `/use clear`
|
||||||
|
- `/btw <question>`,用于发起一个不改动当前会话历史的即时旁支提问;`/btw` 会按一次无工具的直接问答处理,不会进入常规的工具执行流程
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,12 +101,14 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
- `/use <skill> <message>`:只对当前这一条请求强制使用指定技能。
|
- `/use <skill> <message>`:只对当前这一条请求强制使用指定技能。
|
||||||
- `/use <skill>`:为同一会话中的下一条消息预先启用该技能。
|
- `/use <skill>`:为同一会话中的下一条消息预先启用该技能。
|
||||||
- `/use clear`:取消通过 `/use <skill>` 设置的待应用技能。
|
- `/use clear`:取消通过 `/use <skill>` 设置的待应用技能。
|
||||||
|
- `/btw <question>`:发起一个即时的旁支提问,且不改动当前会话历史。`/btw` 会按一次无工具的直接问答处理,不会进入常规的工具执行流程。
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/list skills
|
/list skills
|
||||||
/use git explain how to squash the last 3 commits
|
/use git explain how to squash the last 3 commits
|
||||||
|
/btw 帮我回顾一下刚才关于发布方案的结论
|
||||||
/use italiapersonalfinance
|
/use italiapersonalfinance
|
||||||
dammi le ultime news
|
dammi le ultime news
|
||||||
```
|
```
|
||||||
|
|
@ -114,7 +116,7 @@ dammi le ultime news
|
||||||
### 统一命令执行策略
|
### 统一命令执行策略
|
||||||
|
|
||||||
- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。
|
- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。
|
||||||
- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。
|
- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单,例如 `/start`、`/help`、`/show`、`/list`、`/use` 和 `/btw`。
|
||||||
- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。
|
- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。
|
||||||
- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。
|
- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -462,3 +462,29 @@ Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。
|
||||||
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
||||||
|
|
||||||
注意:嵌套的映射式配置(例如 `tools.mcp.servers.<name>.*`)在 `config.json` 中配置,而非通过环境变量。
|
注意:嵌套的映射式配置(例如 `tools.mcp.servers.<name>.*`)在 `config.json` 中配置,而非通过环境变量。
|
||||||
|
|
||||||
|
## Skills Tool
|
||||||
|
|
||||||
|
Skills 工具用于通过仓库源发现和安装 Skill,支持 ClawHub 与 GitHub。
|
||||||
|
|
||||||
|
### Registries
|
||||||
|
|
||||||
|
| 配置项 | 类型 | 默认值 | 说明 |
|
||||||
|
|--------|------|--------|------|
|
||||||
|
| `registries.clawhub.enabled` | bool | true | 是否启用 ClawHub |
|
||||||
|
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub 基础地址 |
|
||||||
|
| `registries.clawhub.auth_token` | string | `""` | ClawHub 认证令牌 |
|
||||||
|
| `registries.github.enabled` | bool | true | 是否启用 GitHub |
|
||||||
|
| `registries.github.base_url` | string | `https://github.com` | GitHub 或 GitHub Enterprise 基础地址 |
|
||||||
|
| `registries.github.auth_token` | string | `""` | GitHub 访问令牌 |
|
||||||
|
| `registries.github.proxy` | string | `""` | GitHub 请求代理 |
|
||||||
|
|
||||||
|
### 旧版 GitHub 配置
|
||||||
|
|
||||||
|
`github.*` 已废弃,建议迁移到 `registries.github.*`。当前仍保留兼容,后续可移除。
|
||||||
|
|
||||||
|
| 配置项 | 类型 | 默认值 | 说明 |
|
||||||
|
|--------|------|--------|------|
|
||||||
|
| `github.base_url` | string | `https://github.com` | 已废弃 |
|
||||||
|
| `github.proxy` | string | `""` | 已废弃 |
|
||||||
|
| `github.token` | string | `""` | 已废弃 |
|
||||||
|
|
|
||||||
22
go.mod
22
go.mod
|
|
@ -19,15 +19,15 @@ require (
|
||||||
github.com/ergochat/irc-go v0.6.0
|
github.com/ergochat/irc-go v0.6.0
|
||||||
github.com/ergochat/readline v0.1.3
|
github.com/ergochat/readline v0.1.3
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
|
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/h2non/filetype v1.1.3
|
github.com/h2non/filetype v1.1.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/minio/selfupdate v0.6.0
|
github.com/minio/selfupdate v0.6.0
|
||||||
github.com/muesli/termenv v0.16.0
|
|
||||||
github.com/modelcontextprotocol/go-sdk v1.5.0
|
github.com/modelcontextprotocol/go-sdk v1.5.0
|
||||||
|
github.com/muesli/termenv v0.16.0
|
||||||
github.com/mymmrac/telego v1.8.0
|
github.com/mymmrac/telego v1.8.0
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||||
github.com/openai/openai-go/v3 v3.22.0
|
github.com/openai/openai-go/v3 v3.22.0
|
||||||
|
|
@ -40,14 +40,14 @@ require (
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tencent-connect/botgo v0.2.1
|
github.com/tencent-connect/botgo v0.2.1
|
||||||
go.mau.fi/util v0.9.7
|
go.mau.fi/util v0.9.8
|
||||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/term v0.41.0
|
golang.org/x/term v0.42.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
maunium.net/go/mautrix v0.26.4
|
maunium.net/go/mautrix v0.27.0
|
||||||
modernc.org/sqlite v1.48.2
|
modernc.org/sqlite v1.48.2
|
||||||
rsc.io/qr v0.2.0
|
rsc.io/qr v0.2.0
|
||||||
)
|
)
|
||||||
|
|
@ -88,9 +88,9 @@ require (
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
github.com/mattn/go-sqlite3 v1.14.42 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||||
github.com/pion/randutil v0.1.0 // indirect
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
|
@ -106,8 +106,8 @@ require (
|
||||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.36.0 // indirect
|
||||||
modernc.org/libc v1.70.0 // indirect
|
modernc.org/libc v1.70.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|
@ -136,8 +136,8 @@ require (
|
||||||
github.com/valyala/fastjson v1.6.10 // indirect
|
github.com/valyala/fastjson v1.6.10 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
golang.org/x/arch v0.24.0 // indirect
|
golang.org/x/arch v0.24.0 // indirect
|
||||||
golang.org/x/crypto v0.49.0
|
golang.org/x/crypto v0.50.0
|
||||||
golang.org/x/net v0.52.0
|
golang.org/x/net v0.53.0
|
||||||
golang.org/x/sync v0.20.0
|
golang.org/x/sync v0.20.0
|
||||||
golang.org/x/sys v0.43.0
|
golang.org/x/sys v0.43.0
|
||||||
)
|
)
|
||||||
|
|
|
||||||
54
go.sum
54
go.sum
|
|
@ -140,6 +140,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
|
||||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
|
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 h1:p7t34F7K4OCRQblcDhNJnP46Uaarz3z2cLcvOZYxWn8=
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
|
@ -193,16 +195,16 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||||
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
||||||
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
|
||||||
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
|
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
|
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
|
github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
|
||||||
github.com/mymmrac/telego v1.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
|
github.com/mymmrac/telego v1.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
|
@ -219,8 +221,8 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||||
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
||||||
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||||
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE=
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||||
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||||
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
|
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
|
||||||
|
|
@ -293,12 +295,12 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT
|
||||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
|
||||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532 h1:gxFHYeUDGziRb0zXYEqBFohC+NJbIW9L0tddaXMWr2o=
|
github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532 h1:gxFHYeUDGziRb0zXYEqBFohC+NJbIW9L0tddaXMWr2o=
|
||||||
|
|
@ -310,8 +312,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
|
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
|
||||||
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
|
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
|
||||||
go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg=
|
go.mau.fi/util v0.9.8 h1:+/jf8eM2dAT2wx9UidmaneH28r/CSCKCniCyby1qWz8=
|
||||||
go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE=
|
go.mau.fi/util v0.9.8/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0=
|
||||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
|
||||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
|
@ -334,16 +336,16 @@ golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWP
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
|
@ -358,8 +360,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
|
|
@ -400,8 +402,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
|
@ -409,8 +411,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|
@ -420,8 +422,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|
@ -451,8 +453,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs=
|
maunium.net/go/mautrix v0.27.0 h1:yfEYwoIluVWkofUgbZl9gP4i5nQTF+QNsxtb+r5bKlM=
|
||||||
maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M=
|
maunium.net/go/mautrix v0.27.0/go.mod h1:7QpEQiTy6p4LHkXXaZI+N46tGYy8HMhD0JjzZAFoFWs=
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
|
|
|
||||||
|
|
@ -685,43 +685,60 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
// tool result messages following it. This is required by strict providers
|
// tool result messages following it. This is required by strict providers
|
||||||
// like DeepSeek that enforce: "An assistant message with 'tool_calls' must
|
// like DeepSeek that enforce: "An assistant message with 'tool_calls' must
|
||||||
// be followed by tool messages responding to each 'tool_call_id'."
|
// be followed by tool messages responding to each 'tool_call_id'."
|
||||||
|
//
|
||||||
|
// Deduplication is scoped to the contiguous tool-result block that follows a
|
||||||
|
// single assistant tool-call message. Some providers legitimately reuse call
|
||||||
|
// IDs across separate turns (for example "call_0"), so global deduplication
|
||||||
|
// would incorrectly delete later valid tool results and leave an
|
||||||
|
// assistant(tool_calls) -> assistant sequence behind.
|
||||||
final := make([]providers.Message, 0, len(sanitized))
|
final := make([]providers.Message, 0, len(sanitized))
|
||||||
seenToolCallID := make(map[string]bool)
|
|
||||||
for i := 0; i < len(sanitized); i++ {
|
for i := 0; i < len(sanitized); i++ {
|
||||||
msg := sanitized[i]
|
msg := sanitized[i]
|
||||||
|
|
||||||
// Deduplicate tool results by ToolCallID
|
if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
|
||||||
if msg.Role == "tool" && msg.ToolCallID != "" {
|
expected := make(map[string]bool, len(msg.ToolCalls))
|
||||||
if seenToolCallID[msg.ToolCallID] {
|
invalidToolCallID := false
|
||||||
logger.DebugCF("agent", "Dropping duplicate tool result", map[string]any{
|
for _, tc := range msg.ToolCalls {
|
||||||
"tool_call_id": msg.ToolCallID,
|
if tc.ID == "" {
|
||||||
})
|
invalidToolCallID = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seenToolCallID[msg.ToolCallID] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
|
|
||||||
// Collect expected tool_call IDs
|
|
||||||
expected := make(map[string]bool, len(msg.ToolCalls))
|
|
||||||
for _, tc := range msg.ToolCalls {
|
|
||||||
expected[tc.ID] = false
|
expected[tc.ID] = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check following messages for matching tool results
|
block := make([]providers.Message, 0, len(expected))
|
||||||
toolMsgCount := 0
|
seenInBlock := make(map[string]bool, len(expected))
|
||||||
for j := i + 1; j < len(sanitized); j++ {
|
j := i + 1
|
||||||
if sanitized[j].Role != "tool" {
|
for ; j < len(sanitized); j++ {
|
||||||
|
next := sanitized[j]
|
||||||
|
if next.Role != "tool" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
toolMsgCount++
|
if next.ToolCallID == "" {
|
||||||
if _, exists := expected[sanitized[j].ToolCallID]; exists {
|
logger.DebugCF("agent", "Dropping tool result without tool_call_id", map[string]any{})
|
||||||
expected[sanitized[j].ToolCallID] = true
|
continue
|
||||||
}
|
}
|
||||||
|
if _, ok := expected[next.ToolCallID]; !ok {
|
||||||
|
logger.DebugCF("agent", "Dropping unexpected tool result", map[string]any{
|
||||||
|
"tool_call_id": next.ToolCallID,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seenInBlock[next.ToolCallID] {
|
||||||
|
logger.DebugCF("agent", "Dropping duplicate tool result in tool block", map[string]any{
|
||||||
|
"tool_call_id": next.ToolCallID,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenInBlock[next.ToolCallID] = true
|
||||||
|
expected[next.ToolCallID] = true
|
||||||
|
block = append(block, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If any tool_call_id is missing, drop this assistant message and its partial tool messages
|
allFound := !invalidToolCallID
|
||||||
allFound := true
|
if invalidToolCallID {
|
||||||
|
logger.DebugCF("agent", "Dropping assistant message with empty tool_call_id", map[string]any{})
|
||||||
|
}
|
||||||
for toolCallID, found := range expected {
|
for toolCallID, found := range expected {
|
||||||
if !found {
|
if !found {
|
||||||
allFound = false
|
allFound = false
|
||||||
|
|
@ -731,7 +748,7 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"missing_tool_call_id": toolCallID,
|
"missing_tool_call_id": toolCallID,
|
||||||
"expected_count": len(expected),
|
"expected_count": len(expected),
|
||||||
"found_count": toolMsgCount,
|
"found_count": len(block),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
@ -739,11 +756,23 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
if !allFound {
|
if !allFound {
|
||||||
// Skip this assistant message and its tool messages
|
i = j - 1
|
||||||
i += toolMsgCount
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final = append(final, msg)
|
||||||
|
final = append(final, block...)
|
||||||
|
i = j - 1
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if msg.Role == "tool" {
|
||||||
|
logger.DebugCF("agent", "Dropping orphaned tool message after validation", map[string]any{
|
||||||
|
"tool_call_id": msg.ToolCallID,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
final = append(final, msg)
|
final = append(final, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ func (m *legacyContextManager) Compact(_ context.Context, req *CompactRequest) e
|
||||||
if result, ok := m.forceCompression(req.SessionKey); ok {
|
if result, ok := m.forceCompression(req.SessionKey); ok {
|
||||||
m.al.emitEvent(
|
m.al.emitEvent(
|
||||||
EventKindContextCompress,
|
EventKindContextCompress,
|
||||||
m.al.newTurnEventScope("", req.SessionKey).meta(0, "forceCompression", "turn.context.compress"),
|
m.al.newTurnEventScope("", req.SessionKey, nil).meta(0, "forceCompression", "turn.context.compress"),
|
||||||
ContextCompressPayload{
|
ContextCompressPayload{
|
||||||
Reason: req.Reason,
|
Reason: req.Reason,
|
||||||
DroppedMessages: result.DroppedMessages,
|
DroppedMessages: result.DroppedMessages,
|
||||||
|
|
@ -247,7 +247,7 @@ func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey
|
||||||
agent.Sessions.Save(sessionKey)
|
agent.Sessions.Save(sessionKey)
|
||||||
m.al.emitEvent(
|
m.al.emitEvent(
|
||||||
EventKindSessionSummarize,
|
EventKindSessionSummarize,
|
||||||
m.al.newTurnEventScope(agent.ID, sessionKey).meta(0, "summarizeSession", "turn.session.summarize"),
|
m.al.newTurnEventScope(agent.ID, sessionKey, nil).meta(0, "summarizeSession", "turn.session.summarize"),
|
||||||
SessionSummarizePayload{
|
SessionSummarizePayload{
|
||||||
SummarizedMessages: len(validMessages),
|
SummarizedMessages: len(validMessages),
|
||||||
KeptMessages: keepCount,
|
KeptMessages: keepCount,
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,47 @@ func TestSanitizeHistoryForProvider_DuplicateToolResults(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSanitizeHistoryForProvider_ReusedToolCallIDAcrossRounds(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "first"),
|
||||||
|
assistantWithTools("call_0"),
|
||||||
|
toolResult("call_0"),
|
||||||
|
msg("assistant", "first done"),
|
||||||
|
msg("user", "second"),
|
||||||
|
assistantWithTools("call_0"),
|
||||||
|
toolResult("call_0"),
|
||||||
|
msg("assistant", "second done"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
if len(result) != 8 {
|
||||||
|
t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "assistant", "tool", "assistant", "user", "assistant", "tool", "assistant")
|
||||||
|
if result[2].ToolCallID != "call_0" || result[6].ToolCallID != "call_0" {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected both tool results to be preserved, got IDs %q and %q",
|
||||||
|
result[2].ToolCallID,
|
||||||
|
result[6].ToolCallID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeHistoryForProvider_DropsAssistantWithEmptyToolCallID(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "do something"),
|
||||||
|
assistantWithTools(""),
|
||||||
|
toolResult(""),
|
||||||
|
msg("assistant", "done"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
if len(result) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
func roles(msgs []providers.Message) []string {
|
func roles(msgs []providers.Message) []string {
|
||||||
r := make([]string, len(msgs))
|
r := make([]string, len(msgs))
|
||||||
for i, m := range msgs {
|
for i, m := range msgs {
|
||||||
|
|
|
||||||
147
pkg/agent/dispatch_request.go
Normal file
147
pkg/agent/dispatch_request.go
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DispatchRequest is the normalized runtime input passed into the agent loop
|
||||||
|
// after routing and session allocation have completed.
|
||||||
|
type DispatchRequest struct {
|
||||||
|
SessionKey string
|
||||||
|
SessionAliases []string
|
||||||
|
InboundContext *bus.InboundContext
|
||||||
|
RouteResult *routing.ResolvedRoute
|
||||||
|
SessionScope *session.SessionScope
|
||||||
|
UserMessage string
|
||||||
|
Media []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r DispatchRequest) Channel() string {
|
||||||
|
if r.InboundContext == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.InboundContext.Channel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r DispatchRequest) ChatID() string {
|
||||||
|
if r.InboundContext == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.InboundContext.ChatID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r DispatchRequest) MessageID() string {
|
||||||
|
if r.InboundContext == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.InboundContext.MessageID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r DispatchRequest) ReplyToMessageID() string {
|
||||||
|
if r.InboundContext == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.InboundContext.ReplyToMessageID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r DispatchRequest) SenderID() string {
|
||||||
|
if r.InboundContext == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.InboundContext.SenderID
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProcessOptionsInPlace(opts *processOptions) {
|
||||||
|
if opts == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*opts = normalizeProcessOptions(*opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProcessOptions(opts processOptions) processOptions {
|
||||||
|
if opts.Dispatch.SessionKey == "" {
|
||||||
|
opts.Dispatch.SessionKey = strings.TrimSpace(opts.SessionKey)
|
||||||
|
}
|
||||||
|
if len(opts.Dispatch.SessionAliases) == 0 && len(opts.SessionAliases) > 0 {
|
||||||
|
opts.Dispatch.SessionAliases = append([]string(nil), opts.SessionAliases...)
|
||||||
|
}
|
||||||
|
if opts.Dispatch.UserMessage == "" {
|
||||||
|
opts.Dispatch.UserMessage = opts.UserMessage
|
||||||
|
}
|
||||||
|
if len(opts.Dispatch.Media) == 0 && len(opts.Media) > 0 {
|
||||||
|
opts.Dispatch.Media = append([]string(nil), opts.Media...)
|
||||||
|
}
|
||||||
|
if opts.Dispatch.RouteResult == nil {
|
||||||
|
opts.Dispatch.RouteResult = cloneResolvedRoute(opts.RouteResult)
|
||||||
|
}
|
||||||
|
if opts.Dispatch.SessionScope == nil {
|
||||||
|
opts.Dispatch.SessionScope = session.CloneScope(opts.SessionScope)
|
||||||
|
}
|
||||||
|
if opts.Dispatch.InboundContext == nil {
|
||||||
|
if opts.InboundContext != nil {
|
||||||
|
opts.Dispatch.InboundContext = cloneInboundContext(opts.InboundContext)
|
||||||
|
} else if opts.Channel != "" || opts.ChatID != "" || opts.SenderID != "" ||
|
||||||
|
opts.MessageID != "" || opts.ReplyToMessageID != "" {
|
||||||
|
inbound := bus.InboundContext{
|
||||||
|
Channel: strings.TrimSpace(opts.Channel),
|
||||||
|
ChatID: strings.TrimSpace(opts.ChatID),
|
||||||
|
SenderID: strings.TrimSpace(opts.SenderID),
|
||||||
|
MessageID: strings.TrimSpace(opts.MessageID),
|
||||||
|
ReplyToMessageID: strings.TrimSpace(opts.ReplyToMessageID),
|
||||||
|
}
|
||||||
|
inbound.ChatType = inferChatTypeFromSessionScope(opts.Dispatch.SessionScope)
|
||||||
|
if inbound.Channel != "" || inbound.ChatID != "" || inbound.SenderID != "" ||
|
||||||
|
inbound.MessageID != "" || inbound.ReplyToMessageID != "" {
|
||||||
|
inbound = bus.NormalizeInboundMessage(bus.InboundMessage{Context: inbound}).Context
|
||||||
|
opts.Dispatch.InboundContext = &inbound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep legacy mirrors populated while the rest of the runtime migrates.
|
||||||
|
opts.SessionKey = opts.Dispatch.SessionKey
|
||||||
|
opts.SessionAliases = append([]string(nil), opts.Dispatch.SessionAliases...)
|
||||||
|
opts.UserMessage = opts.Dispatch.UserMessage
|
||||||
|
opts.Media = append([]string(nil), opts.Dispatch.Media...)
|
||||||
|
opts.InboundContext = cloneInboundContext(opts.Dispatch.InboundContext)
|
||||||
|
opts.RouteResult = cloneResolvedRoute(opts.Dispatch.RouteResult)
|
||||||
|
opts.SessionScope = session.CloneScope(opts.Dispatch.SessionScope)
|
||||||
|
if opts.InboundContext != nil {
|
||||||
|
if opts.Channel == "" {
|
||||||
|
opts.Channel = opts.InboundContext.Channel
|
||||||
|
}
|
||||||
|
if opts.ChatID == "" {
|
||||||
|
opts.ChatID = opts.InboundContext.ChatID
|
||||||
|
}
|
||||||
|
if opts.MessageID == "" {
|
||||||
|
opts.MessageID = opts.InboundContext.MessageID
|
||||||
|
}
|
||||||
|
if opts.ReplyToMessageID == "" {
|
||||||
|
opts.ReplyToMessageID = opts.InboundContext.ReplyToMessageID
|
||||||
|
}
|
||||||
|
if opts.SenderID == "" {
|
||||||
|
opts.SenderID = opts.InboundContext.SenderID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferChatTypeFromSessionScope(scope *session.SessionScope) string {
|
||||||
|
if scope == nil || len(scope.Values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
chatValue := strings.TrimSpace(scope.Values["chat"])
|
||||||
|
if chatValue == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
chatType, _, ok := strings.Cut(chatValue, ":")
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimSpace(chatType))
|
||||||
|
}
|
||||||
135
pkg/agent/dispatch_request_test.go
Normal file
135
pkg/agent/dispatch_request_test.go
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeProcessOptions_PopulatesDispatchFromLegacyFields(t *testing.T) {
|
||||||
|
opts := normalizeProcessOptions(processOptions{
|
||||||
|
SessionKey: "session-1",
|
||||||
|
SessionAliases: []string{"legacy:one"},
|
||||||
|
Channel: "telegram",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
MessageID: "msg-1",
|
||||||
|
ReplyToMessageID: "reply-1",
|
||||||
|
SenderID: "user-1",
|
||||||
|
UserMessage: "hello",
|
||||||
|
Media: []string{"media://one"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if opts.Dispatch.SessionKey != "session-1" {
|
||||||
|
t.Fatalf("Dispatch.SessionKey = %q, want session-1", opts.Dispatch.SessionKey)
|
||||||
|
}
|
||||||
|
if len(opts.Dispatch.SessionAliases) != 1 || opts.Dispatch.SessionAliases[0] != "legacy:one" {
|
||||||
|
t.Fatalf("Dispatch.SessionAliases = %v, want [legacy:one]", opts.Dispatch.SessionAliases)
|
||||||
|
}
|
||||||
|
if opts.Dispatch.Channel() != "telegram" || opts.Dispatch.ChatID() != "chat-1" {
|
||||||
|
t.Fatalf(
|
||||||
|
"dispatch addressing = (%q,%q), want (telegram,chat-1)",
|
||||||
|
opts.Dispatch.Channel(),
|
||||||
|
opts.Dispatch.ChatID(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if opts.Dispatch.SenderID() != "user-1" || opts.Dispatch.MessageID() != "msg-1" {
|
||||||
|
t.Fatalf("dispatch sender/message = (%q,%q)", opts.Dispatch.SenderID(), opts.Dispatch.MessageID())
|
||||||
|
}
|
||||||
|
if opts.Dispatch.ReplyToMessageID() != "reply-1" {
|
||||||
|
t.Fatalf("Dispatch.ReplyToMessageID() = %q, want reply-1", opts.Dispatch.ReplyToMessageID())
|
||||||
|
}
|
||||||
|
if opts.Dispatch.UserMessage != "hello" {
|
||||||
|
t.Fatalf("Dispatch.UserMessage = %q, want hello", opts.Dispatch.UserMessage)
|
||||||
|
}
|
||||||
|
if len(opts.Dispatch.Media) != 1 || opts.Dispatch.Media[0] != "media://one" {
|
||||||
|
t.Fatalf("Dispatch.Media = %v, want [media://one]", opts.Dispatch.Media)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeProcessOptions_UsesDispatchAsSourceOfTruth(t *testing.T) {
|
||||||
|
inbound := &bus.InboundContext{
|
||||||
|
Channel: "slack",
|
||||||
|
ChatID: "C123",
|
||||||
|
ChatType: "channel",
|
||||||
|
SenderID: "U123",
|
||||||
|
MessageID: "m-1",
|
||||||
|
ReplyToMessageID: "parent-1",
|
||||||
|
}
|
||||||
|
route := &routing.ResolvedRoute{
|
||||||
|
AgentID: "support",
|
||||||
|
Channel: "slack",
|
||||||
|
AccountID: "workspace-a",
|
||||||
|
MatchedBy: "dispatch.rule:test",
|
||||||
|
SessionPolicy: routing.SessionPolicy{
|
||||||
|
Dimensions: []string{"chat", "sender"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
scope := &session.SessionScope{
|
||||||
|
Version: session.ScopeVersionV1,
|
||||||
|
AgentID: "support",
|
||||||
|
Channel: "slack",
|
||||||
|
Account: "workspace-a",
|
||||||
|
Dimensions: []string{"chat"},
|
||||||
|
Values: map[string]string{
|
||||||
|
"chat": "channel:c123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := normalizeProcessOptions(processOptions{
|
||||||
|
Dispatch: DispatchRequest{
|
||||||
|
SessionKey: "sk_v1_example",
|
||||||
|
SessionAliases: []string{"agent:support:slack:channel:c123"},
|
||||||
|
InboundContext: inbound,
|
||||||
|
RouteResult: route,
|
||||||
|
SessionScope: scope,
|
||||||
|
UserMessage: "hello",
|
||||||
|
Media: []string{"media://one"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if opts.SessionKey != "sk_v1_example" {
|
||||||
|
t.Fatalf("SessionKey = %q, want sk_v1_example", opts.SessionKey)
|
||||||
|
}
|
||||||
|
if opts.Channel != "slack" || opts.ChatID != "C123" {
|
||||||
|
t.Fatalf("legacy mirrors = (%q,%q), want (slack,C123)", opts.Channel, opts.ChatID)
|
||||||
|
}
|
||||||
|
if opts.SenderID != "U123" || opts.MessageID != "m-1" {
|
||||||
|
t.Fatalf("legacy sender/message = (%q,%q)", opts.SenderID, opts.MessageID)
|
||||||
|
}
|
||||||
|
if opts.ReplyToMessageID != "parent-1" {
|
||||||
|
t.Fatalf("ReplyToMessageID = %q, want parent-1", opts.ReplyToMessageID)
|
||||||
|
}
|
||||||
|
if opts.RouteResult == nil || opts.RouteResult.AgentID != "support" {
|
||||||
|
t.Fatalf("RouteResult = %#v, want support route", opts.RouteResult)
|
||||||
|
}
|
||||||
|
if opts.SessionScope == nil || opts.SessionScope.AgentID != "support" {
|
||||||
|
t.Fatalf("SessionScope = %#v, want support scope", opts.SessionScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeProcessOptions_InfersLegacyChatTypeFromSessionScope(t *testing.T) {
|
||||||
|
opts := normalizeProcessOptions(processOptions{
|
||||||
|
Channel: "telegram",
|
||||||
|
ChatID: "-100123",
|
||||||
|
SenderID: "user-1",
|
||||||
|
UserMessage: "hello",
|
||||||
|
SessionScope: &session.SessionScope{
|
||||||
|
Version: session.ScopeVersionV1,
|
||||||
|
AgentID: "main",
|
||||||
|
Channel: "telegram",
|
||||||
|
Dimensions: []string{"chat"},
|
||||||
|
Values: map[string]string{
|
||||||
|
"chat": "group:-100123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if opts.Dispatch.InboundContext == nil {
|
||||||
|
t.Fatal("Dispatch.InboundContext is nil")
|
||||||
|
}
|
||||||
|
if opts.Dispatch.InboundContext.ChatType != "group" {
|
||||||
|
t.Fatalf("Dispatch.InboundContext.ChatType = %q, want group", opts.Dispatch.InboundContext.ChatType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -136,6 +138,31 @@ func TestAgentLoop_EmitsMinimalTurnEvents(t *testing.T) {
|
||||||
DefaultResponse: defaultResponse,
|
DefaultResponse: defaultResponse,
|
||||||
EnableSummary: false,
|
EnableSummary: false,
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
|
InboundContext: &bus.InboundContext{
|
||||||
|
Channel: "cli",
|
||||||
|
ChatID: "direct",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "tester",
|
||||||
|
},
|
||||||
|
RouteResult: &routing.ResolvedRoute{
|
||||||
|
AgentID: "main",
|
||||||
|
Channel: "cli",
|
||||||
|
AccountID: routing.DefaultAccountID,
|
||||||
|
SessionPolicy: routing.SessionPolicy{
|
||||||
|
Dimensions: []string{"sender"},
|
||||||
|
},
|
||||||
|
MatchedBy: "default",
|
||||||
|
},
|
||||||
|
SessionScope: &session.SessionScope{
|
||||||
|
Version: session.ScopeVersionV1,
|
||||||
|
AgentID: "main",
|
||||||
|
Channel: "cli",
|
||||||
|
Account: routing.DefaultAccountID,
|
||||||
|
Dimensions: []string{"sender"},
|
||||||
|
Values: map[string]string{
|
||||||
|
"sender": "tester",
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("runAgentLoop failed: %v", err)
|
t.Fatalf("runAgentLoop failed: %v", err)
|
||||||
|
|
@ -176,6 +203,18 @@ func TestAgentLoop_EmitsMinimalTurnEvents(t *testing.T) {
|
||||||
if evt.Meta.SessionKey != "session-1" {
|
if evt.Meta.SessionKey != "session-1" {
|
||||||
t.Fatalf("event %d has session key %q, want session-1", i, evt.Meta.SessionKey)
|
t.Fatalf("event %d has session key %q, want session-1", i, evt.Meta.SessionKey)
|
||||||
}
|
}
|
||||||
|
if evt.Context == nil || evt.Context.Inbound == nil {
|
||||||
|
t.Fatalf("event %d missing inbound turn context", i)
|
||||||
|
}
|
||||||
|
if evt.Context.Inbound.Channel != "cli" || evt.Context.Inbound.SenderID != "tester" {
|
||||||
|
t.Fatalf("event %d inbound context = %+v", i, evt.Context.Inbound)
|
||||||
|
}
|
||||||
|
if evt.Context.Route == nil || evt.Context.Route.AgentID != "main" {
|
||||||
|
t.Fatalf("event %d missing route context: %+v", i, evt.Context.Route)
|
||||||
|
}
|
||||||
|
if evt.Context.Scope == nil || evt.Context.Scope.Values["sender"] != "tester" {
|
||||||
|
t.Fatalf("event %d missing session scope: %+v", i, evt.Context.Scope)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startPayload, ok := events[0].Payload.(TurnStartPayload)
|
startPayload, ok := events[0].Payload.(TurnStartPayload)
|
||||||
|
|
@ -472,7 +511,6 @@ func TestAgentLoop_EmitsSessionSummarizeEvent(t *testing.T) {
|
||||||
sub := al.SubscribeEvents(16)
|
sub := al.SubscribeEvents(16)
|
||||||
defer al.UnsubscribeEvents(sub.ID)
|
defer al.UnsubscribeEvents(sub.ID)
|
||||||
|
|
||||||
// Use legacyContextManager's summarizeSession via contextManager interface
|
|
||||||
lcm := &legacyContextManager{al: al}
|
lcm := &legacyContextManager{al: al}
|
||||||
lcm.summarizeSession(defaultAgent, "session-1")
|
lcm.summarizeSession(defaultAgent, "session-1")
|
||||||
|
|
||||||
|
|
@ -572,12 +610,6 @@ func TestAgentLoop_EmitsFollowUpQueuedEvent(t *testing.T) {
|
||||||
if payload.SourceTool != "async_followup" {
|
if payload.SourceTool != "async_followup" {
|
||||||
t.Fatalf("expected source tool async_followup, got %q", payload.SourceTool)
|
t.Fatalf("expected source tool async_followup, got %q", payload.SourceTool)
|
||||||
}
|
}
|
||||||
if payload.Channel != "cli" {
|
|
||||||
t.Fatalf("expected channel cli, got %q", payload.Channel)
|
|
||||||
}
|
|
||||||
if payload.ChatID != "direct" {
|
|
||||||
t.Fatalf("expected chat id direct, got %q", payload.ChatID)
|
|
||||||
}
|
|
||||||
if payload.ContentLen != len("background result") {
|
if payload.ContentLen != len("background result") {
|
||||||
t.Fatalf("expected content len %d, got %d", len("background result"), payload.ContentLen)
|
t.Fatalf("expected content len %d, got %d", len("background result"), payload.ContentLen)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ type Event struct {
|
||||||
Kind EventKind
|
Kind EventKind
|
||||||
Time time.Time
|
Time time.Time
|
||||||
Meta EventMeta
|
Meta EventMeta
|
||||||
|
Context *TurnContext
|
||||||
Payload any
|
Payload any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,6 +99,7 @@ type EventMeta struct {
|
||||||
Iteration int
|
Iteration int
|
||||||
TracePath string
|
TracePath string
|
||||||
Source string
|
Source string
|
||||||
|
turnContext *TurnContext
|
||||||
}
|
}
|
||||||
|
|
||||||
// TurnEndStatus describes the terminal state of a turn.
|
// TurnEndStatus describes the terminal state of a turn.
|
||||||
|
|
@ -114,8 +116,6 @@ const (
|
||||||
|
|
||||||
// TurnStartPayload describes the start of a turn.
|
// TurnStartPayload describes the start of a turn.
|
||||||
type TurnStartPayload struct {
|
type TurnStartPayload struct {
|
||||||
Channel string
|
|
||||||
ChatID string
|
|
||||||
UserMessage string
|
UserMessage string
|
||||||
MediaCount int
|
MediaCount int
|
||||||
}
|
}
|
||||||
|
|
@ -217,8 +217,6 @@ type SteeringInjectedPayload struct {
|
||||||
// FollowUpQueuedPayload describes an async follow-up queued back into the inbound bus.
|
// FollowUpQueuedPayload describes an async follow-up queued back into the inbound bus.
|
||||||
type FollowUpQueuedPayload struct {
|
type FollowUpQueuedPayload struct {
|
||||||
SourceTool string
|
SourceTool string
|
||||||
Channel string
|
|
||||||
ChatID string
|
|
||||||
ContentLen int
|
ContentLen int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,12 +90,11 @@ type ToolApprover interface {
|
||||||
|
|
||||||
type LLMHookRequest struct {
|
type LLMHookRequest struct {
|
||||||
Meta EventMeta `json:"meta"`
|
Meta EventMeta `json:"meta"`
|
||||||
|
Context *TurnContext `json:"context,omitempty"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Messages []providers.Message `json:"messages,omitempty"`
|
Messages []providers.Message `json:"messages,omitempty"`
|
||||||
Tools []providers.ToolDefinition `json:"tools,omitempty"`
|
Tools []providers.ToolDefinition `json:"tools,omitempty"`
|
||||||
Options map[string]any `json:"options,omitempty"`
|
Options map[string]any `json:"options,omitempty"`
|
||||||
Channel string `json:"channel,omitempty"`
|
|
||||||
ChatID string `json:"chat_id,omitempty"`
|
|
||||||
GracefulTerminal bool `json:"graceful_terminal,omitempty"`
|
GracefulTerminal bool `json:"graceful_terminal,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,6 +103,8 @@ func (r *LLMHookRequest) Clone() *LLMHookRequest {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
cloned := *r
|
cloned := *r
|
||||||
|
cloned.Meta = cloneEventMeta(r.Meta)
|
||||||
|
cloned.Context = cloneTurnContext(r.Context)
|
||||||
cloned.Messages = cloneProviderMessages(r.Messages)
|
cloned.Messages = cloneProviderMessages(r.Messages)
|
||||||
cloned.Tools = cloneToolDefinitions(r.Tools)
|
cloned.Tools = cloneToolDefinitions(r.Tools)
|
||||||
cloned.Options = cloneStringAnyMap(r.Options)
|
cloned.Options = cloneStringAnyMap(r.Options)
|
||||||
|
|
@ -112,10 +113,9 @@ func (r *LLMHookRequest) Clone() *LLMHookRequest {
|
||||||
|
|
||||||
type LLMHookResponse struct {
|
type LLMHookResponse struct {
|
||||||
Meta EventMeta `json:"meta"`
|
Meta EventMeta `json:"meta"`
|
||||||
|
Context *TurnContext `json:"context,omitempty"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Response *providers.LLMResponse `json:"response,omitempty"`
|
Response *providers.LLMResponse `json:"response,omitempty"`
|
||||||
Channel string `json:"channel,omitempty"`
|
|
||||||
ChatID string `json:"chat_id,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LLMHookResponse) Clone() *LLMHookResponse {
|
func (r *LLMHookResponse) Clone() *LLMHookResponse {
|
||||||
|
|
@ -123,12 +123,15 @@ func (r *LLMHookResponse) Clone() *LLMHookResponse {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
cloned := *r
|
cloned := *r
|
||||||
|
cloned.Meta = cloneEventMeta(r.Meta)
|
||||||
|
cloned.Context = cloneTurnContext(r.Context)
|
||||||
cloned.Response = cloneLLMResponse(r.Response)
|
cloned.Response = cloneLLMResponse(r.Response)
|
||||||
return &cloned
|
return &cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolCallHookRequest struct {
|
type ToolCallHookRequest struct {
|
||||||
Meta EventMeta `json:"meta"`
|
Meta EventMeta `json:"meta"`
|
||||||
|
Context *TurnContext `json:"context,omitempty"`
|
||||||
Tool string `json:"tool"`
|
Tool string `json:"tool"`
|
||||||
Arguments map[string]any `json:"arguments,omitempty"`
|
Arguments map[string]any `json:"arguments,omitempty"`
|
||||||
Channel string `json:"channel,omitempty"`
|
Channel string `json:"channel,omitempty"`
|
||||||
|
|
@ -141,6 +144,8 @@ func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
cloned := *r
|
cloned := *r
|
||||||
|
cloned.Meta = cloneEventMeta(r.Meta)
|
||||||
|
cloned.Context = cloneTurnContext(r.Context)
|
||||||
cloned.Arguments = cloneStringAnyMap(r.Arguments)
|
cloned.Arguments = cloneStringAnyMap(r.Arguments)
|
||||||
cloned.HookResult = cloneToolResult(r.HookResult)
|
cloned.HookResult = cloneToolResult(r.HookResult)
|
||||||
return &cloned
|
return &cloned
|
||||||
|
|
@ -148,10 +153,9 @@ func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest {
|
||||||
|
|
||||||
type ToolApprovalRequest struct {
|
type ToolApprovalRequest struct {
|
||||||
Meta EventMeta `json:"meta"`
|
Meta EventMeta `json:"meta"`
|
||||||
|
Context *TurnContext `json:"context,omitempty"`
|
||||||
Tool string `json:"tool"`
|
Tool string `json:"tool"`
|
||||||
Arguments map[string]any `json:"arguments,omitempty"`
|
Arguments map[string]any `json:"arguments,omitempty"`
|
||||||
Channel string `json:"channel,omitempty"`
|
|
||||||
ChatID string `json:"chat_id,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ToolApprovalRequest) Clone() *ToolApprovalRequest {
|
func (r *ToolApprovalRequest) Clone() *ToolApprovalRequest {
|
||||||
|
|
@ -159,18 +163,19 @@ func (r *ToolApprovalRequest) Clone() *ToolApprovalRequest {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
cloned := *r
|
cloned := *r
|
||||||
|
cloned.Meta = cloneEventMeta(r.Meta)
|
||||||
|
cloned.Context = cloneTurnContext(r.Context)
|
||||||
cloned.Arguments = cloneStringAnyMap(r.Arguments)
|
cloned.Arguments = cloneStringAnyMap(r.Arguments)
|
||||||
return &cloned
|
return &cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolResultHookResponse struct {
|
type ToolResultHookResponse struct {
|
||||||
Meta EventMeta `json:"meta"`
|
Meta EventMeta `json:"meta"`
|
||||||
|
Context *TurnContext `json:"context,omitempty"`
|
||||||
Tool string `json:"tool"`
|
Tool string `json:"tool"`
|
||||||
Arguments map[string]any `json:"arguments,omitempty"`
|
Arguments map[string]any `json:"arguments,omitempty"`
|
||||||
Result *tools.ToolResult `json:"result,omitempty"`
|
Result *tools.ToolResult `json:"result,omitempty"`
|
||||||
Duration time.Duration `json:"duration"`
|
Duration time.Duration `json:"duration"`
|
||||||
Channel string `json:"channel,omitempty"`
|
|
||||||
ChatID string `json:"chat_id,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ToolResultHookResponse) Clone() *ToolResultHookResponse {
|
func (r *ToolResultHookResponse) Clone() *ToolResultHookResponse {
|
||||||
|
|
@ -178,6 +183,8 @@ func (r *ToolResultHookResponse) Clone() *ToolResultHookResponse {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
cloned := *r
|
cloned := *r
|
||||||
|
cloned.Meta = cloneEventMeta(r.Meta)
|
||||||
|
cloned.Context = cloneTurnContext(r.Context)
|
||||||
cloned.Arguments = cloneStringAnyMap(r.Arguments)
|
cloned.Arguments = cloneStringAnyMap(r.Arguments)
|
||||||
cloned.Result = cloneToolResult(r.Result)
|
cloned.Result = cloneToolResult(r.Result)
|
||||||
return &cloned
|
return &cloned
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -109,6 +110,9 @@ func (p *llmHookTestProvider) GetDefaultModel() string {
|
||||||
|
|
||||||
type llmObserverHook struct {
|
type llmObserverHook struct {
|
||||||
eventCh chan Event
|
eventCh chan Event
|
||||||
|
lastInbound *bus.InboundContext
|
||||||
|
lastRoute *routing.ResolvedRoute
|
||||||
|
lastScope *session.SessionScope
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *llmObserverHook) OnEvent(ctx context.Context, evt Event) error {
|
func (h *llmObserverHook) OnEvent(ctx context.Context, evt Event) error {
|
||||||
|
|
@ -125,6 +129,11 @@ func (h *llmObserverHook) BeforeLLM(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *LLMHookRequest,
|
req *LLMHookRequest,
|
||||||
) (*LLMHookRequest, HookDecision, error) {
|
) (*LLMHookRequest, HookDecision, error) {
|
||||||
|
if req.Context != nil {
|
||||||
|
h.lastInbound = cloneInboundContext(req.Context.Inbound)
|
||||||
|
h.lastRoute = cloneResolvedRoute(req.Context.Route)
|
||||||
|
h.lastScope = session.CloneScope(req.Context.Scope)
|
||||||
|
}
|
||||||
next := req.Clone()
|
next := req.Clone()
|
||||||
next.Model = "hook-model"
|
next.Model = "hook-model"
|
||||||
return next, HookDecision{Action: HookActionModify}, nil
|
return next, HookDecision{Action: HookActionModify}, nil
|
||||||
|
|
@ -157,6 +166,31 @@ func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) {
|
||||||
DefaultResponse: defaultResponse,
|
DefaultResponse: defaultResponse,
|
||||||
EnableSummary: false,
|
EnableSummary: false,
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
|
InboundContext: &bus.InboundContext{
|
||||||
|
Channel: "cli",
|
||||||
|
ChatID: "direct",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "hook-user",
|
||||||
|
},
|
||||||
|
RouteResult: &routing.ResolvedRoute{
|
||||||
|
AgentID: "main",
|
||||||
|
Channel: "cli",
|
||||||
|
AccountID: routing.DefaultAccountID,
|
||||||
|
SessionPolicy: routing.SessionPolicy{
|
||||||
|
Dimensions: []string{"sender"},
|
||||||
|
},
|
||||||
|
MatchedBy: "default",
|
||||||
|
},
|
||||||
|
SessionScope: &session.SessionScope{
|
||||||
|
Version: session.ScopeVersionV1,
|
||||||
|
AgentID: "main",
|
||||||
|
Channel: "cli",
|
||||||
|
Account: routing.DefaultAccountID,
|
||||||
|
Dimensions: []string{"sender"},
|
||||||
|
Values: map[string]string{
|
||||||
|
"sender": "hook-user",
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("runAgentLoop failed: %v", err)
|
t.Fatalf("runAgentLoop failed: %v", err)
|
||||||
|
|
@ -171,17 +205,120 @@ func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) {
|
||||||
if lastModel != "hook-model" {
|
if lastModel != "hook-model" {
|
||||||
t.Fatalf("expected model hook-model, got %q", lastModel)
|
t.Fatalf("expected model hook-model, got %q", lastModel)
|
||||||
}
|
}
|
||||||
|
if hook.lastInbound == nil {
|
||||||
|
t.Fatal("expected hook to receive inbound context")
|
||||||
|
}
|
||||||
|
if hook.lastInbound.Channel != "cli" || hook.lastInbound.SenderID != "hook-user" {
|
||||||
|
t.Fatalf("hook inbound context = %+v", hook.lastInbound)
|
||||||
|
}
|
||||||
|
if hook.lastInbound != nil && hook.lastInbound.ChatID != "direct" {
|
||||||
|
t.Fatalf("hook inbound chat ID = %q, want direct", hook.lastInbound.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case evt := <-hook.eventCh:
|
case evt := <-hook.eventCh:
|
||||||
if evt.Kind != EventKindTurnEnd {
|
if evt.Kind != EventKindTurnEnd {
|
||||||
t.Fatalf("expected turn end event, got %v", evt.Kind)
|
t.Fatalf("expected turn end event, got %v", evt.Kind)
|
||||||
}
|
}
|
||||||
|
if evt.Context == nil || evt.Context.Inbound == nil {
|
||||||
|
t.Fatal("expected observer event to carry inbound context")
|
||||||
|
}
|
||||||
|
if evt.Context.Route == nil || evt.Context.Route.AgentID != "main" {
|
||||||
|
t.Fatalf("expected observer event to carry route context, got %+v", evt.Context.Route)
|
||||||
|
}
|
||||||
|
if evt.Context.Scope == nil || evt.Context.Scope.Values["sender"] != "hook-user" {
|
||||||
|
t.Fatalf("expected observer event to carry session scope, got %+v", evt.Context.Scope)
|
||||||
|
}
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
t.Fatal("timed out waiting for hook observer event")
|
t.Fatal("timed out waiting for hook observer event")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAgentLoop_BtwCommand_UsesLLMHooks(t *testing.T) {
|
||||||
|
provider := &llmHookTestProvider{}
|
||||||
|
al, agent, cleanup := newHookTestLoop(t, provider)
|
||||||
|
defer cleanup()
|
||||||
|
useTestSideQuestionProvider(al, provider)
|
||||||
|
|
||||||
|
hook := &llmObserverHook{eventCh: make(chan Event, 1)}
|
||||||
|
if err := al.MountHook(NamedHook("llm-observer", hook)); err != nil {
|
||||||
|
t.Fatalf("MountHook failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, handled := al.handleCommand(context.Background(), bus.InboundMessage{
|
||||||
|
Context: bus.InboundContext{
|
||||||
|
Channel: "cli",
|
||||||
|
ChatID: "direct",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "hook-user",
|
||||||
|
},
|
||||||
|
Content: "/btw hello",
|
||||||
|
}, agent, &processOptions{
|
||||||
|
Dispatch: DispatchRequest{
|
||||||
|
SessionKey: "session-1",
|
||||||
|
InboundContext: &bus.InboundContext{
|
||||||
|
Channel: "cli",
|
||||||
|
ChatID: "direct",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "hook-user",
|
||||||
|
},
|
||||||
|
RouteResult: &routing.ResolvedRoute{
|
||||||
|
AgentID: "main",
|
||||||
|
Channel: "cli",
|
||||||
|
AccountID: routing.DefaultAccountID,
|
||||||
|
SessionPolicy: routing.SessionPolicy{
|
||||||
|
Dimensions: []string{"sender"},
|
||||||
|
},
|
||||||
|
MatchedBy: "default",
|
||||||
|
},
|
||||||
|
SessionScope: &session.SessionScope{
|
||||||
|
Version: session.ScopeVersionV1,
|
||||||
|
AgentID: "main",
|
||||||
|
Channel: "cli",
|
||||||
|
Account: routing.DefaultAccountID,
|
||||||
|
Dimensions: []string{"sender"},
|
||||||
|
Values: map[string]string{
|
||||||
|
"sender": "hook-user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
UserMessage: "/btw hello",
|
||||||
|
},
|
||||||
|
SessionKey: "session-1",
|
||||||
|
Channel: "cli",
|
||||||
|
ChatID: "direct",
|
||||||
|
SenderID: "hook-user",
|
||||||
|
SenderDisplayName: "Hook User",
|
||||||
|
})
|
||||||
|
if !handled {
|
||||||
|
t.Fatal("expected /btw command to be handled")
|
||||||
|
}
|
||||||
|
if response != "hooked content" {
|
||||||
|
t.Fatalf("expected hooked content, got %q", response)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.mu.Lock()
|
||||||
|
lastModel := provider.lastModel
|
||||||
|
provider.mu.Unlock()
|
||||||
|
if lastModel != "hook-model" {
|
||||||
|
t.Fatalf("expected model hook-model, got %q", lastModel)
|
||||||
|
}
|
||||||
|
if hook.lastInbound == nil {
|
||||||
|
t.Fatal("expected hook to receive inbound context")
|
||||||
|
}
|
||||||
|
if hook.lastInbound.Channel != "cli" || hook.lastInbound.SenderID != "hook-user" {
|
||||||
|
t.Fatalf("hook inbound context = %+v", hook.lastInbound)
|
||||||
|
}
|
||||||
|
if hook.lastInbound.ChatID != "direct" {
|
||||||
|
t.Fatalf("hook inbound chat ID = %q, want direct", hook.lastInbound.ChatID)
|
||||||
|
}
|
||||||
|
if hook.lastRoute == nil || hook.lastRoute.AgentID != "main" {
|
||||||
|
t.Fatalf("expected hook route context for /btw, got %+v", hook.lastRoute)
|
||||||
|
}
|
||||||
|
if hook.lastScope == nil || hook.lastScope.Values["sender"] != "hook-user" {
|
||||||
|
t.Fatalf("expected hook session scope for /btw, got %+v", hook.lastScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type toolHookProvider struct {
|
type toolHookProvider struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
calls int
|
calls int
|
||||||
|
|
@ -725,7 +862,7 @@ func TestAgentLoop_HookRespond_InterruptSkipsRemaining(t *testing.T) {
|
||||||
sub := al.SubscribeEvents(32)
|
sub := al.SubscribeEvents(32)
|
||||||
defer al.UnsubscribeEvents(sub.ID)
|
defer al.UnsubscribeEvents(sub.ID)
|
||||||
|
|
||||||
sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID)
|
sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
|
||||||
|
|
||||||
type result struct {
|
type result struct {
|
||||||
resp string
|
resp string
|
||||||
|
|
@ -801,7 +938,7 @@ func TestAgentLoop_HookRespond_SteeringSkipsRemaining(t *testing.T) {
|
||||||
sub := al.SubscribeEvents(32)
|
sub := al.SubscribeEvents(32)
|
||||||
defer al.UnsubscribeEvents(sub.ID)
|
defer al.UnsubscribeEvents(sub.ID)
|
||||||
|
|
||||||
sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID)
|
sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
|
||||||
|
|
||||||
type result struct {
|
type result struct {
|
||||||
resp string
|
resp string
|
||||||
|
|
@ -819,9 +956,26 @@ func TestAgentLoop_HookRespond_SteeringSkipsRemaining(t *testing.T) {
|
||||||
resultCh <- result{resp: resp, err: err}
|
resultCh <- result{resp: resp, err: err}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
collectedEvents := make([]Event, 0, 8)
|
||||||
|
steered := false
|
||||||
|
deadline := time.After(3 * time.Second)
|
||||||
|
for !steered {
|
||||||
|
select {
|
||||||
|
case evt := <-sub.C:
|
||||||
|
collectedEvents = append(collectedEvents, evt)
|
||||||
|
if evt.Kind != EventKindToolExecEnd {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload, ok := evt.Payload.(ToolExecEndPayload)
|
||||||
|
if !ok || payload.Tool != "tool_one" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
al.Steer(providers.Message{Role: "user", Content: "change direction"})
|
al.Steer(providers.Message{Role: "user", Content: "change direction"})
|
||||||
|
steered = true
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("timeout waiting for tool_one to finish before steering")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case r := <-resultCh:
|
case r := <-resultCh:
|
||||||
|
|
@ -832,7 +986,7 @@ func TestAgentLoop_HookRespond_SteeringSkipsRemaining(t *testing.T) {
|
||||||
t.Fatal("timeout waiting for result")
|
t.Fatal("timeout waiting for result")
|
||||||
}
|
}
|
||||||
|
|
||||||
events := collectEventStream(sub.C)
|
events := append(collectedEvents, collectEventStream(sub.C)...)
|
||||||
|
|
||||||
skippedEvts := filterEvents(events, EventKindToolExecSkipped)
|
skippedEvts := filterEvents(events, EventKindToolExecSkipped)
|
||||||
if len(skippedEvts) < 1 {
|
if len(skippedEvts) < 1 {
|
||||||
|
|
|
||||||
60
pkg/agent/llm_media.go
Normal file
60
pkg/agent/llm_media.go
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func messagesContainMedia(messages []providers.Message) bool {
|
||||||
|
for _, msg := range messages {
|
||||||
|
for _, ref := range msg.Media {
|
||||||
|
if strings.TrimSpace(ref) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripMessageMedia(messages []providers.Message) []providers.Message {
|
||||||
|
if !messagesContainMedia(messages) {
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
stripped := make([]providers.Message, len(messages))
|
||||||
|
for i, msg := range messages {
|
||||||
|
stripped[i] = msg
|
||||||
|
stripped[i].Media = nil
|
||||||
|
}
|
||||||
|
return stripped
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVisionUnsupportedError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
|
||||||
|
// OpenRouter (and OpenAI-compatible) style.
|
||||||
|
if strings.Contains(msg, "no endpoints found that support image input") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common provider variants.
|
||||||
|
if strings.Contains(msg, "does not support image input") ||
|
||||||
|
strings.Contains(msg, "does not support image inputs") ||
|
||||||
|
strings.Contains(msg, "does not support images") ||
|
||||||
|
strings.Contains(msg, "image input is not supported") ||
|
||||||
|
strings.Contains(msg, "images are not supported") ||
|
||||||
|
strings.Contains(msg, "does not support vision") ||
|
||||||
|
strings.Contains(msg, "unsupported content type: image_url") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some providers return a generic "invalid" message that still mentions image_url.
|
||||||
|
if strings.Contains(msg, "image_url") && strings.Contains(msg, "invalid") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
3451
pkg/agent/loop.go
3451
pkg/agent/loop.go
File diff suppressed because it is too large
Load diff
266
pkg/agent/loop_command.go
Normal file
266
pkg/agent/loop_command.go
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (al *AgentLoop) handleCommand(
|
||||||
|
ctx context.Context,
|
||||||
|
msg bus.InboundMessage,
|
||||||
|
agent *AgentInstance,
|
||||||
|
opts *processOptions,
|
||||||
|
) (string, bool) {
|
||||||
|
normalizeProcessOptionsInPlace(opts)
|
||||||
|
|
||||||
|
if !commands.HasCommandPrefix(msg.Content) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if matched, handled, reply := al.applyExplicitSkillCommand(msg.Content, agent, opts); matched {
|
||||||
|
return reply, handled
|
||||||
|
}
|
||||||
|
|
||||||
|
if al.cmdRegistry == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
rt := al.buildCommandsRuntime(ctx, agent, opts)
|
||||||
|
executor := commands.NewExecutor(al.cmdRegistry, rt)
|
||||||
|
|
||||||
|
var commandReply string
|
||||||
|
result := executor.Execute(ctx, commands.Request{
|
||||||
|
Channel: msg.Channel,
|
||||||
|
ChatID: msg.ChatID,
|
||||||
|
SenderID: msg.SenderID,
|
||||||
|
Text: msg.Content,
|
||||||
|
Reply: func(text string) error {
|
||||||
|
commandReply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
switch result.Outcome {
|
||||||
|
case commands.OutcomeHandled:
|
||||||
|
if result.Err != nil {
|
||||||
|
return mapCommandError(result), true
|
||||||
|
}
|
||||||
|
if commandReply != "" {
|
||||||
|
return commandReply, true
|
||||||
|
}
|
||||||
|
return "", true
|
||||||
|
default: // OutcomePassthrough — let the message fall through to LLM
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) applyExplicitSkillCommand(
|
||||||
|
raw string,
|
||||||
|
agent *AgentInstance,
|
||||||
|
opts *processOptions,
|
||||||
|
) (matched bool, handled bool, reply string) {
|
||||||
|
normalizeProcessOptionsInPlace(opts)
|
||||||
|
|
||||||
|
cmdName, ok := commands.CommandName(raw)
|
||||||
|
if !ok || cmdName != "use" {
|
||||||
|
return false, false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if agent == nil || agent.ContextBuilder == nil {
|
||||||
|
return true, true, commandsUnavailableSkillMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Fields(strings.TrimSpace(raw))
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return true, true, buildUseCommandHelp(agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
arg := strings.TrimSpace(parts[1])
|
||||||
|
if strings.EqualFold(arg, "clear") || strings.EqualFold(arg, "off") {
|
||||||
|
if opts != nil {
|
||||||
|
al.clearPendingSkills(opts.Dispatch.SessionKey)
|
||||||
|
}
|
||||||
|
return true, true, "Cleared pending skill override."
|
||||||
|
}
|
||||||
|
|
||||||
|
skillName, ok := agent.ContextBuilder.ResolveSkillName(arg)
|
||||||
|
if !ok {
|
||||||
|
return true, true, fmt.Sprintf("Unknown skill: %s\nUse /list skills to see installed skills.", arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) < 3 {
|
||||||
|
if opts == nil || strings.TrimSpace(opts.Dispatch.SessionKey) == "" {
|
||||||
|
return true, true, commandsUnavailableSkillMessage()
|
||||||
|
}
|
||||||
|
al.setPendingSkills(opts.Dispatch.SessionKey, []string{skillName})
|
||||||
|
return true, true, fmt.Sprintf(
|
||||||
|
"Skill %q is armed for your next message. Send your next prompt normally, or use /use clear to cancel.",
|
||||||
|
skillName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
message := strings.TrimSpace(strings.Join(parts[2:], " "))
|
||||||
|
if message == "" {
|
||||||
|
return true, true, buildUseCommandHelp(agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts != nil {
|
||||||
|
opts.ForcedSkills = append(opts.ForcedSkills, skillName)
|
||||||
|
opts.Dispatch.UserMessage = message
|
||||||
|
opts.UserMessage = message
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) buildCommandsRuntime(
|
||||||
|
ctx context.Context,
|
||||||
|
agent *AgentInstance,
|
||||||
|
opts *processOptions,
|
||||||
|
) *commands.Runtime {
|
||||||
|
normalizeProcessOptionsInPlace(opts)
|
||||||
|
|
||||||
|
registry := al.GetRegistry()
|
||||||
|
cfg := al.GetConfig()
|
||||||
|
rt := &commands.Runtime{
|
||||||
|
Config: cfg,
|
||||||
|
ListAgentIDs: registry.ListAgentIDs,
|
||||||
|
ListDefinitions: al.cmdRegistry.Definitions,
|
||||||
|
GetEnabledChannels: func() []string {
|
||||||
|
if al.channelManager == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return al.channelManager.GetEnabledChannels()
|
||||||
|
},
|
||||||
|
GetActiveTurn: func() any {
|
||||||
|
info := al.GetActiveTurn()
|
||||||
|
if info == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
},
|
||||||
|
SwitchChannel: func(value string) error {
|
||||||
|
if al.channelManager == nil {
|
||||||
|
return fmt.Errorf("channel manager not initialized")
|
||||||
|
}
|
||||||
|
if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
|
||||||
|
return fmt.Errorf("channel '%s' not found or not enabled", value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if agent != nil && agent.ContextBuilder != nil {
|
||||||
|
rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
|
||||||
|
}
|
||||||
|
rt.ReloadConfig = func() error {
|
||||||
|
if al.reloadFunc == nil {
|
||||||
|
return fmt.Errorf("reload not configured")
|
||||||
|
}
|
||||||
|
return al.reloadFunc()
|
||||||
|
}
|
||||||
|
if agent != nil {
|
||||||
|
if agent.ContextBuilder != nil {
|
||||||
|
rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
|
||||||
|
}
|
||||||
|
rt.GetModelInfo = func() (string, string) {
|
||||||
|
return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider)
|
||||||
|
}
|
||||||
|
rt.SwitchModel = func(value string) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
modelCfg, err := resolvedModelConfig(cfg, value, agent.Workspace)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
nextProvider, _, err := providers.CreateProviderFromConfig(modelCfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to initialize model %q: %w", value, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, value, agent.Fallbacks)
|
||||||
|
if len(nextCandidates) == 0 {
|
||||||
|
return "", fmt.Errorf("model %q did not resolve to any provider candidates", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldModel := agent.Model
|
||||||
|
oldProvider := agent.Provider
|
||||||
|
agent.Model = value
|
||||||
|
agent.Provider = nextProvider
|
||||||
|
agent.Candidates = nextCandidates
|
||||||
|
agent.ThinkingLevel = parseThinkingLevel(modelCfg.ThinkingLevel)
|
||||||
|
|
||||||
|
if oldProvider != nil && oldProvider != nextProvider {
|
||||||
|
if stateful, ok := oldProvider.(providers.StatefulProvider); ok {
|
||||||
|
stateful.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return oldModel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rt.ClearHistory = func() error {
|
||||||
|
if opts == nil {
|
||||||
|
return fmt.Errorf("process options not available")
|
||||||
|
}
|
||||||
|
return al.contextManager.Clear(ctx, opts.SessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
rt.AskSideQuestion = func(ctx context.Context, question string) (string, error) {
|
||||||
|
return al.askSideQuestion(ctx, agent, opts, question)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) {
|
||||||
|
sessionKey = strings.TrimSpace(sessionKey)
|
||||||
|
if sessionKey == "" || len(skillNames) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]string, 0, len(skillNames))
|
||||||
|
for _, name := range skillNames {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name != "" {
|
||||||
|
filtered = append(filtered, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
al.pendingSkills.Store(sessionKey, filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) takePendingSkills(sessionKey string) []string {
|
||||||
|
sessionKey = strings.TrimSpace(sessionKey)
|
||||||
|
if sessionKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value, ok := al.pendingSkills.LoadAndDelete(sessionKey)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
skills, ok := value.([]string)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return append([]string(nil), skills...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) clearPendingSkills(sessionKey string) {
|
||||||
|
sessionKey = strings.TrimSpace(sessionKey)
|
||||||
|
if sessionKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
al.pendingSkills.Delete(sessionKey)
|
||||||
|
}
|
||||||
206
pkg/agent/loop_event.go
Normal file
206
pkg/agent/loop_event.go
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (al *AgentLoop) newTurnEventScope(agentID, sessionKey string, turnCtx *TurnContext) turnEventScope {
|
||||||
|
seq := al.turnSeq.Add(1)
|
||||||
|
return turnEventScope{
|
||||||
|
agentID: agentID,
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
turnID: fmt.Sprintf("%s-turn-%d", agentID, seq),
|
||||||
|
context: cloneTurnContext(turnCtx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts turnEventScope) meta(iteration int, source, tracePath string) EventMeta {
|
||||||
|
return EventMeta{
|
||||||
|
AgentID: ts.agentID,
|
||||||
|
TurnID: ts.turnID,
|
||||||
|
SessionKey: ts.sessionKey,
|
||||||
|
Iteration: iteration,
|
||||||
|
Source: source,
|
||||||
|
TracePath: tracePath,
|
||||||
|
turnContext: cloneTurnContext(ts.context),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) emitEvent(kind EventKind, meta EventMeta, payload any) {
|
||||||
|
clonedMeta := cloneEventMeta(meta)
|
||||||
|
evt := Event{
|
||||||
|
Kind: kind,
|
||||||
|
Meta: clonedMeta,
|
||||||
|
Context: cloneTurnContext(clonedMeta.turnContext),
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
if al == nil || al.eventBus == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
al.logEvent(evt)
|
||||||
|
|
||||||
|
al.eventBus.Emit(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) hookAbortError(ts *turnState, stage string, decision HookDecision) error {
|
||||||
|
reason := decision.Reason
|
||||||
|
if reason == "" {
|
||||||
|
reason = "hook requested turn abort"
|
||||||
|
}
|
||||||
|
|
||||||
|
err := fmt.Errorf("hook aborted turn during %s: %s", stage, reason)
|
||||||
|
al.emitEvent(
|
||||||
|
EventKindError,
|
||||||
|
ts.eventMeta("hooks", "turn.error"),
|
||||||
|
ErrorPayload{
|
||||||
|
Stage: "hook." + stage,
|
||||||
|
Message: err.Error(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) logEvent(evt Event) {
|
||||||
|
fields := map[string]any{
|
||||||
|
"event_kind": evt.Kind.String(),
|
||||||
|
"agent_id": evt.Meta.AgentID,
|
||||||
|
"turn_id": evt.Meta.TurnID,
|
||||||
|
"session_key": evt.Meta.SessionKey,
|
||||||
|
"iteration": evt.Meta.Iteration,
|
||||||
|
}
|
||||||
|
|
||||||
|
if evt.Meta.TracePath != "" {
|
||||||
|
fields["trace"] = evt.Meta.TracePath
|
||||||
|
}
|
||||||
|
if evt.Meta.Source != "" {
|
||||||
|
fields["source"] = evt.Meta.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
appendEventContextFields(fields, evt.Context)
|
||||||
|
|
||||||
|
switch payload := evt.Payload.(type) {
|
||||||
|
case TurnStartPayload:
|
||||||
|
fields["user_len"] = len(payload.UserMessage)
|
||||||
|
fields["media_count"] = payload.MediaCount
|
||||||
|
case TurnEndPayload:
|
||||||
|
fields["status"] = payload.Status
|
||||||
|
fields["iterations_total"] = payload.Iterations
|
||||||
|
fields["duration_ms"] = payload.Duration.Milliseconds()
|
||||||
|
fields["final_len"] = payload.FinalContentLen
|
||||||
|
case LLMRequestPayload:
|
||||||
|
fields["model"] = payload.Model
|
||||||
|
fields["messages"] = payload.MessagesCount
|
||||||
|
fields["tools"] = payload.ToolsCount
|
||||||
|
fields["max_tokens"] = payload.MaxTokens
|
||||||
|
case LLMDeltaPayload:
|
||||||
|
fields["content_delta_len"] = payload.ContentDeltaLen
|
||||||
|
fields["reasoning_delta_len"] = payload.ReasoningDeltaLen
|
||||||
|
case LLMResponsePayload:
|
||||||
|
fields["content_len"] = payload.ContentLen
|
||||||
|
fields["tool_calls"] = payload.ToolCalls
|
||||||
|
fields["has_reasoning"] = payload.HasReasoning
|
||||||
|
case LLMRetryPayload:
|
||||||
|
fields["attempt"] = payload.Attempt
|
||||||
|
fields["max_retries"] = payload.MaxRetries
|
||||||
|
fields["reason"] = payload.Reason
|
||||||
|
fields["error"] = payload.Error
|
||||||
|
fields["backoff_ms"] = payload.Backoff.Milliseconds()
|
||||||
|
case ContextCompressPayload:
|
||||||
|
fields["reason"] = payload.Reason
|
||||||
|
fields["dropped_messages"] = payload.DroppedMessages
|
||||||
|
fields["remaining_messages"] = payload.RemainingMessages
|
||||||
|
case SessionSummarizePayload:
|
||||||
|
fields["summarized_messages"] = payload.SummarizedMessages
|
||||||
|
fields["kept_messages"] = payload.KeptMessages
|
||||||
|
fields["summary_len"] = payload.SummaryLen
|
||||||
|
fields["omitted_oversized"] = payload.OmittedOversized
|
||||||
|
case ToolExecStartPayload:
|
||||||
|
fields["tool"] = payload.Tool
|
||||||
|
fields["args_count"] = len(payload.Arguments)
|
||||||
|
case ToolExecEndPayload:
|
||||||
|
fields["tool"] = payload.Tool
|
||||||
|
fields["duration_ms"] = payload.Duration.Milliseconds()
|
||||||
|
fields["for_llm_len"] = payload.ForLLMLen
|
||||||
|
fields["for_user_len"] = payload.ForUserLen
|
||||||
|
fields["is_error"] = payload.IsError
|
||||||
|
fields["async"] = payload.Async
|
||||||
|
case ToolExecSkippedPayload:
|
||||||
|
fields["tool"] = payload.Tool
|
||||||
|
fields["reason"] = payload.Reason
|
||||||
|
case SteeringInjectedPayload:
|
||||||
|
fields["count"] = payload.Count
|
||||||
|
fields["total_content_len"] = payload.TotalContentLen
|
||||||
|
case FollowUpQueuedPayload:
|
||||||
|
fields["source_tool"] = payload.SourceTool
|
||||||
|
fields["content_len"] = payload.ContentLen
|
||||||
|
case InterruptReceivedPayload:
|
||||||
|
fields["interrupt_kind"] = payload.Kind
|
||||||
|
fields["role"] = payload.Role
|
||||||
|
fields["content_len"] = payload.ContentLen
|
||||||
|
fields["queue_depth"] = payload.QueueDepth
|
||||||
|
fields["hint_len"] = payload.HintLen
|
||||||
|
case SubTurnSpawnPayload:
|
||||||
|
fields["child_agent_id"] = payload.AgentID
|
||||||
|
fields["label"] = payload.Label
|
||||||
|
case SubTurnEndPayload:
|
||||||
|
fields["child_agent_id"] = payload.AgentID
|
||||||
|
fields["status"] = payload.Status
|
||||||
|
case SubTurnResultDeliveredPayload:
|
||||||
|
fields["target_channel"] = payload.TargetChannel
|
||||||
|
fields["target_chat_id"] = payload.TargetChatID
|
||||||
|
fields["content_len"] = payload.ContentLen
|
||||||
|
case ErrorPayload:
|
||||||
|
fields["stage"] = payload.Stage
|
||||||
|
fields["error"] = payload.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("eventbus", fmt.Sprintf("Agent event: %s", evt.Kind.String()), fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountHook registers an in-process hook on the agent loop.
|
||||||
|
func (al *AgentLoop) MountHook(reg HookRegistration) error {
|
||||||
|
if al == nil || al.hooks == nil {
|
||||||
|
return fmt.Errorf("hook manager is not initialized")
|
||||||
|
}
|
||||||
|
return al.hooks.Mount(reg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmountHook removes a previously registered in-process hook.
|
||||||
|
func (al *AgentLoop) UnmountHook(name string) {
|
||||||
|
if al == nil || al.hooks == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
al.hooks.Unmount(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeEvents registers a subscriber for agent-loop events.
|
||||||
|
func (al *AgentLoop) SubscribeEvents(buffer int) EventSubscription {
|
||||||
|
if al == nil || al.eventBus == nil {
|
||||||
|
ch := make(chan Event)
|
||||||
|
close(ch)
|
||||||
|
return EventSubscription{C: ch}
|
||||||
|
}
|
||||||
|
return al.eventBus.Subscribe(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnsubscribeEvents removes a previously registered event subscriber.
|
||||||
|
func (al *AgentLoop) UnsubscribeEvents(id uint64) {
|
||||||
|
if al == nil || al.eventBus == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
al.eventBus.Unsubscribe(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventDrops returns the number of dropped events for the given kind.
|
||||||
|
func (al *AgentLoop) EventDrops(kind EventKind) int64 {
|
||||||
|
if al == nil || al.eventBus == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return al.eventBus.Dropped(kind)
|
||||||
|
}
|
||||||
353
pkg/agent/loop_init.go
Normal file
353
pkg/agent/loop_init.go
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/audio/tts"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewAgentLoop(
|
||||||
|
cfg *config.Config,
|
||||||
|
msgBus *bus.MessageBus,
|
||||||
|
provider providers.LLMProvider,
|
||||||
|
) *AgentLoop {
|
||||||
|
registry := NewAgentRegistry(cfg, provider)
|
||||||
|
|
||||||
|
// Set up shared fallback chain with rate limiting.
|
||||||
|
cooldown := providers.NewCooldownTracker()
|
||||||
|
rl := providers.NewRateLimiterRegistry()
|
||||||
|
// Register rate limiters for all agents' candidates so that RPM limits
|
||||||
|
// configured in ModelConfig are enforced before each LLM call.
|
||||||
|
for _, agentID := range registry.ListAgentIDs() {
|
||||||
|
if agent, ok := registry.GetAgent(agentID); ok {
|
||||||
|
rl.RegisterCandidates(agent.Candidates)
|
||||||
|
rl.RegisterCandidates(agent.LightCandidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fallbackChain := providers.NewFallbackChain(cooldown, rl)
|
||||||
|
|
||||||
|
// Create state manager using default agent's workspace for channel recording
|
||||||
|
defaultAgent := registry.GetDefaultAgent()
|
||||||
|
var stateManager *state.Manager
|
||||||
|
if defaultAgent != nil {
|
||||||
|
stateManager = state.NewManager(defaultAgent.Workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
eventBus := NewEventBus()
|
||||||
|
|
||||||
|
// Determine worker pool size from config (default: 1 = sequential)
|
||||||
|
workerPoolSize := cfg.Agents.Defaults.MaxParallelTurns
|
||||||
|
if workerPoolSize <= 0 {
|
||||||
|
workerPoolSize = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
al := &AgentLoop{
|
||||||
|
bus: msgBus,
|
||||||
|
cfg: cfg,
|
||||||
|
registry: registry,
|
||||||
|
state: stateManager,
|
||||||
|
eventBus: eventBus,
|
||||||
|
fallback: fallbackChain,
|
||||||
|
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
|
||||||
|
steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)),
|
||||||
|
workerSem: make(chan struct{}, workerPoolSize),
|
||||||
|
}
|
||||||
|
al.providerFactory = providers.CreateProviderFromConfig
|
||||||
|
al.hooks = NewHookManager(eventBus)
|
||||||
|
configureHookManagerFromConfig(al.hooks, cfg)
|
||||||
|
al.contextManager = al.resolveContextManager()
|
||||||
|
|
||||||
|
// Register shared tools to all agents (now that al is created)
|
||||||
|
registerSharedTools(al, cfg, msgBus, registry, provider)
|
||||||
|
|
||||||
|
return al
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerSharedTools(
|
||||||
|
al *AgentLoop,
|
||||||
|
cfg *config.Config,
|
||||||
|
msgBus *bus.MessageBus,
|
||||||
|
registry *AgentRegistry,
|
||||||
|
provider providers.LLMProvider,
|
||||||
|
) {
|
||||||
|
allowReadPaths := buildAllowReadPatterns(cfg)
|
||||||
|
var ttsProvider tts.TTSProvider
|
||||||
|
if cfg.Tools.IsToolEnabled("send_tts") {
|
||||||
|
ttsProvider = tts.DetectTTS(cfg)
|
||||||
|
if ttsProvider == nil {
|
||||||
|
logger.WarnCF("voice-tts", "send_tts enabled but no TTS provider configured", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, agentID := range registry.ListAgentIDs() {
|
||||||
|
agent, ok := registry.GetAgent(agentID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Tools.IsToolEnabled("web") {
|
||||||
|
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
||||||
|
BraveAPIKeys: cfg.Tools.Web.Brave.APIKeys.Values(),
|
||||||
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
|
TavilyAPIKeys: cfg.Tools.Web.Tavily.APIKeys.Values(),
|
||||||
|
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||||
|
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||||
|
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
||||||
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||||
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||||
|
PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(),
|
||||||
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
|
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
||||||
|
SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults,
|
||||||
|
SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled,
|
||||||
|
GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey.String(),
|
||||||
|
GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
|
||||||
|
GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
|
||||||
|
GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
|
||||||
|
GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled,
|
||||||
|
BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey.String(),
|
||||||
|
BaiduSearchBaseURL: cfg.Tools.Web.BaiduSearch.BaseURL,
|
||||||
|
BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults,
|
||||||
|
BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled,
|
||||||
|
Proxy: cfg.Tools.Web.Proxy,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()})
|
||||||
|
} else if searchTool != nil {
|
||||||
|
agent.Tools.Register(searchTool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("web_fetch") {
|
||||||
|
fetchTool, err := tools.NewWebFetchToolWithProxy(
|
||||||
|
50000,
|
||||||
|
cfg.Tools.Web.Proxy,
|
||||||
|
cfg.Tools.Web.Format,
|
||||||
|
cfg.Tools.Web.FetchLimitBytes,
|
||||||
|
cfg.Tools.Web.PrivateHostWhitelist)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
||||||
|
} else {
|
||||||
|
agent.Tools.Register(fetchTool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
||||||
|
if cfg.Tools.IsToolEnabled("i2c") {
|
||||||
|
agent.Tools.Register(tools.NewI2CTool())
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("spi") {
|
||||||
|
agent.Tools.Register(tools.NewSPITool())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message tool
|
||||||
|
if cfg.Tools.IsToolEnabled("message") {
|
||||||
|
messageTool := tools.NewMessageTool()
|
||||||
|
messageTool.SetSendCallback(func(
|
||||||
|
ctx context.Context,
|
||||||
|
channel, chatID, content, replyToMessageID string,
|
||||||
|
) error {
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID)
|
||||||
|
outboundAgentID, outboundSessionKey, outboundScope := outboundTurnMetadata(
|
||||||
|
tools.ToolAgentID(ctx),
|
||||||
|
tools.ToolSessionKey(ctx),
|
||||||
|
tools.ToolSessionScope(ctx),
|
||||||
|
)
|
||||||
|
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
|
Context: outboundCtx,
|
||||||
|
AgentID: outboundAgentID,
|
||||||
|
SessionKey: outboundSessionKey,
|
||||||
|
Scope: outboundScope,
|
||||||
|
Content: content,
|
||||||
|
ReplyToMessageID: replyToMessageID,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
agent.Tools.Register(messageTool)
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("reaction") {
|
||||||
|
reactionTool := tools.NewReactionTool()
|
||||||
|
reactionTool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error {
|
||||||
|
if al.channelManager == nil {
|
||||||
|
return fmt.Errorf("channel manager not configured")
|
||||||
|
}
|
||||||
|
ch, ok := al.channelManager.GetChannel(channel)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("channel %s not found", channel)
|
||||||
|
}
|
||||||
|
rc, ok := ch.(channels.ReactionCapable)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("channel %s does not support reactions", channel)
|
||||||
|
}
|
||||||
|
_, err := rc.ReactToMessage(ctx, chatID, messageID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
agent.Tools.Register(reactionTool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send file tool (outbound media via MediaStore — store injected later by SetMediaStore)
|
||||||
|
if cfg.Tools.IsToolEnabled("send_file") {
|
||||||
|
sendFileTool := tools.NewSendFileTool(
|
||||||
|
agent.Workspace,
|
||||||
|
cfg.Agents.Defaults.RestrictToWorkspace,
|
||||||
|
cfg.Agents.Defaults.GetMaxMediaSize(),
|
||||||
|
nil,
|
||||||
|
allowReadPaths,
|
||||||
|
)
|
||||||
|
agent.Tools.Register(sendFileTool)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ttsProvider != nil {
|
||||||
|
agent.Tools.Register(tools.NewSendTTSTool(ttsProvider, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Tools.IsToolEnabled("load_image") {
|
||||||
|
loadImageTool := tools.NewLoadImageTool(
|
||||||
|
agent.Workspace,
|
||||||
|
cfg.Agents.Defaults.RestrictToWorkspace,
|
||||||
|
cfg.Agents.Defaults.GetMaxMediaSize(),
|
||||||
|
nil,
|
||||||
|
allowReadPaths,
|
||||||
|
)
|
||||||
|
agent.Tools.Register(loadImageTool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skill discovery and installation tools
|
||||||
|
skills_enabled := cfg.Tools.IsToolEnabled("skills")
|
||||||
|
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
|
||||||
|
install_skills_enable := cfg.Tools.IsToolEnabled("install_skill")
|
||||||
|
if skills_enabled && (find_skills_enable || install_skills_enable) {
|
||||||
|
registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills)
|
||||||
|
|
||||||
|
if find_skills_enable {
|
||||||
|
searchCache := skills.NewSearchCache(
|
||||||
|
cfg.Tools.Skills.SearchCache.MaxSize,
|
||||||
|
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
|
||||||
|
)
|
||||||
|
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
|
||||||
|
}
|
||||||
|
|
||||||
|
if install_skills_enable {
|
||||||
|
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn and spawn_status tools share a SubagentManager.
|
||||||
|
// Construct it when either tool is enabled (both require subagent).
|
||||||
|
spawnEnabled := cfg.Tools.IsToolEnabled("spawn")
|
||||||
|
spawnStatusEnabled := cfg.Tools.IsToolEnabled("spawn_status")
|
||||||
|
if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") {
|
||||||
|
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
|
||||||
|
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
||||||
|
|
||||||
|
// Inject a media resolver so the legacy RunToolLoop fallback path can
|
||||||
|
// resolve media:// refs in the same way the main AgentLoop does.
|
||||||
|
// This keeps subagent vision support working even when the optimized
|
||||||
|
// sub-turn spawner path is unavailable.
|
||||||
|
subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message {
|
||||||
|
return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set the spawner that links into AgentLoop's turnState
|
||||||
|
subagentManager.SetSpawner(func(
|
||||||
|
ctx context.Context,
|
||||||
|
task, label, targetAgentID string,
|
||||||
|
tls *tools.ToolRegistry,
|
||||||
|
maxTokens int,
|
||||||
|
temperature float64,
|
||||||
|
hasMaxTokens, hasTemperature bool,
|
||||||
|
) (*tools.ToolResult, error) {
|
||||||
|
// 1. Recover parent Turn State from Context
|
||||||
|
parentTS := turnStateFromContext(ctx)
|
||||||
|
if parentTS == nil {
|
||||||
|
// Fallback: If no turnState exists in context, create an isolated ad-hoc root turn state
|
||||||
|
// so that the tool can still function outside of an agent loop (e.g. tests, raw invocations).
|
||||||
|
parentTS = &turnState{
|
||||||
|
ctx: ctx,
|
||||||
|
turnID: "adhoc-root",
|
||||||
|
depth: 0,
|
||||||
|
session: nil, // Ephemeral session not needed for adhoc spawn
|
||||||
|
pendingResults: make(chan *tools.ToolResult, 16),
|
||||||
|
concurrencySem: make(chan struct{}, 5),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Build Tools slice from registry
|
||||||
|
var tlSlice []tools.Tool
|
||||||
|
for _, name := range tls.List() {
|
||||||
|
if t, ok := tls.Get(name); ok {
|
||||||
|
tlSlice = append(tlSlice, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. System Prompt
|
||||||
|
systemPrompt := "You are a subagent. Complete the given task independently and report the result.\n" +
|
||||||
|
"You have access to tools - use them as needed to complete your task.\n" +
|
||||||
|
"After completing the task, provide a clear summary of what was done.\n\n" +
|
||||||
|
"Task: " + task
|
||||||
|
|
||||||
|
// 4. Resolve Model
|
||||||
|
modelToUse := agent.Model
|
||||||
|
if targetAgentID != "" {
|
||||||
|
if targetAgent, ok := al.GetRegistry().GetAgent(targetAgentID); ok {
|
||||||
|
modelToUse = targetAgent.Model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Build SubTurnConfig
|
||||||
|
cfg := SubTurnConfig{
|
||||||
|
Model: modelToUse,
|
||||||
|
Tools: tlSlice,
|
||||||
|
SystemPrompt: systemPrompt,
|
||||||
|
}
|
||||||
|
if hasMaxTokens {
|
||||||
|
cfg.MaxTokens = maxTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Spawn SubTurn
|
||||||
|
return spawnSubTurn(ctx, al, parentTS, cfg)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clone the parent's tool registry so subagents can use all
|
||||||
|
// tools registered so far (file, web, etc.) but NOT spawn/
|
||||||
|
// spawn_status which are added below — preventing recursive
|
||||||
|
// subagent spawning.
|
||||||
|
subagentManager.SetTools(agent.Tools.Clone())
|
||||||
|
if spawnEnabled {
|
||||||
|
spawnTool := tools.NewSpawnTool(subagentManager)
|
||||||
|
spawnTool.SetSpawner(NewSubTurnSpawner(al))
|
||||||
|
currentAgentID := agentID
|
||||||
|
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
||||||
|
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
||||||
|
})
|
||||||
|
|
||||||
|
agent.Tools.Register(spawnTool)
|
||||||
|
|
||||||
|
// Also register the synchronous subagent tool
|
||||||
|
subagentTool := tools.NewSubagentTool(subagentManager)
|
||||||
|
subagentTool.SetSpawner(NewSubTurnSpawner(al))
|
||||||
|
agent.Tools.Register(subagentTool)
|
||||||
|
}
|
||||||
|
if spawnStatusEnabled {
|
||||||
|
agent.Tools.Register(tools.NewSpawnStatusTool(subagentManager))
|
||||||
|
}
|
||||||
|
} else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") {
|
||||||
|
logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
103
pkg/agent/loop_inject.go
Normal file
103
pkg/agent/loop_inject.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/audio/asr"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
|
||||||
|
registry := al.GetRegistry()
|
||||||
|
for _, agentID := range registry.ListAgentIDs() {
|
||||||
|
if agent, ok := registry.GetAgent(agentID); ok {
|
||||||
|
agent.Tools.Register(tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
||||||
|
al.channelManager = cm
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) GetRegistry() *AgentRegistry {
|
||||||
|
al.mu.RLock()
|
||||||
|
defer al.mu.RUnlock()
|
||||||
|
return al.registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) GetConfig() *config.Config {
|
||||||
|
al.mu.RLock()
|
||||||
|
defer al.mu.RUnlock()
|
||||||
|
return al.cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
||||||
|
al.mediaStore = s
|
||||||
|
|
||||||
|
// Propagate store to all registered tools that can emit media.
|
||||||
|
registry := al.GetRegistry()
|
||||||
|
for _, agentID := range registry.ListAgentIDs() {
|
||||||
|
if agent, ok := registry.GetAgent(agentID); ok {
|
||||||
|
agent.Tools.SetMediaStore(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registry.ForEachTool("send_tts", func(t tools.Tool) {
|
||||||
|
if st, ok := t.(*tools.SendTTSTool); ok {
|
||||||
|
st.SetMediaStore(s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) SetTranscriber(t asr.Transcriber) {
|
||||||
|
al.transcriber = t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) SetReloadFunc(fn func() error) {
|
||||||
|
al.reloadFunc = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) RecordLastChannel(channel string) error {
|
||||||
|
if al.state == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return al.state.SetLastChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) RecordLastChatID(chatID string) error {
|
||||||
|
if al.state == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return al.state.SetLastChatID(chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) GetStartupInfo() map[string]any {
|
||||||
|
info := make(map[string]any)
|
||||||
|
|
||||||
|
registry := al.GetRegistry()
|
||||||
|
agent := registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools info
|
||||||
|
toolsList := agent.Tools.List()
|
||||||
|
info["tools"] = map[string]any{
|
||||||
|
"count": len(toolsList),
|
||||||
|
"names": toolsList,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills info
|
||||||
|
info["skills"] = agent.ContextBuilder.GetSkillsInfo()
|
||||||
|
|
||||||
|
// Agents info
|
||||||
|
info["agents"] = map[string]any{
|
||||||
|
"count": len(registry.ListAgentIDs()),
|
||||||
|
"ids": registry.ListAgentIDs(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,16 @@ type mcpRuntime struct {
|
||||||
initErr error
|
initErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *mcpRuntime) reset() *mcp.Manager {
|
||||||
|
r.mu.Lock()
|
||||||
|
manager := r.manager
|
||||||
|
r.manager = nil
|
||||||
|
r.initErr = nil
|
||||||
|
r.initOnce = sync.Once{}
|
||||||
|
r.mu.Unlock()
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
func (r *mcpRuntime) setManager(manager *mcp.Manager) {
|
func (r *mcpRuntime) setManager(manager *mcp.Manager) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.manager = manager
|
r.manager = manager
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,73 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
func boolPtr(b bool) *bool { return &b }
|
func boolPtr(b bool) *bool { return &b }
|
||||||
|
|
||||||
|
func TestMCPRuntimeResetClearsState(t *testing.T) {
|
||||||
|
var rt mcpRuntime
|
||||||
|
manager := mcp.NewManager()
|
||||||
|
rt.setManager(manager)
|
||||||
|
rt.setInitErr(errors.New("stale init error"))
|
||||||
|
rt.initOnce.Do(func() {})
|
||||||
|
|
||||||
|
got := rt.reset()
|
||||||
|
if got != manager {
|
||||||
|
t.Fatalf("reset() manager = %p, want %p", got, manager)
|
||||||
|
}
|
||||||
|
if rt.hasManager() {
|
||||||
|
t.Fatal("expected manager to be cleared after reset")
|
||||||
|
}
|
||||||
|
if err := rt.getInitErr(); err != nil {
|
||||||
|
t.Fatalf("getInitErr() = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reran := false
|
||||||
|
rt.initOnce.Do(func() { reran = true })
|
||||||
|
if !reran {
|
||||||
|
t.Fatal("expected initOnce to be reset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReloadProviderAndConfig_ResetsMCPRuntime(t *testing.T) {
|
||||||
|
al, cfg, _, _, cleanup := newTestAgentLoop(t)
|
||||||
|
defer cleanup()
|
||||||
|
defer al.Close()
|
||||||
|
|
||||||
|
manager := mcp.NewManager()
|
||||||
|
al.mcp.setManager(manager)
|
||||||
|
al.mcp.setInitErr(errors.New("stale init error"))
|
||||||
|
al.mcp.initOnce.Do(func() {})
|
||||||
|
|
||||||
|
if !al.mcp.hasManager() {
|
||||||
|
t.Fatal("expected MCP manager to exist before reload")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := al.ReloadProviderAndConfig(context.Background(), &mockProvider{}, cfg); err != nil {
|
||||||
|
t.Fatalf("ReloadProviderAndConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if al.mcp.hasManager() {
|
||||||
|
t.Fatal("expected MCP manager to be cleared when reloaded config has MCP disabled")
|
||||||
|
}
|
||||||
|
if err := al.mcp.getInitErr(); err != nil {
|
||||||
|
t.Fatalf("getInitErr() = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reran := false
|
||||||
|
al.mcp.initOnce.Do(func() { reran = true })
|
||||||
|
if !reran {
|
||||||
|
t.Fatal("expected MCP initOnce to be reset after reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServerIsDeferred(t *testing.T) {
|
func TestServerIsDeferred(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
302
pkg/agent/loop_message.go
Normal file
302
pkg/agent/loop_message.go
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (al *AgentLoop) buildContinuationTarget(msg bus.InboundMessage) (*continuationTarget, error) {
|
||||||
|
if msg.Channel == "system" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
route, _, err := al.resolveMessageRoute(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
allocation := al.allocateRouteSession(route, msg)
|
||||||
|
|
||||||
|
return &continuationTarget{
|
||||||
|
SessionKey: resolveScopeKey(allocation.SessionKey, msg.SessionKey),
|
||||||
|
Channel: msg.Channel,
|
||||||
|
ChatID: msg.ChatID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) ProcessDirect(
|
||||||
|
ctx context.Context,
|
||||||
|
content, sessionKey string,
|
||||||
|
) (string, error) {
|
||||||
|
return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) ProcessDirectWithChannel(
|
||||||
|
ctx context.Context,
|
||||||
|
content, sessionKey, channel, chatID string,
|
||||||
|
) (string, error) {
|
||||||
|
if err := al.ensureHooksInitialized(ctx); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := bus.InboundMessage{
|
||||||
|
Context: bus.InboundContext{
|
||||||
|
Channel: channel,
|
||||||
|
ChatID: chatID,
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "cron",
|
||||||
|
},
|
||||||
|
Content: content,
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
return al.processMessage(ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) ProcessHeartbeat(
|
||||||
|
ctx context.Context,
|
||||||
|
content, channel, chatID string,
|
||||||
|
) (string, error) {
|
||||||
|
if err := al.ensureHooksInitialized(ctx); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := al.GetRegistry().GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return "", fmt.Errorf("no default agent for heartbeat")
|
||||||
|
}
|
||||||
|
dispatch := DispatchRequest{
|
||||||
|
SessionKey: "heartbeat",
|
||||||
|
UserMessage: content,
|
||||||
|
}
|
||||||
|
if channel != "" || chatID != "" {
|
||||||
|
dispatch.InboundContext = &bus.InboundContext{
|
||||||
|
Channel: channel,
|
||||||
|
ChatID: chatID,
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "heartbeat",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return al.runAgentLoop(ctx, agent, processOptions{
|
||||||
|
Dispatch: dispatch,
|
||||||
|
DefaultResponse: defaultResponse,
|
||||||
|
EnableSummary: false,
|
||||||
|
SendResponse: false,
|
||||||
|
SuppressToolFeedback: true,
|
||||||
|
NoHistory: true, // Don't load session history for heartbeat
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
|
||||||
|
msg = bus.NormalizeInboundMessage(msg)
|
||||||
|
|
||||||
|
// Add message preview to log (show full content for error messages)
|
||||||
|
var logContent string
|
||||||
|
if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") {
|
||||||
|
logContent = msg.Content // Full content for errors
|
||||||
|
} else {
|
||||||
|
logContent = utils.Truncate(msg.Content, 80)
|
||||||
|
}
|
||||||
|
logger.InfoCF(
|
||||||
|
"agent",
|
||||||
|
fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent),
|
||||||
|
map[string]any{
|
||||||
|
"channel": msg.Channel,
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"sender_id": msg.SenderID,
|
||||||
|
"session_key": msg.SessionKey,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
var hadAudio bool
|
||||||
|
msg, hadAudio = al.transcribeAudioInMessage(ctx, msg)
|
||||||
|
|
||||||
|
// For audio messages the placeholder was deferred by the channel.
|
||||||
|
// Now that transcription (and optional feedback) is done, send it.
|
||||||
|
if hadAudio && al.channelManager != nil {
|
||||||
|
al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route system messages to processSystemMessage
|
||||||
|
if msg.Channel == "system" {
|
||||||
|
return al.processSystemMessage(ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
route, agent, routeErr := al.resolveMessageRoute(msg)
|
||||||
|
if routeErr != nil {
|
||||||
|
return "", routeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
allocation := al.allocateRouteSession(route, msg)
|
||||||
|
|
||||||
|
// Resolve session key from the route allocation, while preserving explicit
|
||||||
|
// agent-scoped keys supplied by the caller.
|
||||||
|
scopeKey := resolveScopeKey(allocation.SessionKey, msg.SessionKey)
|
||||||
|
sessionKey := scopeKey
|
||||||
|
|
||||||
|
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
|
||||||
|
if tool, ok := agent.Tools.Get("message"); ok {
|
||||||
|
if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok {
|
||||||
|
resetter.ResetSentInRound(sessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Routed message",
|
||||||
|
map[string]any{
|
||||||
|
"agent_id": agent.ID,
|
||||||
|
"scope_key": scopeKey,
|
||||||
|
"session_key": sessionKey,
|
||||||
|
"matched_by": route.MatchedBy,
|
||||||
|
"route_agent": route.AgentID,
|
||||||
|
"route_channel": route.Channel,
|
||||||
|
"route_main_session": allocation.MainSessionKey,
|
||||||
|
})
|
||||||
|
|
||||||
|
opts := processOptions{
|
||||||
|
Dispatch: DispatchRequest{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
SessionAliases: buildSessionAliases(sessionKey, append(allocation.SessionAliases, msg.SessionKey)...),
|
||||||
|
InboundContext: cloneInboundContext(&msg.Context),
|
||||||
|
RouteResult: cloneResolvedRoute(&route),
|
||||||
|
SessionScope: session.CloneScope(&allocation.Scope),
|
||||||
|
UserMessage: msg.Content,
|
||||||
|
Media: append([]string(nil), msg.Media...),
|
||||||
|
},
|
||||||
|
SenderID: msg.SenderID,
|
||||||
|
SenderDisplayName: msg.Sender.DisplayName,
|
||||||
|
DefaultResponse: defaultResponse,
|
||||||
|
EnableSummary: true,
|
||||||
|
SendResponse: false,
|
||||||
|
AllowInterimPicoPublish: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// context-dependent commands check their own Runtime fields and report
|
||||||
|
// "unavailable" when the required capability is nil.
|
||||||
|
if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled {
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if pending := al.takePendingSkills(opts.Dispatch.SessionKey); len(pending) > 0 {
|
||||||
|
opts.ForcedSkills = append(opts.ForcedSkills, pending...)
|
||||||
|
logger.InfoCF("agent", "Applying pending skill override",
|
||||||
|
map[string]any{
|
||||||
|
"session_key": opts.Dispatch.SessionKey,
|
||||||
|
"skills": strings.Join(pending, ","),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return al.runAgentLoop(ctx, agent, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) {
|
||||||
|
registry := al.GetRegistry()
|
||||||
|
inboundCtx := normalizedInboundContext(msg)
|
||||||
|
route := registry.ResolveRoute(inboundCtx)
|
||||||
|
|
||||||
|
agent, ok := registry.GetAgent(route.AgentID)
|
||||||
|
if !ok {
|
||||||
|
agent = registry.GetDefaultAgent()
|
||||||
|
}
|
||||||
|
if agent == nil {
|
||||||
|
return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return route, agent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) allocateRouteSession(route routing.ResolvedRoute, msg bus.InboundMessage) session.Allocation {
|
||||||
|
return session.AllocateRouteSession(session.AllocationInput{
|
||||||
|
AgentID: route.AgentID,
|
||||||
|
Context: normalizedInboundContext(msg),
|
||||||
|
SessionPolicy: route.SessionPolicy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) processSystemMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
msg bus.InboundMessage,
|
||||||
|
) (string, error) {
|
||||||
|
if msg.Channel != "system" {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"processSystemMessage called with non-system message channel: %s",
|
||||||
|
msg.Channel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Processing system message",
|
||||||
|
map[string]any{
|
||||||
|
"sender_id": msg.SenderID,
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Parse origin channel from chat_id (format: "channel:chat_id")
|
||||||
|
var originChannel, originChatID string
|
||||||
|
if idx := strings.Index(msg.ChatID, ":"); idx > 0 {
|
||||||
|
originChannel = msg.ChatID[:idx]
|
||||||
|
originChatID = msg.ChatID[idx+1:]
|
||||||
|
} else {
|
||||||
|
originChannel = "cli"
|
||||||
|
originChatID = msg.ChatID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract subagent result from message content
|
||||||
|
// Format: "Task 'label' completed.\n\nResult:\n<actual content>"
|
||||||
|
content := msg.Content
|
||||||
|
if idx := strings.Index(content, "Result:\n"); idx >= 0 {
|
||||||
|
content = content[idx+8:] // Extract just the result part
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip internal channels - only log, don't send to user
|
||||||
|
if constants.IsInternalChannel(originChannel) {
|
||||||
|
logger.InfoCF("agent", "Subagent completed (internal channel)",
|
||||||
|
map[string]any{
|
||||||
|
"sender_id": msg.SenderID,
|
||||||
|
"content_len": len(content),
|
||||||
|
"channel": originChannel,
|
||||||
|
})
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use default agent for system messages
|
||||||
|
agent := al.GetRegistry().GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return "", fmt.Errorf("no default agent for system message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the origin session for context
|
||||||
|
sessionKey := session.BuildMainSessionKey(agent.ID)
|
||||||
|
dispatch := DispatchRequest{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content),
|
||||||
|
}
|
||||||
|
if originChannel != "" || originChatID != "" {
|
||||||
|
dispatch.InboundContext = &bus.InboundContext{
|
||||||
|
Channel: originChannel,
|
||||||
|
ChatID: originChatID,
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: msg.SenderID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return al.runAgentLoop(ctx, agent, processOptions{
|
||||||
|
Dispatch: dispatch,
|
||||||
|
DefaultResponse: "Background task completed.",
|
||||||
|
EnableSummary: false,
|
||||||
|
SendResponse: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
165
pkg/agent/loop_outbound.go
Normal file
165
pkg/agent/loop_outbound.go
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (al *AgentLoop) maybePublishError(ctx context.Context, channel, chatID, sessionKey string, err error) bool {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
al.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, fmt.Sprintf("Error processing message: %v", err))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) publishResponseOrError(
|
||||||
|
ctx context.Context,
|
||||||
|
channel, chatID, sessionKey string,
|
||||||
|
response string,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
if err != nil {
|
||||||
|
if !al.maybePublishError(ctx, channel, chatID, sessionKey, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response = ""
|
||||||
|
}
|
||||||
|
al.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string) {
|
||||||
|
if response == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
alreadySentToSameChat := false
|
||||||
|
defaultAgent := al.GetRegistry().GetDefaultAgent()
|
||||||
|
if defaultAgent != nil {
|
||||||
|
if tool, ok := defaultAgent.Tools.Get("message"); ok {
|
||||||
|
if mt, ok := tool.(*tools.MessageTool); ok {
|
||||||
|
alreadySentToSameChat = mt.HasSentTo(sessionKey, channel, chatID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if alreadySentToSameChat {
|
||||||
|
logger.DebugCF(
|
||||||
|
"agent",
|
||||||
|
"Skipped outbound (message tool already sent to same chat)",
|
||||||
|
map[string]any{"channel": channel, "chat_id": chatID},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
|
Context: bus.NewOutboundContext(channel, chatID, ""),
|
||||||
|
Content: response,
|
||||||
|
})
|
||||||
|
logger.InfoCF("agent", "Published outbound response",
|
||||||
|
map[string]any{
|
||||||
|
"channel": channel,
|
||||||
|
"chat_id": chatID,
|
||||||
|
"content_len": len(response),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) {
|
||||||
|
if al.channelManager == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if ch, ok := al.channelManager.GetChannel(channelName); ok {
|
||||||
|
return ch.ReasoningChannelID()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, chatID string) {
|
||||||
|
if reasoningContent == "" || chatID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
|
||||||
|
if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
|
Context: bus.InboundContext{
|
||||||
|
Channel: "pico",
|
||||||
|
ChatID: chatID,
|
||||||
|
Raw: map[string]string{
|
||||||
|
metadataKeyMessageKind: messageKindThought,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Content: reasoningContent,
|
||||||
|
}); err != nil {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
|
||||||
|
errors.Is(err, bus.ErrBusClosed) {
|
||||||
|
logger.DebugCF("agent", "Pico reasoning publish skipped (timeout/cancel)", map[string]any{
|
||||||
|
"channel": "pico",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "Failed to publish pico reasoning (best-effort)", map[string]any{
|
||||||
|
"channel": "pico",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) handleReasoning(
|
||||||
|
ctx context.Context,
|
||||||
|
reasoningContent, channelName, channelID string,
|
||||||
|
) {
|
||||||
|
if reasoningContent == "" || channelName == "" || channelID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check context cancellation before attempting to publish,
|
||||||
|
// since PublishOutbound's select may race between send and ctx.Done().
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a short timeout so the goroutine does not block indefinitely when
|
||||||
|
// the outbound bus is full. Reasoning output is best-effort; dropping it
|
||||||
|
// is acceptable to avoid goroutine accumulation.
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
|
||||||
|
if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
|
Context: bus.NewOutboundContext(channelName, channelID, ""),
|
||||||
|
Content: reasoningContent,
|
||||||
|
}); err != nil {
|
||||||
|
// Treat context.DeadlineExceeded / context.Canceled as expected
|
||||||
|
// (bus full under load, or parent canceled). Check the error
|
||||||
|
// itself rather than ctx.Err(), because pubCtx may time out
|
||||||
|
// (5 s) while the parent ctx is still active.
|
||||||
|
// Also treat ErrBusClosed as expected — it occurs during normal
|
||||||
|
// shutdown when the bus is closed before all goroutines finish.
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
|
||||||
|
errors.Is(err, bus.ErrBusClosed) {
|
||||||
|
logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{
|
||||||
|
"channel": channelName,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{
|
||||||
|
"channel": channelName,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
pkg/agent/loop_steering.go
Normal file
96
pkg/agent/loop_steering.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (al *AgentLoop) processMessageSync(ctx context.Context, msg bus.InboundMessage) {
|
||||||
|
if al.channelManager != nil {
|
||||||
|
defer al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := al.processMessage(ctx, msg)
|
||||||
|
al.publishResponseOrError(ctx, msg.Channel, msg.ChatID, msg.SessionKey, response, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.InboundMessage) {
|
||||||
|
// Process the initial message
|
||||||
|
response, err := al.processMessage(ctx, initialMsg)
|
||||||
|
if err != nil {
|
||||||
|
if !al.maybePublishError(ctx, initialMsg.Channel, initialMsg.ChatID, initialMsg.SessionKey, err) {
|
||||||
|
return // context canceled
|
||||||
|
}
|
||||||
|
response = ""
|
||||||
|
}
|
||||||
|
finalResponse := response
|
||||||
|
|
||||||
|
// Build continuation target
|
||||||
|
target, targetErr := al.buildContinuationTarget(initialMsg)
|
||||||
|
if targetErr != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to build steering continuation target",
|
||||||
|
map[string]any{
|
||||||
|
"channel": initialMsg.Channel,
|
||||||
|
"error": targetErr.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target == nil {
|
||||||
|
// System message or non-routable, response already published
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain steering queue using existing Continue mechanism
|
||||||
|
for al.pendingSteeringCountForScope(target.SessionKey) > 0 {
|
||||||
|
// Check for context cancellation between iterations
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Continuing queued steering after turn end",
|
||||||
|
map[string]any{
|
||||||
|
"channel": target.Channel,
|
||||||
|
"chat_id": target.ChatID,
|
||||||
|
"session_key": target.SessionKey,
|
||||||
|
"queue_depth": al.pendingSteeringCountForScope(target.SessionKey),
|
||||||
|
})
|
||||||
|
|
||||||
|
continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID)
|
||||||
|
if continueErr != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to continue queued steering",
|
||||||
|
map[string]any{
|
||||||
|
"channel": target.Channel,
|
||||||
|
"chat_id": target.ChatID,
|
||||||
|
"error": continueErr.Error(),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if continued == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
finalResponse = continued
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish final response
|
||||||
|
if finalResponse != "" {
|
||||||
|
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) {
|
||||||
|
if msg.Channel == "system" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
route, agent, err := al.resolveMessageRoute(msg)
|
||||||
|
if err != nil || agent == nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
allocation := al.allocateRouteSession(route, msg)
|
||||||
|
|
||||||
|
return resolveScopeKey(allocation.SessionKey, msg.SessionKey), agent.ID, true
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
109
pkg/agent/loop_transcribe.go
Normal file
109
pkg/agent/loop_transcribe.go
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) {
|
||||||
|
if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 {
|
||||||
|
return msg, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcribe each audio media ref in order.
|
||||||
|
var transcriptions []string
|
||||||
|
var keptMedia []string
|
||||||
|
for _, ref := range msg.Media {
|
||||||
|
path, meta, err := al.mediaStore.ResolveWithMeta(ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err})
|
||||||
|
keptMedia = append(keptMedia, ref)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !utils.IsAudioFile(meta.Filename, meta.ContentType) {
|
||||||
|
keptMedia = append(keptMedia, ref)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, err := al.transcriber.Transcribe(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err})
|
||||||
|
transcriptions = append(transcriptions, "")
|
||||||
|
keptMedia = append(keptMedia, ref)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
transcriptions = append(transcriptions, result.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(transcriptions) == 0 {
|
||||||
|
return msg, false
|
||||||
|
}
|
||||||
|
|
||||||
|
al.sendTranscriptionFeedback(ctx, msg.Channel, msg.ChatID, msg.MessageID, transcriptions)
|
||||||
|
|
||||||
|
// Replace audio annotations sequentially with transcriptions.
|
||||||
|
idx := 0
|
||||||
|
newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string {
|
||||||
|
if idx >= len(transcriptions) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
text := transcriptions[idx]
|
||||||
|
idx++
|
||||||
|
if text == "" {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
return "[voice: " + text + "]"
|
||||||
|
})
|
||||||
|
|
||||||
|
// Append any remaining transcriptions not matched by an annotation.
|
||||||
|
for ; idx < len(transcriptions); idx++ {
|
||||||
|
if transcriptions[idx] != "" {
|
||||||
|
newContent += "\n[voice: " + transcriptions[idx] + "]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Content = newContent
|
||||||
|
msg.Media = keptMedia
|
||||||
|
return msg, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) sendTranscriptionFeedback(
|
||||||
|
ctx context.Context,
|
||||||
|
channel, chatID, messageID string,
|
||||||
|
validTexts []string,
|
||||||
|
) {
|
||||||
|
if !al.cfg.Voice.EchoTranscription {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if al.channelManager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonEmpty []string
|
||||||
|
for _, t := range validTexts {
|
||||||
|
if t != "" {
|
||||||
|
nonEmpty = append(nonEmpty, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var feedbackMsg string
|
||||||
|
if len(nonEmpty) > 0 {
|
||||||
|
feedbackMsg = "Transcript: " + strings.Join(nonEmpty, "\n")
|
||||||
|
} else {
|
||||||
|
feedbackMsg = "No voice detected in the audio"
|
||||||
|
}
|
||||||
|
|
||||||
|
err := al.channelManager.SendMessage(ctx, bus.OutboundMessage{
|
||||||
|
Context: bus.NewOutboundContext(channel, chatID, messageID),
|
||||||
|
Content: feedbackMsg,
|
||||||
|
ReplyToMessageID: messageID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
1878
pkg/agent/loop_turn.go
Normal file
1878
pkg/agent/loop_turn.go
Normal file
File diff suppressed because it is too large
Load diff
482
pkg/agent/loop_utils.go
Normal file
482
pkg/agent/loop_utils.go
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func outboundContextFromInbound(
|
||||||
|
inbound *bus.InboundContext,
|
||||||
|
channel, chatID, replyToMessageID string,
|
||||||
|
) bus.InboundContext {
|
||||||
|
if inbound == nil {
|
||||||
|
return bus.NewOutboundContext(channel, chatID, replyToMessageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
outboundCtx := *cloneInboundContext(inbound)
|
||||||
|
if outboundCtx.Channel == "" {
|
||||||
|
outboundCtx.Channel = channel
|
||||||
|
}
|
||||||
|
if outboundCtx.ChatID == "" {
|
||||||
|
outboundCtx.ChatID = chatID
|
||||||
|
}
|
||||||
|
if outboundCtx.ReplyToMessageID == "" {
|
||||||
|
outboundCtx.ReplyToMessageID = replyToMessageID
|
||||||
|
}
|
||||||
|
return outboundCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
func outboundScopeFromSessionScope(scope *session.SessionScope) *bus.OutboundScope {
|
||||||
|
if scope == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
outboundScope := &bus.OutboundScope{
|
||||||
|
Version: scope.Version,
|
||||||
|
AgentID: scope.AgentID,
|
||||||
|
Channel: scope.Channel,
|
||||||
|
Account: scope.Account,
|
||||||
|
}
|
||||||
|
if len(scope.Dimensions) > 0 {
|
||||||
|
outboundScope.Dimensions = append([]string(nil), scope.Dimensions...)
|
||||||
|
}
|
||||||
|
if len(scope.Values) > 0 {
|
||||||
|
outboundScope.Values = make(map[string]string, len(scope.Values))
|
||||||
|
for key, value := range scope.Values {
|
||||||
|
outboundScope.Values[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outboundScope
|
||||||
|
}
|
||||||
|
|
||||||
|
func outboundTurnMetadata(
|
||||||
|
agentID, sessionKey string,
|
||||||
|
scope *session.SessionScope,
|
||||||
|
) (string, string, *bus.OutboundScope) {
|
||||||
|
return agentID, sessionKey, outboundScopeFromSessionScope(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
func outboundMessageForTurn(ts *turnState, content string) bus.OutboundMessage {
|
||||||
|
agentID, sessionKey, scope := outboundTurnMetadata(ts.agent.ID, ts.sessionKey, ts.opts.Dispatch.SessionScope)
|
||||||
|
return bus.OutboundMessage{
|
||||||
|
Channel: ts.channel,
|
||||||
|
ChatID: ts.chatID,
|
||||||
|
Context: outboundContextFromInbound(
|
||||||
|
ts.opts.Dispatch.InboundContext,
|
||||||
|
ts.channel,
|
||||||
|
ts.chatID,
|
||||||
|
ts.opts.Dispatch.ReplyToMessageID(),
|
||||||
|
),
|
||||||
|
AgentID: agentID,
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
Scope: scope,
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneEventArguments(args map[string]any) map[string]any {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cloned := make(map[string]any, len(args))
|
||||||
|
for k, v := range args {
|
||||||
|
cloned[k] = v
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func hookDeniedToolContent(prefix, reason string) string {
|
||||||
|
if reason == "" {
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
return prefix + ": " + reason
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendEventContextFields(fields map[string]any, turnCtx *TurnContext) {
|
||||||
|
if turnCtx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if inbound := turnCtx.Inbound; inbound != nil {
|
||||||
|
if inbound.Channel != "" {
|
||||||
|
fields["inbound_channel"] = inbound.Channel
|
||||||
|
}
|
||||||
|
if inbound.Account != "" {
|
||||||
|
fields["inbound_account"] = inbound.Account
|
||||||
|
}
|
||||||
|
if inbound.ChatID != "" {
|
||||||
|
fields["inbound_chat_id"] = inbound.ChatID
|
||||||
|
}
|
||||||
|
if inbound.ChatType != "" {
|
||||||
|
fields["inbound_chat_type"] = inbound.ChatType
|
||||||
|
}
|
||||||
|
if inbound.TopicID != "" {
|
||||||
|
fields["inbound_topic_id"] = inbound.TopicID
|
||||||
|
}
|
||||||
|
if inbound.SpaceType != "" {
|
||||||
|
fields["inbound_space_type"] = inbound.SpaceType
|
||||||
|
}
|
||||||
|
if inbound.SpaceID != "" {
|
||||||
|
fields["inbound_space_id"] = inbound.SpaceID
|
||||||
|
}
|
||||||
|
if inbound.SenderID != "" {
|
||||||
|
fields["inbound_sender_id"] = inbound.SenderID
|
||||||
|
}
|
||||||
|
if inbound.Mentioned {
|
||||||
|
fields["inbound_mentioned"] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if route := turnCtx.Route; route != nil {
|
||||||
|
if route.AgentID != "" {
|
||||||
|
fields["route_agent_id"] = route.AgentID
|
||||||
|
}
|
||||||
|
if route.Channel != "" {
|
||||||
|
fields["route_channel"] = route.Channel
|
||||||
|
}
|
||||||
|
if route.AccountID != "" {
|
||||||
|
fields["route_account_id"] = route.AccountID
|
||||||
|
}
|
||||||
|
if route.MatchedBy != "" {
|
||||||
|
fields["route_matched_by"] = route.MatchedBy
|
||||||
|
}
|
||||||
|
if len(route.SessionPolicy.Dimensions) > 0 {
|
||||||
|
fields["route_dimensions"] = strings.Join(route.SessionPolicy.Dimensions, ",")
|
||||||
|
}
|
||||||
|
if count := len(route.SessionPolicy.IdentityLinks); count > 0 {
|
||||||
|
fields["route_identity_link_count"] = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if scope := turnCtx.Scope; scope != nil {
|
||||||
|
if scope.Version > 0 {
|
||||||
|
fields["scope_version"] = scope.Version
|
||||||
|
}
|
||||||
|
if scope.AgentID != "" {
|
||||||
|
fields["scope_agent_id"] = scope.AgentID
|
||||||
|
}
|
||||||
|
if scope.Channel != "" {
|
||||||
|
fields["scope_channel"] = scope.Channel
|
||||||
|
}
|
||||||
|
if scope.Account != "" {
|
||||||
|
fields["scope_account"] = scope.Account
|
||||||
|
}
|
||||||
|
if len(scope.Dimensions) > 0 {
|
||||||
|
fields["scope_dimensions"] = strings.Join(scope.Dimensions, ",")
|
||||||
|
}
|
||||||
|
for dim, value := range scope.Values {
|
||||||
|
if dim == "" || value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields["scope_"+dim] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferMediaType(filename, contentType string) string {
|
||||||
|
ct := strings.ToLower(contentType)
|
||||||
|
fn := strings.ToLower(filename)
|
||||||
|
|
||||||
|
if strings.HasPrefix(ct, "image/") {
|
||||||
|
return "image"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" {
|
||||||
|
return "audio"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(ct, "video/") {
|
||||||
|
return "video"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: infer from extension
|
||||||
|
ext := filepath.Ext(fn)
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
|
||||||
|
return "image"
|
||||||
|
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
|
||||||
|
return "audio"
|
||||||
|
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
|
||||||
|
return "video"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedInboundContext(msg bus.InboundMessage) bus.InboundContext {
|
||||||
|
return bus.NormalizeInboundMessage(msg).Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveScopeKey(routeSessionKey, msgSessionKey string) string {
|
||||||
|
if isExplicitSessionKey(msgSessionKey) {
|
||||||
|
return msgSessionKey
|
||||||
|
}
|
||||||
|
return routeSessionKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExplicitSessionKey(sessionKey string) bool {
|
||||||
|
return session.IsExplicitSessionKey(sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSessionAliases(canonicalKey string, keys ...string) []string {
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
aliases := make([]string, 0, len(keys))
|
||||||
|
seen := make(map[string]struct{}, len(keys))
|
||||||
|
canonicalKey = strings.TrimSpace(canonicalKey)
|
||||||
|
for _, key := range keys {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" || key == canonicalKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
aliases = append(aliases, key)
|
||||||
|
}
|
||||||
|
if len(aliases) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return aliases
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSessionMetadata(store session.SessionStore, key string, scope *session.SessionScope, aliases []string) {
|
||||||
|
if key == "" || scope == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metaStore, ok := store.(interface {
|
||||||
|
EnsureSessionMetadata(sessionKey string, scope *session.SessionScope, aliases []string)
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metaStore.EnsureSessionMetadata(key, scope, aliases)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
||||||
|
timer := time.NewTimer(d)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMessagesForLog(messages []providers.Message) string {
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("[\n")
|
||||||
|
for i, msg := range messages {
|
||||||
|
fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role)
|
||||||
|
if len(msg.ToolCalls) > 0 {
|
||||||
|
sb.WriteString(" ToolCalls:\n")
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
|
||||||
|
if tc.Function != nil {
|
||||||
|
fmt.Fprintf(
|
||||||
|
&sb,
|
||||||
|
" Arguments: %s\n",
|
||||||
|
utils.Truncate(tc.Function.Arguments, 200),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msg.Content != "" {
|
||||||
|
content := utils.Truncate(msg.Content, 200)
|
||||||
|
fmt.Fprintf(&sb, " Content: %s\n", content)
|
||||||
|
}
|
||||||
|
if msg.ToolCallID != "" {
|
||||||
|
fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID)
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("]")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
|
||||||
|
if len(toolDefs) == 0 {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("[\n")
|
||||||
|
for i, tool := range toolDefs {
|
||||||
|
fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
|
||||||
|
fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description)
|
||||||
|
if len(tool.Function.Parameters) > 0 {
|
||||||
|
fmt.Fprintf(
|
||||||
|
&sb,
|
||||||
|
" Parameters: %s\n",
|
||||||
|
utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString("]")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeSkillNames(agent *AgentInstance, opts processOptions) []string {
|
||||||
|
if agent == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
combined := make([]string, 0, len(agent.SkillsFilter)+len(opts.ForcedSkills))
|
||||||
|
combined = append(combined, agent.SkillsFilter...)
|
||||||
|
combined = append(combined, opts.ForcedSkills...)
|
||||||
|
if len(combined) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolved []string
|
||||||
|
seen := make(map[string]struct{}, len(combined))
|
||||||
|
for _, name := range combined {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if agent.ContextBuilder != nil {
|
||||||
|
if canonical, ok := agent.ContextBuilder.ResolveSkillName(name); ok {
|
||||||
|
name = canonical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key := strings.ToLower(name)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
resolved = append(resolved, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func sideQuestionResponseContent(response *providers.LLMResponse) string {
|
||||||
|
if response == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if response.Content != "" {
|
||||||
|
return response.Content
|
||||||
|
}
|
||||||
|
return response.ReasoningContent
|
||||||
|
}
|
||||||
|
|
||||||
|
func shallowCloneLLMOptions(opts map[string]any) map[string]any {
|
||||||
|
clone := make(map[string]any, len(opts))
|
||||||
|
for k, v := range opts {
|
||||||
|
clone[k] = v
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasMediaRefs(messages []providers.Message) bool {
|
||||||
|
for _, msg := range messages {
|
||||||
|
if len(msg.Media) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sideQuestionModelName(agent *AgentInstance, usedLight bool) string {
|
||||||
|
if usedLight && len(agent.LightCandidates) > 0 {
|
||||||
|
// Use the first light candidate's model
|
||||||
|
return agent.LightCandidates[0].Model
|
||||||
|
}
|
||||||
|
return agent.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelNameFromIdentityKey(identityKey string) string {
|
||||||
|
if identityKey == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(identityKey, "/", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
return identityKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeProviderIfStateful(provider providers.LLMProvider) {
|
||||||
|
if stateful, ok := provider.(providers.StatefulProvider); ok {
|
||||||
|
stateful.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makePendingTurnID(sessionKey string, seq uint64) string {
|
||||||
|
return pendingTurnPrefix + sessionKey + "-" + fmt.Sprintf("%d", seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandsUnavailableSkillMessage() string {
|
||||||
|
return "Skill selection is unavailable in the current context."
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUseCommandHelp(agent *AgentInstance) string {
|
||||||
|
if agent == nil || agent.ContextBuilder == nil {
|
||||||
|
return "Usage: /use <skill> [message]"
|
||||||
|
}
|
||||||
|
|
||||||
|
names := agent.ContextBuilder.ListSkillNames()
|
||||||
|
if len(names) == 0 {
|
||||||
|
return "Usage: /use <skill> [message]\nNo installed skills found."
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"Usage: /use <skill> [message]\n\nInstalled Skills:\n- %s\n\nUse /use <skill> to apply a skill to your next message, or /use <skill> <message> to force it immediately.",
|
||||||
|
strings.Join(names, "\n- "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapCommandError(result commands.ExecuteResult) string {
|
||||||
|
if result.Command == "" {
|
||||||
|
return fmt.Sprintf("Failed to execute command: %v", result.Err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNativeSearchProvider(p providers.LLMProvider) bool {
|
||||||
|
if ns, ok := p.(providers.NativeSearchCapable); ok {
|
||||||
|
return ns.SupportsNativeSearch()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterClientWebSearch(tools []providers.ToolDefinition) []providers.ToolDefinition {
|
||||||
|
result := make([]providers.ToolDefinition, 0, len(tools))
|
||||||
|
for _, t := range tools {
|
||||||
|
if strings.EqualFold(t.Function.Name, "web_search") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, t)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractProvider(registry *AgentRegistry) (providers.LLMProvider, bool) {
|
||||||
|
if registry == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
// Get any agent to access the provider
|
||||||
|
defaultAgent := registry.GetDefaultAgent()
|
||||||
|
if defaultAgent == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return defaultAgent.Provider, true
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package agent
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
|
@ -64,9 +65,9 @@ func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) {
|
||||||
return agent, ok
|
return agent, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveRoute determines which agent handles the message.
|
// ResolveRoute determines which agent handles the normalized inbound context.
|
||||||
func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute {
|
func (r *AgentRegistry) ResolveRoute(inbound bus.InboundContext) routing.ResolvedRoute {
|
||||||
return r.resolver.ResolveRoute(input)
|
return r.resolver.ResolveRoute(inbound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAgentIDs returns all registered agent IDs.
|
// ListAgentIDs returns all registered agent IDs.
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ package agent
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"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/routing"
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -290,12 +292,22 @@ func (al *AgentLoop) continueWithSteeringMessages(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
agent *AgentInstance,
|
agent *AgentInstance,
|
||||||
sessionKey, channel, chatID string,
|
sessionKey, channel, chatID string,
|
||||||
|
scope *session.SessionScope,
|
||||||
steeringMsgs []providers.Message,
|
steeringMsgs []providers.Message,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
return al.runAgentLoop(ctx, agent, processOptions{
|
dispatch := DispatchRequest{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
|
SessionScope: session.CloneScope(scope),
|
||||||
|
}
|
||||||
|
if channel != "" || chatID != "" {
|
||||||
|
dispatch.InboundContext = &bus.InboundContext{
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
|
ChatType: inferChatTypeFromSessionScope(scope),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return al.runAgentLoop(ctx, agent, processOptions{
|
||||||
|
Dispatch: dispatch,
|
||||||
DefaultResponse: defaultResponse,
|
DefaultResponse: defaultResponse,
|
||||||
EnableSummary: true,
|
EnableSummary: true,
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
|
|
@ -310,9 +322,19 @@ func (al *AgentLoop) agentForSession(sessionKey string) *AgentInstance {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed := routing.ParseAgentSessionKey(sessionKey); parsed != nil {
|
agentIDs := registry.ListAgentIDs()
|
||||||
if agent, ok := registry.GetAgent(parsed.AgentID); ok {
|
sort.Strings(agentIDs)
|
||||||
return agent
|
for _, agentID := range agentIDs {
|
||||||
|
agent, ok := registry.GetAgent(agentID)
|
||||||
|
if !ok || agent == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resolvedAgentID := session.ResolveAgentID(agent.Sessions, sessionKey)
|
||||||
|
if resolvedAgentID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if scopedAgent, ok := registry.GetAgent(resolvedAgentID); ok {
|
||||||
|
return scopedAgent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -326,33 +348,55 @@ func (al *AgentLoop) agentForSession(sessionKey string) *AgentInstance {
|
||||||
//
|
//
|
||||||
// If no steering messages are pending, it returns an empty string.
|
// If no steering messages are pending, it returns an empty string.
|
||||||
func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error) {
|
func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error) {
|
||||||
if active := al.GetActiveTurn(); active != nil {
|
// Claim the session with a unique placeholder to prevent a TOCTOU race where two
|
||||||
return "", fmt.Errorf("turn %s is still active", active.TurnID)
|
// concurrent Continue calls for the same session both pass the active-turn
|
||||||
|
// check and create parallel turns. The placeholder is replaced by the real
|
||||||
|
// turnState inside continueWithSteeringMessages → runAgentLoop → registerActiveTurn.
|
||||||
|
placeholder := &turnState{
|
||||||
|
turnID: "pending-continue-" + sessionKey + "-" + fmt.Sprintf("%d", al.turnSeq.Add(1)),
|
||||||
|
phase: TurnPhaseSetup,
|
||||||
}
|
}
|
||||||
|
if _, loaded := al.activeTurnStates.LoadOrStore(sessionKey, placeholder); loaded {
|
||||||
|
if active := al.GetActiveTurnBySession(sessionKey); active != nil {
|
||||||
|
return "", fmt.Errorf("turn %s is still active for session %q", active.TurnID, sessionKey)
|
||||||
|
}
|
||||||
|
// Another Continue just claimed the slot; let it handle the steering.
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
if err := al.ensureHooksInitialized(ctx); err != nil {
|
if err := al.ensureHooksInitialized(ctx); err != nil {
|
||||||
|
al.activeTurnStates.Delete(sessionKey)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := al.ensureMCPInitialized(ctx); err != nil {
|
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||||
|
al.activeTurnStates.Delete(sessionKey)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
steeringMsgs := al.dequeueSteeringMessagesForScopeWithFallback(sessionKey)
|
steeringMsgs := al.dequeueSteeringMessagesForScopeWithFallback(sessionKey)
|
||||||
if len(steeringMsgs) == 0 {
|
if len(steeringMsgs) == 0 {
|
||||||
|
al.activeTurnStates.Delete(sessionKey)
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
agent := al.agentForSession(sessionKey)
|
agent := al.agentForSession(sessionKey)
|
||||||
if agent == nil {
|
if agent == nil {
|
||||||
|
al.activeTurnStates.Delete(sessionKey)
|
||||||
return "", fmt.Errorf("no agent available for session %q", sessionKey)
|
return "", fmt.Errorf("no agent available for session %q", sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tool, ok := agent.Tools.Get("message"); ok {
|
if tool, ok := agent.Tools.Get("message"); ok {
|
||||||
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
|
if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok {
|
||||||
resetter.ResetSentInRound()
|
resetter.ResetSentInRound(sessionKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return al.continueWithSteeringMessages(ctx, agent, sessionKey, channel, chatID, steeringMsgs)
|
var scope *session.SessionScope
|
||||||
|
if metaStore, ok := agent.Sessions.(session.MetadataAwareSessionStore); ok {
|
||||||
|
scope = metaStore.GetSessionScope(sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return al.continueWithSteeringMessages(ctx, agent, sessionKey, channel, chatID, scope, steeringMsgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) InterruptGraceful(hint string) error {
|
func (al *AgentLoop) InterruptGraceful(hint string) error {
|
||||||
|
|
@ -376,11 +420,18 @@ func (al *AgentLoop) InterruptGraceful(hint string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InterruptHard aborts an arbitrary active turn. In parallel mode this may
|
||||||
|
// target the wrong session. Prefer HardAbort(sessionKey) instead.
|
||||||
|
//
|
||||||
|
// Deprecated: Use HardAbort(sessionKey) for session-safe aborts.
|
||||||
func (al *AgentLoop) InterruptHard() error {
|
func (al *AgentLoop) InterruptHard() error {
|
||||||
ts := al.getAnyActiveTurnState()
|
ts := al.getAnyActiveTurnState()
|
||||||
if ts == nil {
|
if ts == nil {
|
||||||
return fmt.Errorf("no active turn")
|
return fmt.Errorf("no active turn")
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(ts.turnID, "pending-") {
|
||||||
|
return fmt.Errorf("turn is still initializing for session %s", ts.sessionKey)
|
||||||
|
}
|
||||||
if !ts.requestHardAbort() {
|
if !ts.requestHardAbort() {
|
||||||
return fmt.Errorf("turn %s is already aborting", ts.turnID)
|
return fmt.Errorf("turn %s is already aborting", ts.turnID)
|
||||||
}
|
}
|
||||||
|
|
@ -447,6 +498,10 @@ func (al *AgentLoop) HardAbort(sessionKey string) error {
|
||||||
return fmt.Errorf("invalid turn state type for session %s", sessionKey)
|
return fmt.Errorf("invalid turn state type for session %s", sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(ts.turnID, "pending-") {
|
||||||
|
return fmt.Errorf("turn is still initializing for session %s", sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("agent", "Hard abort triggered", map[string]any{
|
logger.InfoCF("agent", "Hard abort triggered", map[string]any{
|
||||||
"session_key": sessionKey,
|
"session_key": sessionKey,
|
||||||
"turn_id": ts.turnID,
|
"turn_id": ts.turnID,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/media"
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -340,97 +341,6 @@ func TestAgentLoop_Continue_WithMessages(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDrainBusToSteering_RequeuesDifferentScopeMessage(t *testing.T) {
|
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(tmpDir)
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
Agents: config.AgentsConfig{
|
|
||||||
Defaults: config.AgentDefaults{
|
|
||||||
Workspace: tmpDir,
|
|
||||||
ModelName: "test-model",
|
|
||||||
MaxTokens: 4096,
|
|
||||||
MaxToolIterations: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Session: config.SessionConfig{
|
|
||||||
DMScope: "per-peer",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
|
||||||
al := NewAgentLoop(cfg, msgBus, &mockProvider{})
|
|
||||||
|
|
||||||
activeMsg := bus.InboundMessage{
|
|
||||||
Channel: "telegram",
|
|
||||||
SenderID: "user1",
|
|
||||||
ChatID: "chat1",
|
|
||||||
Content: "active turn",
|
|
||||||
Peer: bus.Peer{
|
|
||||||
Kind: "direct",
|
|
||||||
ID: "user1",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
activeScope, activeAgentID, ok := al.resolveSteeringTarget(activeMsg)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected active message to resolve to a steering scope")
|
|
||||||
}
|
|
||||||
|
|
||||||
otherMsg := bus.InboundMessage{
|
|
||||||
Channel: "telegram",
|
|
||||||
SenderID: "user2",
|
|
||||||
ChatID: "chat2",
|
|
||||||
Content: "other session",
|
|
||||||
Peer: bus.Peer{
|
|
||||||
Kind: "direct",
|
|
||||||
ID: "user2",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
otherScope, _, ok := al.resolveSteeringTarget(otherMsg)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected other message to resolve to a steering scope")
|
|
||||||
}
|
|
||||||
if otherScope == activeScope {
|
|
||||||
t.Fatalf("expected different steering scopes, got same scope %q", activeScope)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := msgBus.PublishInbound(context.Background(), otherMsg); err != nil {
|
|
||||||
t.Fatalf("PublishInbound failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
al.drainBusToSteering(ctx, activeScope, activeAgentID)
|
|
||||||
close(done)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("timeout waiting for drainBusToSteering to stop")
|
|
||||||
}
|
|
||||||
|
|
||||||
if msgs := al.dequeueSteeringMessagesForScope(activeScope); len(msgs) != 0 {
|
|
||||||
t.Fatalf("expected no steering messages for active scope, got %v", msgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
t.Fatalf("timeout waiting for requeued message on outbound bus")
|
|
||||||
case requeued := <-msgBus.OutboundChan():
|
|
||||||
if requeued.Channel != otherMsg.Channel || requeued.ChatID != otherMsg.ChatID ||
|
|
||||||
requeued.Content != otherMsg.Content {
|
|
||||||
t.Fatalf("requeued message mismatch: got %+v want %+v", requeued, otherMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// slowTool simulates a tool that takes some time to execute.
|
// slowTool simulates a tool that takes some time to execute.
|
||||||
type slowTool struct {
|
type slowTool struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -841,24 +751,22 @@ func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
first := bus.InboundMessage{
|
first := bus.InboundMessage{
|
||||||
|
Context: bus.InboundContext{
|
||||||
Channel: "test",
|
Channel: "test",
|
||||||
SenderID: "user1",
|
|
||||||
ChatID: "chat1",
|
ChatID: "chat1",
|
||||||
Content: "first message",
|
ChatType: "direct",
|
||||||
Peer: bus.Peer{
|
SenderID: "user1",
|
||||||
Kind: "direct",
|
|
||||||
ID: "user1",
|
|
||||||
},
|
},
|
||||||
|
Content: "first message",
|
||||||
}
|
}
|
||||||
late := bus.InboundMessage{
|
late := bus.InboundMessage{
|
||||||
|
Context: bus.InboundContext{
|
||||||
Channel: "test",
|
Channel: "test",
|
||||||
SenderID: "user1",
|
|
||||||
ChatID: "chat1",
|
ChatID: "chat1",
|
||||||
Content: "late append",
|
ChatType: "direct",
|
||||||
Peer: bus.Peer{
|
SenderID: "user1",
|
||||||
Kind: "direct",
|
|
||||||
ID: "user1",
|
|
||||||
},
|
},
|
||||||
|
Content: "late append",
|
||||||
}
|
}
|
||||||
|
|
||||||
pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
|
@ -949,7 +857,7 @@ func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing.
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID)
|
sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
|
||||||
provider := &blockingDirectProvider{
|
provider := &blockingDirectProvider{
|
||||||
firstStarted: make(chan struct{}),
|
firstStarted: make(chan struct{}),
|
||||||
releaseFirst: make(chan struct{}),
|
releaseFirst: make(chan struct{}),
|
||||||
|
|
@ -1013,6 +921,62 @@ func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAgentLoop_AgentForSession_UsesStoredScopeMetadata(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
List: []config.AgentConfig{
|
||||||
|
{ID: "sales", Default: true},
|
||||||
|
{ID: "support"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
|
||||||
|
support, ok := al.registry.GetAgent("support")
|
||||||
|
if !ok || support == nil {
|
||||||
|
t.Fatal("expected support agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
metaStore, ok := support.Sessions.(session.MetadataAwareSessionStore)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("support session store does not support metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
alias := "agent:support:slack:channel:c001"
|
||||||
|
key := session.BuildOpaqueSessionKey(alias)
|
||||||
|
scope := &session.SessionScope{
|
||||||
|
Version: session.ScopeVersionV1,
|
||||||
|
AgentID: "support",
|
||||||
|
Channel: "slack",
|
||||||
|
Account: "default",
|
||||||
|
Dimensions: []string{"chat"},
|
||||||
|
Values: map[string]string{
|
||||||
|
"chat": "channel:c001",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
metaStore.EnsureSessionMetadata(key, scope, []string{alias})
|
||||||
|
|
||||||
|
got := al.agentForSession(key)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("agentForSession() returned nil")
|
||||||
|
}
|
||||||
|
if got.ID != "support" {
|
||||||
|
t.Fatalf("agentForSession() = %q, want %q", got.ID, "support")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) {
|
func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1060,7 +1024,7 @@ func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID)
|
sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
al.SetMediaStore(store)
|
al.SetMediaStore(store)
|
||||||
|
|
@ -1168,7 +1132,7 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) {
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
al.RegisterTool(tool1)
|
al.RegisterTool(tool1)
|
||||||
al.RegisterTool(tool2)
|
al.RegisterTool(tool2)
|
||||||
sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID)
|
sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
|
||||||
|
|
||||||
sub := al.SubscribeEvents(32)
|
sub := al.SubscribeEvents(32)
|
||||||
defer al.UnsubscribeEvents(sub.ID)
|
defer al.UnsubscribeEvents(sub.ID)
|
||||||
|
|
@ -1322,7 +1286,7 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) {
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
started := make(chan struct{})
|
started := make(chan struct{})
|
||||||
al.RegisterTool(&interruptibleTool{name: "cancel_tool", started: started})
|
al.RegisterTool(&interruptibleTool{name: "cancel_tool", started: started})
|
||||||
sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID)
|
sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
|
||||||
|
|
||||||
defaultAgent := al.registry.GetDefaultAgent()
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
if defaultAgent == nil {
|
if defaultAgent == nil {
|
||||||
|
|
|
||||||
|
|
@ -351,15 +351,17 @@ func spawnSubTurn(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create processOptions for the child turn
|
// Create processOptions for the child turn
|
||||||
opts := processOptions{
|
dispatch := DispatchRequest{
|
||||||
SessionKey: childID,
|
SessionKey: childID,
|
||||||
Channel: parentTS.channel,
|
UserMessage: cfg.SystemPrompt,
|
||||||
ChatID: parentTS.chatID,
|
|
||||||
SenderID: parentTS.opts.SenderID,
|
|
||||||
SenderDisplayName: parentTS.opts.SenderDisplayName,
|
|
||||||
UserMessage: cfg.SystemPrompt, // Task description becomes the first user message
|
|
||||||
SystemPromptOverride: cfg.ActualSystemPrompt,
|
|
||||||
Media: nil,
|
Media: nil,
|
||||||
|
InboundContext: cloneInboundContext(parentTS.opts.Dispatch.InboundContext),
|
||||||
|
}
|
||||||
|
opts := processOptions{
|
||||||
|
Dispatch: dispatch,
|
||||||
|
SenderID: parentTS.opts.Dispatch.SenderID(),
|
||||||
|
SenderDisplayName: parentTS.opts.SenderDisplayName,
|
||||||
|
SystemPromptOverride: cfg.ActualSystemPrompt,
|
||||||
InitialSteeringMessages: cfg.InitialMessages,
|
InitialSteeringMessages: cfg.InitialMessages,
|
||||||
DefaultResponse: "",
|
DefaultResponse: "",
|
||||||
EnableSummary: false,
|
EnableSummary: false,
|
||||||
|
|
@ -369,7 +371,11 @@ func spawnSubTurn(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create event scope for the child turn
|
// Create event scope for the child turn
|
||||||
scope := al.newTurnEventScope(agent.ID, childID)
|
scope := al.newTurnEventScope(
|
||||||
|
agent.ID,
|
||||||
|
childID,
|
||||||
|
newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope),
|
||||||
|
)
|
||||||
|
|
||||||
// Create child turnState using the new API
|
// Create child turnState using the new API
|
||||||
childTS := newTurnState(&agent, opts, scope)
|
childTS := newTurnState(&agent, opts, scope)
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ type turnState struct {
|
||||||
turnID string
|
turnID string
|
||||||
agentID string
|
agentID string
|
||||||
sessionKey string
|
sessionKey string
|
||||||
|
turnCtx *TurnContext
|
||||||
|
|
||||||
channel string
|
channel string
|
||||||
chatID string
|
chatID string
|
||||||
|
|
@ -115,11 +116,12 @@ func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScop
|
||||||
scope: scope,
|
scope: scope,
|
||||||
turnID: scope.turnID,
|
turnID: scope.turnID,
|
||||||
agentID: agent.ID,
|
agentID: agent.ID,
|
||||||
sessionKey: opts.SessionKey,
|
sessionKey: opts.Dispatch.SessionKey,
|
||||||
channel: opts.Channel,
|
turnCtx: cloneTurnContext(scope.context),
|
||||||
chatID: opts.ChatID,
|
channel: opts.Dispatch.Channel(),
|
||||||
userMessage: opts.UserMessage,
|
chatID: opts.Dispatch.ChatID(),
|
||||||
media: append([]string(nil), opts.Media...),
|
userMessage: opts.Dispatch.UserMessage,
|
||||||
|
media: append([]string(nil), opts.Dispatch.Media...),
|
||||||
phase: TurnPhaseSetup,
|
phase: TurnPhaseSetup,
|
||||||
startedAt: time.Now(),
|
startedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
@ -127,7 +129,7 @@ func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScop
|
||||||
// Bind session store and capture initial history length for rollback logic
|
// Bind session store and capture initial history length for rollback logic
|
||||||
if agent != nil && agent.Sessions != nil {
|
if agent != nil && agent.Sessions != nil {
|
||||||
ts.session = agent.Sessions
|
ts.session = agent.Sessions
|
||||||
ts.initialHistoryLength = len(agent.Sessions.GetHistory(opts.SessionKey))
|
ts.initialHistoryLength = len(agent.Sessions.GetHistory(opts.Dispatch.SessionKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
return ts
|
return ts
|
||||||
|
|
@ -143,7 +145,11 @@ func (al *AgentLoop) clearActiveTurn(ts *turnState) {
|
||||||
|
|
||||||
func (al *AgentLoop) getActiveTurnState(sessionKey string) *turnState {
|
func (al *AgentLoop) getActiveTurnState(sessionKey string) *turnState {
|
||||||
if val, ok := al.activeTurnStates.Load(sessionKey); ok {
|
if val, ok := al.activeTurnStates.Load(sessionKey); ok {
|
||||||
return val.(*turnState)
|
if ts, ok := val.(*turnState); ok {
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
// Unexpected non-*turnState value — treat as "no active turn" to avoid
|
||||||
|
// panics. This should not happen under normal operation.
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -152,8 +158,11 @@ func (al *AgentLoop) getActiveTurnState(sessionKey string) *turnState {
|
||||||
func (al *AgentLoop) getAnyActiveTurnState() *turnState {
|
func (al *AgentLoop) getAnyActiveTurnState() *turnState {
|
||||||
var firstTS *turnState
|
var firstTS *turnState
|
||||||
al.activeTurnStates.Range(func(key, value any) bool {
|
al.activeTurnStates.Range(func(key, value any) bool {
|
||||||
firstTS = value.(*turnState)
|
if ts, ok := value.(*turnState); ok {
|
||||||
return false // stop after first
|
firstTS = ts
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
return firstTS
|
return firstTS
|
||||||
}
|
}
|
||||||
|
|
@ -163,8 +172,11 @@ func (al *AgentLoop) GetActiveTurn() *ActiveTurnInfo {
|
||||||
// In the new architecture, there can be multiple concurrent turns
|
// In the new architecture, there can be multiple concurrent turns
|
||||||
var firstTS *turnState
|
var firstTS *turnState
|
||||||
al.activeTurnStates.Range(func(key, value any) bool {
|
al.activeTurnStates.Range(func(key, value any) bool {
|
||||||
firstTS = value.(*turnState)
|
if ts, ok := value.(*turnState); ok {
|
||||||
return false // stop after first
|
firstTS = ts
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
if firstTS == nil {
|
if firstTS == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -308,6 +320,7 @@ func (ts *turnState) eventMeta(source, tracePath string) EventMeta {
|
||||||
Iteration: snap.Iteration,
|
Iteration: snap.Iteration,
|
||||||
Source: source,
|
Source: source,
|
||||||
TracePath: tracePath,
|
TracePath: tracePath,
|
||||||
|
turnContext: cloneTurnContext(ts.turnCtx),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -426,7 +439,9 @@ func (ts *turnState) Finish(isHardAbort bool) {
|
||||||
ts.mu.RUnlock()
|
ts.mu.RUnlock()
|
||||||
for _, childID := range children {
|
for _, childID := range children {
|
||||||
if val, ok := ts.al.activeTurnStates.Load(childID); ok {
|
if val, ok := ts.al.activeTurnStates.Load(childID); ok {
|
||||||
val.(*turnState).Finish(true)
|
if child, ok := val.(*turnState); ok {
|
||||||
|
child.Finish(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
92
pkg/agent/turn_context.go
Normal file
92
pkg/agent/turn_context.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TurnContext carries normalized turn-scoped facts that can be shared across
|
||||||
|
// events, hooks, and other runtime observers without re-parsing legacy fields.
|
||||||
|
type TurnContext struct {
|
||||||
|
Inbound *bus.InboundContext `json:"inbound,omitempty"`
|
||||||
|
Route *routing.ResolvedRoute `json:"route,omitempty"`
|
||||||
|
Scope *session.SessionScope `json:"scope,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTurnContext(
|
||||||
|
inbound *bus.InboundContext,
|
||||||
|
route *routing.ResolvedRoute,
|
||||||
|
scope *session.SessionScope,
|
||||||
|
) *TurnContext {
|
||||||
|
if inbound == nil && route == nil && scope == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &TurnContext{
|
||||||
|
Inbound: cloneInboundContext(inbound),
|
||||||
|
Route: cloneResolvedRoute(route),
|
||||||
|
Scope: session.CloneScope(scope),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneTurnContext(ctx *TurnContext) *TurnContext {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := *ctx
|
||||||
|
cloned.Inbound = cloneInboundContext(ctx.Inbound)
|
||||||
|
cloned.Route = cloneResolvedRoute(ctx.Route)
|
||||||
|
cloned.Scope = session.CloneScope(ctx.Scope)
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneInboundContext(ctx *bus.InboundContext) *bus.InboundContext {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := *ctx
|
||||||
|
cloned.ReplyHandles = cloneStringMap(ctx.ReplyHandles)
|
||||||
|
cloned.Raw = cloneStringMap(ctx.Raw)
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringMap(src map[string]string) map[string]string {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make(map[string]string, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
cloned[k] = v
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneEventMeta(meta EventMeta) EventMeta {
|
||||||
|
meta.turnContext = cloneTurnContext(meta.turnContext)
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneResolvedRoute(route *routing.ResolvedRoute) *routing.ResolvedRoute {
|
||||||
|
if route == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := *route
|
||||||
|
cloned.SessionPolicy = routing.SessionPolicy{
|
||||||
|
Dimensions: append([]string(nil), route.SessionPolicy.Dimensions...),
|
||||||
|
IdentityLinks: cloneIdentityLinks(route.SessionPolicy.IdentityLinks),
|
||||||
|
}
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneIdentityLinks(src map[string][]string) map[string][]string {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make(map[string][]string, len(src))
|
||||||
|
for canonical, ids := range src {
|
||||||
|
dup := make([]string, len(ids))
|
||||||
|
copy(dup, ids)
|
||||||
|
cloned[canonical] = dup
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
@ -226,8 +226,7 @@ func (a *Agent) processUtterance(ctx context.Context, acc *speechAccumulator) {
|
||||||
logger.ErrorCF("voice-agent", "Failed to publish leave control", map[string]any{"error": err})
|
logger.ErrorCF("voice-agent", "Failed to publish leave control", map[string]any{"error": err})
|
||||||
}
|
}
|
||||||
if err := a.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
if err := a.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
Channel: channelType,
|
Context: bus.NewOutboundContext(channelType, acc.chatID, ""),
|
||||||
ChatID: acc.chatID,
|
|
||||||
Content: "Goodbye! Leaving the voice channel.",
|
Content: "Goodbye! Leaving the voice channel.",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logger.ErrorCF("voice-agent", "Failed to publish goodbye message", map[string]any{"error": err})
|
logger.ErrorCF("voice-agent", "Failed to publish goodbye message", map[string]any{"error": err})
|
||||||
|
|
@ -238,14 +237,16 @@ func (a *Agent) processUtterance(ctx context.Context, acc *speechAccumulator) {
|
||||||
oralPrompt := "\n\n[SYSTEM]: The user just spoke this to you over voice chat. Please reply in a highly concise, conversational, oral style suitable for text-to-speech. Do not use markdown, emojis, asterisks, or code blocks. Speak naturally."
|
oralPrompt := "\n\n[SYSTEM]: The user just spoke this to you over voice chat. Please reply in a highly concise, conversational, oral style suitable for text-to-speech. Do not use markdown, emojis, asterisks, or code blocks. Speak naturally."
|
||||||
|
|
||||||
if err := a.bus.PublishInbound(ctx, bus.InboundMessage{
|
if err := a.bus.PublishInbound(ctx, bus.InboundMessage{
|
||||||
|
Context: bus.InboundContext{
|
||||||
Channel: channelType,
|
Channel: channelType,
|
||||||
SenderID: acc.speakerID,
|
|
||||||
ChatID: acc.chatID,
|
ChatID: acc.chatID,
|
||||||
Content: res.Text + oralPrompt,
|
ChatType: "channel",
|
||||||
Peer: bus.Peer{Kind: "channel", ID: acc.chatID},
|
SenderID: acc.speakerID,
|
||||||
Metadata: map[string]string{
|
Raw: map[string]string{
|
||||||
"is_voice": "true",
|
"is_voice": "true",
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
Content: res.Text + oralPrompt,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logger.ErrorCF("voice-agent", "Failed to publish inbound message", map[string]any{"error": err})
|
logger.ErrorCF("voice-agent", "Failed to publish inbound message", map[string]any{"error": err})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -185,8 +185,8 @@ func TestAgentCheckSilencePublishesInboundAndCleansUp(t *testing.T) {
|
||||||
if !strings.Contains(msg.Content, "hello there") {
|
if !strings.Contains(msg.Content, "hello there") {
|
||||||
t.Fatalf("unexpected inbound content: %q", msg.Content)
|
t.Fatalf("unexpected inbound content: %q", msg.Content)
|
||||||
}
|
}
|
||||||
if msg.Metadata["is_voice"] != "true" {
|
if msg.Context.Raw["is_voice"] != "true" {
|
||||||
t.Fatalf("expected is_voice metadata, got %#v", msg.Metadata)
|
t.Fatalf("expected is_voice metadata, got %#v", msg.Context.Raw)
|
||||||
}
|
}
|
||||||
case <-time.After(500 * time.Millisecond):
|
case <-time.After(500 * time.Millisecond):
|
||||||
t.Fatal("expected inbound publish")
|
t.Fatal("expected inbound publish")
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,15 @@ type OAuthProviderConfig struct {
|
||||||
Port int
|
Port int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LoginBrowserOptions struct {
|
||||||
|
NoBrowser bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
openBrowserFunc = OpenBrowser
|
||||||
|
browserLoginInput io.Reader = os.Stdin
|
||||||
|
)
|
||||||
|
|
||||||
func OpenAIOAuthConfig() OAuthProviderConfig {
|
func OpenAIOAuthConfig() OAuthProviderConfig {
|
||||||
return OAuthProviderConfig{
|
return OAuthProviderConfig{
|
||||||
Issuer: "https://auth.openai.com",
|
Issuer: "https://auth.openai.com",
|
||||||
|
|
@ -76,6 +85,10 @@ func GenerateState() (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
|
return LoginBrowserWithOptions(cfg, LoginBrowserOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoginBrowserWithOptions(cfg OAuthProviderConfig, opts LoginBrowserOptions) (*AuthCredential, error) {
|
||||||
pkce, err := GeneratePKCE()
|
pkce, err := GeneratePKCE()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("generating PKCE: %w", err)
|
return nil, fmt.Errorf("generating PKCE: %w", err)
|
||||||
|
|
@ -86,55 +99,45 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
return nil, fmt.Errorf("generating state: %w", err)
|
return nil, fmt.Errorf("generating state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURI := fmt.Sprintf("http://localhost:%d/auth/callback", cfg.Port)
|
redirectURI := oauthCallbackRedirectURI(cfg.Port)
|
||||||
|
callbackPort := cfg.Port
|
||||||
|
var resultCh <-chan callbackResult
|
||||||
|
|
||||||
authURL := buildAuthorizeURL(cfg, pkce, state, redirectURI)
|
if !opts.NoBrowser {
|
||||||
|
callbackResultCh := make(chan callbackResult, 1)
|
||||||
resultCh := make(chan callbackResult, 1)
|
listener, actualPort, err := listenOAuthCallback(cfg.Port)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Query().Get("state") != state {
|
|
||||||
resultCh <- callbackResult{err: fmt.Errorf("state mismatch")}
|
|
||||||
http.Error(w, "State mismatch", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
code := r.URL.Query().Get("code")
|
|
||||||
if code == "" {
|
|
||||||
errMsg := r.URL.Query().Get("error")
|
|
||||||
resultCh <- callbackResult{err: fmt.Errorf("no code received: %s", errMsg)}
|
|
||||||
http.Error(w, "No authorization code received", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
|
||||||
fmt.Fprint(w, "<html><body><h2>Authentication successful!</h2><p>You can close this window.</p></body></html>")
|
|
||||||
resultCh <- callbackResult{code: code}
|
|
||||||
})
|
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.Port))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err)
|
return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
server := &http.Server{Handler: mux}
|
redirectURI = oauthCallbackRedirectURI(actualPort)
|
||||||
go server.Serve(listener)
|
callbackPort = actualPort
|
||||||
|
resultCh = callbackResultCh
|
||||||
|
|
||||||
|
server := &http.Server{Handler: oauthCallbackHandler(state, callbackResultCh)}
|
||||||
|
go func() {
|
||||||
|
_ = server.Serve(listener)
|
||||||
|
}()
|
||||||
defer func() {
|
defer func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
server.Shutdown(ctx)
|
_ = server.Shutdown(ctx)
|
||||||
}()
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
authURL := buildAuthorizeURL(cfg, pkce, state, redirectURI)
|
||||||
|
|
||||||
fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL)
|
fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL)
|
||||||
|
|
||||||
if err := OpenBrowser(authURL); err != nil {
|
if opts.NoBrowser {
|
||||||
|
fmt.Println("Browser auto-open disabled. Open the URL manually to continue.")
|
||||||
|
} else if err := openBrowserFunc(authURL); err != nil {
|
||||||
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf(
|
fmt.Printf(
|
||||||
"Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n",
|
"Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n",
|
||||||
cfg.Port,
|
callbackPort,
|
||||||
)
|
)
|
||||||
fmt.Println(
|
fmt.Println(
|
||||||
"please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.",
|
"please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.",
|
||||||
|
|
@ -142,11 +145,16 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
fmt.Println("Waiting for authentication (browser or manual paste)...")
|
fmt.Println("Waiting for authentication (browser or manual paste)...")
|
||||||
|
|
||||||
// Start manual input in a goroutine
|
// Start manual input in a goroutine
|
||||||
manualCh := make(chan string)
|
manualCh := make(chan string, 1)
|
||||||
|
manualDone := make(chan struct{})
|
||||||
|
defer close(manualDone)
|
||||||
go func() {
|
go func() {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(browserLoginInput)
|
||||||
input, _ := reader.ReadString('\n')
|
input, _ := reader.ReadString('\n')
|
||||||
manualCh <- strings.TrimSpace(input)
|
select {
|
||||||
|
case manualCh <- strings.TrimSpace(input):
|
||||||
|
case <-manualDone:
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|
@ -176,6 +184,49 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func oauthCallbackRedirectURI(port int) string {
|
||||||
|
return fmt.Sprintf("http://localhost:%d/auth/callback", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func oauthCallbackHandler(state string, resultCh chan<- callbackResult) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("state") != state {
|
||||||
|
resultCh <- callbackResult{err: fmt.Errorf("state mismatch")}
|
||||||
|
http.Error(w, "State mismatch", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
if code == "" {
|
||||||
|
errMsg := r.URL.Query().Get("error")
|
||||||
|
resultCh <- callbackResult{err: fmt.Errorf("no code received: %s", errMsg)}
|
||||||
|
http.Error(w, "No authorization code received", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprint(w, "<html><body><h2>Authentication successful!</h2><p>You can close this window.</p></body></html>")
|
||||||
|
resultCh <- callbackResult{code: code}
|
||||||
|
})
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenOAuthCallback(port int) (net.Listener, int, error) {
|
||||||
|
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
|
||||||
|
if !ok {
|
||||||
|
_ = listener.Close()
|
||||||
|
return nil, 0, fmt.Errorf("unexpected listener address type %T", listener.Addr())
|
||||||
|
}
|
||||||
|
|
||||||
|
return listener, tcpAddr.Port, nil
|
||||||
|
}
|
||||||
|
|
||||||
type callbackResult struct {
|
type callbackResult struct {
|
||||||
code string
|
code string
|
||||||
err error
|
err error
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package auth
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
@ -373,3 +374,118 @@ func TestParseDeviceCodeResponseInvalidInterval(t *testing.T) {
|
||||||
t.Fatal("expected error for invalid interval")
|
t.Fatal("expected error for invalid interval")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoginBrowserWithOptionsNoBrowserDoesNotRequireCallbackPort(t *testing.T) {
|
||||||
|
server := newMockOAuthTokenServer()
|
||||||
|
defer server.Close()
|
||||||
|
reservedListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("net.Listen() error: %v", err)
|
||||||
|
}
|
||||||
|
defer reservedListener.Close()
|
||||||
|
|
||||||
|
reservedPort := reservedListener.Addr().(*net.TCPAddr).Port
|
||||||
|
origOpenBrowserFunc := openBrowserFunc
|
||||||
|
origBrowserLoginInput := browserLoginInput
|
||||||
|
t.Cleanup(func() {
|
||||||
|
openBrowserFunc = origOpenBrowserFunc
|
||||||
|
browserLoginInput = origBrowserLoginInput
|
||||||
|
})
|
||||||
|
|
||||||
|
var openCalls int
|
||||||
|
openBrowserFunc = func(string) error {
|
||||||
|
openCalls++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
browserLoginInput = strings.NewReader("manual-code\n")
|
||||||
|
|
||||||
|
cfg := OAuthProviderConfig{
|
||||||
|
Issuer: server.URL,
|
||||||
|
ClientID: "test-client",
|
||||||
|
Scopes: "openid",
|
||||||
|
Port: reservedPort,
|
||||||
|
}
|
||||||
|
|
||||||
|
cred, err := LoginBrowserWithOptions(cfg, LoginBrowserOptions{NoBrowser: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoginBrowserWithOptions() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if openCalls != 0 {
|
||||||
|
t.Fatalf("openBrowserFunc call count = %d, want 0", openCalls)
|
||||||
|
}
|
||||||
|
if cred.AccessToken != "mock-access-token" {
|
||||||
|
t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "mock-access-token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginBrowserWithOptionsAutoOpensByDefault(t *testing.T) {
|
||||||
|
server := newMockOAuthTokenServer()
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
origOpenBrowserFunc := openBrowserFunc
|
||||||
|
origBrowserLoginInput := browserLoginInput
|
||||||
|
t.Cleanup(func() {
|
||||||
|
openBrowserFunc = origOpenBrowserFunc
|
||||||
|
browserLoginInput = origBrowserLoginInput
|
||||||
|
})
|
||||||
|
|
||||||
|
var (
|
||||||
|
openCalls int
|
||||||
|
browserURL string
|
||||||
|
)
|
||||||
|
openBrowserFunc = func(url string) error {
|
||||||
|
openCalls++
|
||||||
|
browserURL = url
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
browserLoginInput = strings.NewReader("manual-code\n")
|
||||||
|
|
||||||
|
cfg := OAuthProviderConfig{
|
||||||
|
Issuer: server.URL,
|
||||||
|
ClientID: "test-client",
|
||||||
|
Scopes: "openid",
|
||||||
|
Port: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoginBrowserWithOptions(cfg, LoginBrowserOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoginBrowserWithOptions() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if openCalls != 1 {
|
||||||
|
t.Fatalf("openBrowserFunc call count = %d, want 1", openCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedBrowserURL, err := url.Parse(browserURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("url.Parse(browserURL) error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectURI, err := url.Parse(parsedBrowserURL.Query().Get("redirect_uri"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("url.Parse(redirectURI) error: %v", err)
|
||||||
|
}
|
||||||
|
if redirectURI.Port() == "" {
|
||||||
|
t.Fatal("redirectURI port is empty")
|
||||||
|
}
|
||||||
|
if redirectURI.Port() == "0" {
|
||||||
|
t.Fatalf("redirectURI port = %q, want dynamically assigned port", redirectURI.Port())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockOAuthTokenServer() *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/oauth/token" {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := map[string]any{
|
||||||
|
"access_token": "mock-access-token",
|
||||||
|
"refresh_token": "mock-refresh-token",
|
||||||
|
"expires_in": 3600,
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,12 @@ import (
|
||||||
// ErrBusClosed is returned when publishing to a closed MessageBus.
|
// ErrBusClosed is returned when publishing to a closed MessageBus.
|
||||||
var ErrBusClosed = errors.New("message bus closed")
|
var ErrBusClosed = errors.New("message bus closed")
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrMissingInboundContext = errors.New("inbound message context is required")
|
||||||
|
ErrMissingOutboundContext = errors.New("outbound message context is required")
|
||||||
|
ErrMissingOutboundMediaContext = errors.New("outbound media context is required")
|
||||||
|
)
|
||||||
|
|
||||||
const defaultBusBufferSize = 64
|
const defaultBusBufferSize = 64
|
||||||
|
|
||||||
// StreamDelegate is implemented by the channel Manager to provide streaming
|
// StreamDelegate is implemented by the channel Manager to provide streaming
|
||||||
|
|
@ -49,7 +55,7 @@ func NewMessageBus() *MessageBus {
|
||||||
inbound: make(chan InboundMessage, defaultBusBufferSize),
|
inbound: make(chan InboundMessage, defaultBusBufferSize),
|
||||||
outbound: make(chan OutboundMessage, defaultBusBufferSize),
|
outbound: make(chan OutboundMessage, defaultBusBufferSize),
|
||||||
outboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize),
|
outboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize),
|
||||||
audioChunks: make(chan AudioChunk, defaultBusBufferSize*4), // Audio chunks need more buffer
|
audioChunks: make(chan AudioChunk, defaultBusBufferSize*4), // Audio chunks need more buffer.
|
||||||
voiceControls: make(chan VoiceControl, defaultBusBufferSize),
|
voiceControls: make(chan VoiceControl, defaultBusBufferSize),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +90,10 @@ func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {
|
func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {
|
||||||
|
msg = NormalizeInboundMessage(msg)
|
||||||
|
if msg.Context.isZero() {
|
||||||
|
return ErrMissingInboundContext
|
||||||
|
}
|
||||||
return publish(ctx, mb, mb.inbound, msg)
|
return publish(ctx, mb, mb.inbound, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +102,10 @@ func (mb *MessageBus) InboundChan() <-chan InboundMessage {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {
|
func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {
|
||||||
|
msg = NormalizeOutboundMessage(msg)
|
||||||
|
if msg.Context.isZero() {
|
||||||
|
return ErrMissingOutboundContext
|
||||||
|
}
|
||||||
return publish(ctx, mb, mb.outbound, msg)
|
return publish(ctx, mb, mb.outbound, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,6 +114,10 @@ func (mb *MessageBus) OutboundChan() <-chan OutboundMessage {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {
|
func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {
|
||||||
|
msg = NormalizeOutboundMediaMessage(msg)
|
||||||
|
if msg.Context.isZero() {
|
||||||
|
return ErrMissingOutboundMediaContext
|
||||||
|
}
|
||||||
return publish(ctx, mb, mb.outboundMedia, msg)
|
return publish(ctx, mb, mb.outboundMedia, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,12 @@ func TestPublishConsume(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
msg := InboundMessage{
|
msg := InboundMessage{
|
||||||
|
Context: InboundContext{
|
||||||
Channel: "test",
|
Channel: "test",
|
||||||
SenderID: "user1",
|
|
||||||
ChatID: "chat1",
|
ChatID: "chat1",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "user1",
|
||||||
|
},
|
||||||
Content: "hello",
|
Content: "hello",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,6 +37,138 @@ func TestPublishConsume(t *testing.T) {
|
||||||
if got.Channel != "test" {
|
if got.Channel != "test" {
|
||||||
t.Fatalf("expected channel 'test', got %q", got.Channel)
|
t.Fatalf("expected channel 'test', got %q", got.Channel)
|
||||||
}
|
}
|
||||||
|
if got.Context.Channel != "test" {
|
||||||
|
t.Fatalf("expected context channel 'test', got %q", got.Context.Channel)
|
||||||
|
}
|
||||||
|
if got.Context.ChatID != "chat1" {
|
||||||
|
t.Fatalf("expected context chat ID 'chat1', got %q", got.Context.ChatID)
|
||||||
|
}
|
||||||
|
if got.Context.SenderID != "user1" {
|
||||||
|
t.Fatalf("expected context sender ID 'user1', got %q", got.Context.SenderID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInbound_NormalizesContext(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
msg := InboundMessage{
|
||||||
|
Context: InboundContext{
|
||||||
|
Channel: "slack",
|
||||||
|
Account: "workspace-a",
|
||||||
|
ChatID: "C456/1712",
|
||||||
|
ChatType: "group",
|
||||||
|
TopicID: "1712",
|
||||||
|
SpaceID: "T001",
|
||||||
|
SpaceType: "team",
|
||||||
|
SenderID: "U123",
|
||||||
|
MessageID: "1712.01",
|
||||||
|
ReplyToMessageID: "1700.01",
|
||||||
|
Mentioned: true,
|
||||||
|
},
|
||||||
|
Content: "hello",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishInbound(context.Background(), msg); err != nil {
|
||||||
|
t.Fatalf("PublishInbound failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := <-mb.InboundChan()
|
||||||
|
if got.Context.Channel != "slack" {
|
||||||
|
t.Fatalf("expected context channel slack, got %q", got.Context.Channel)
|
||||||
|
}
|
||||||
|
if got.Context.Account != "workspace-a" {
|
||||||
|
t.Fatalf("expected context account workspace-a, got %q", got.Context.Account)
|
||||||
|
}
|
||||||
|
if got.Context.ChatType != "group" {
|
||||||
|
t.Fatalf("expected context chat type group, got %q", got.Context.ChatType)
|
||||||
|
}
|
||||||
|
if got.Context.TopicID != "1712" {
|
||||||
|
t.Fatalf("expected topic 1712, got %q", got.Context.TopicID)
|
||||||
|
}
|
||||||
|
if got.Context.SpaceType != "team" || got.Context.SpaceID != "T001" {
|
||||||
|
t.Fatalf("expected team space T001, got %q/%q", got.Context.SpaceType, got.Context.SpaceID)
|
||||||
|
}
|
||||||
|
if !got.Context.Mentioned {
|
||||||
|
t.Fatal("expected mentioned=true in context")
|
||||||
|
}
|
||||||
|
if got.Context.ReplyToMessageID != "1700.01" {
|
||||||
|
t.Fatalf("expected reply_to_message_id 1700.01, got %q", got.Context.ReplyToMessageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInbound_MirrorsContextIntoConvenienceFields(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
msg := InboundMessage{
|
||||||
|
Context: InboundContext{
|
||||||
|
Channel: "telegram",
|
||||||
|
Account: "bot-a",
|
||||||
|
ChatID: "-1001",
|
||||||
|
ChatType: "group",
|
||||||
|
TopicID: "42",
|
||||||
|
SpaceID: "guild-9",
|
||||||
|
SpaceType: "guild",
|
||||||
|
SenderID: "user-1",
|
||||||
|
MessageID: "777",
|
||||||
|
Mentioned: true,
|
||||||
|
ReplyToMessageID: "666",
|
||||||
|
},
|
||||||
|
Content: "hi",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishInbound(context.Background(), msg); err != nil {
|
||||||
|
t.Fatalf("PublishInbound failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := <-mb.InboundChan()
|
||||||
|
if got.Channel != "telegram" {
|
||||||
|
t.Fatalf("expected legacy channel telegram, got %q", got.Channel)
|
||||||
|
}
|
||||||
|
if got.ChatID != "-1001" {
|
||||||
|
t.Fatalf("expected legacy chat ID -1001, got %q", got.ChatID)
|
||||||
|
}
|
||||||
|
if got.SenderID != "user-1" {
|
||||||
|
t.Fatalf("expected legacy sender ID user-1, got %q", got.SenderID)
|
||||||
|
}
|
||||||
|
if got.MessageID != "777" {
|
||||||
|
t.Fatalf("expected legacy message ID 777, got %q", got.MessageID)
|
||||||
|
}
|
||||||
|
if got.Context.Account != "bot-a" || got.Context.SpaceID != "guild-9" || got.Context.TopicID != "42" {
|
||||||
|
t.Fatalf("unexpected normalized context: %+v", got.Context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInbound_BackfillsContextFromLegacyFields(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
msg := InboundMessage{
|
||||||
|
Channel: "pico",
|
||||||
|
ChatID: "session-1",
|
||||||
|
SenderID: "user-1",
|
||||||
|
MessageID: "msg-1",
|
||||||
|
Content: "hello",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishInbound(context.Background(), msg); err != nil {
|
||||||
|
t.Fatalf("PublishInbound failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := <-mb.InboundChan()
|
||||||
|
if got.Context.Channel != "pico" {
|
||||||
|
t.Fatalf("expected context channel pico, got %q", got.Context.Channel)
|
||||||
|
}
|
||||||
|
if got.Context.ChatID != "session-1" {
|
||||||
|
t.Fatalf("expected context chat ID session-1, got %q", got.Context.ChatID)
|
||||||
|
}
|
||||||
|
if got.Context.SenderID != "user-1" {
|
||||||
|
t.Fatalf("expected context sender ID user-1, got %q", got.Context.SenderID)
|
||||||
|
}
|
||||||
|
if got.Context.MessageID != "msg-1" {
|
||||||
|
t.Fatalf("expected context message ID msg-1, got %q", got.Context.MessageID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPublishOutboundSubscribe(t *testing.T) {
|
func TestPublishOutboundSubscribe(t *testing.T) {
|
||||||
|
|
@ -43,8 +178,10 @@ func TestPublishOutboundSubscribe(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
msg := OutboundMessage{
|
msg := OutboundMessage{
|
||||||
|
Context: InboundContext{
|
||||||
Channel: "telegram",
|
Channel: "telegram",
|
||||||
ChatID: "123",
|
ChatID: "123",
|
||||||
|
},
|
||||||
Content: "world",
|
Content: "world",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,6 +196,222 @@ func TestPublishOutboundSubscribe(t *testing.T) {
|
||||||
if got.Content != "world" {
|
if got.Content != "world" {
|
||||||
t.Fatalf("expected content 'world', got %q", got.Content)
|
t.Fatalf("expected content 'world', got %q", got.Content)
|
||||||
}
|
}
|
||||||
|
if got.Context.Channel != "telegram" || got.Context.ChatID != "123" {
|
||||||
|
t.Fatalf("expected normalized outbound context, got %+v", got.Context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishOutbound_MirrorsContextToLegacyFields(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
msg := OutboundMessage{
|
||||||
|
Context: InboundContext{
|
||||||
|
Channel: "telegram",
|
||||||
|
ChatID: "chat-42",
|
||||||
|
ReplyToMessageID: "msg-9",
|
||||||
|
},
|
||||||
|
AgentID: "main",
|
||||||
|
SessionKey: "sk_v1_123",
|
||||||
|
Scope: &OutboundScope{
|
||||||
|
Version: 1,
|
||||||
|
AgentID: "main",
|
||||||
|
Channel: "telegram",
|
||||||
|
Account: "bot-a",
|
||||||
|
Dimensions: []string{"chat", "sender"},
|
||||||
|
Values: map[string]string{
|
||||||
|
"chat": "direct:chat-42",
|
||||||
|
"sender": "user-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Content: "reply",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishOutbound(context.Background(), msg); err != nil {
|
||||||
|
t.Fatalf("PublishOutbound failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := <-mb.OutboundChan()
|
||||||
|
if got.Channel != "telegram" {
|
||||||
|
t.Fatalf("expected legacy channel telegram, got %q", got.Channel)
|
||||||
|
}
|
||||||
|
if got.ChatID != "chat-42" {
|
||||||
|
t.Fatalf("expected legacy chat ID chat-42, got %q", got.ChatID)
|
||||||
|
}
|
||||||
|
if got.ReplyToMessageID != "msg-9" {
|
||||||
|
t.Fatalf("expected mirrored reply_to_message_id msg-9, got %q", got.ReplyToMessageID)
|
||||||
|
}
|
||||||
|
if got.AgentID != "main" || got.SessionKey != "sk_v1_123" {
|
||||||
|
t.Fatalf("unexpected outbound turn metadata: agent=%q session=%q", got.AgentID, got.SessionKey)
|
||||||
|
}
|
||||||
|
if got.Scope == nil || got.Scope.AgentID != "main" || got.Scope.Values["chat"] != "direct:chat-42" {
|
||||||
|
t.Fatalf("unexpected outbound scope: %+v", got.Scope)
|
||||||
|
}
|
||||||
|
if got.Context.Channel != "telegram" || got.Context.ChatID != "chat-42" {
|
||||||
|
t.Fatalf("unexpected outbound context: %+v", got.Context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishOutbound_PreservesExplicitReplyToMessageID(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
msg := OutboundMessage{
|
||||||
|
Context: InboundContext{
|
||||||
|
Channel: "telegram",
|
||||||
|
ChatID: "chat-42",
|
||||||
|
},
|
||||||
|
ReplyToMessageID: "msg-9",
|
||||||
|
Content: "reply",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishOutbound(context.Background(), msg); err != nil {
|
||||||
|
t.Fatalf("PublishOutbound failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := <-mb.OutboundChan()
|
||||||
|
if got.ReplyToMessageID != "msg-9" {
|
||||||
|
t.Fatalf("expected mirrored reply_to_message_id msg-9, got %q", got.ReplyToMessageID)
|
||||||
|
}
|
||||||
|
if got.Context.ReplyToMessageID != "msg-9" {
|
||||||
|
t.Fatalf("expected context reply_to_message_id msg-9, got %q", got.Context.ReplyToMessageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishOutbound_PreservesExplicitReplyToMessageIDWhenContextReplyIsBlank(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
msg := OutboundMessage{
|
||||||
|
Context: InboundContext{
|
||||||
|
Channel: "telegram",
|
||||||
|
ChatID: "chat-42",
|
||||||
|
ReplyToMessageID: " ",
|
||||||
|
},
|
||||||
|
ReplyToMessageID: "msg-9",
|
||||||
|
Content: "reply",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishOutbound(context.Background(), msg); err != nil {
|
||||||
|
t.Fatalf("PublishOutbound failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := <-mb.OutboundChan()
|
||||||
|
if got.ReplyToMessageID != "msg-9" {
|
||||||
|
t.Fatalf("expected mirrored reply_to_message_id msg-9, got %q", got.ReplyToMessageID)
|
||||||
|
}
|
||||||
|
if got.Context.ReplyToMessageID != "msg-9" {
|
||||||
|
t.Fatalf("expected context reply_to_message_id msg-9, got %q", got.Context.ReplyToMessageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishOutboundMedia_MirrorsContextToLegacyFields(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
msg := OutboundMediaMessage{
|
||||||
|
Context: InboundContext{
|
||||||
|
Channel: "slack",
|
||||||
|
ChatID: "C001",
|
||||||
|
},
|
||||||
|
AgentID: "support",
|
||||||
|
SessionKey: "sk_v1_media",
|
||||||
|
Scope: &OutboundScope{
|
||||||
|
Version: 1,
|
||||||
|
AgentID: "support",
|
||||||
|
Channel: "slack",
|
||||||
|
Dimensions: []string{"chat"},
|
||||||
|
Values: map[string]string{
|
||||||
|
"chat": "channel:c001",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Parts: []MediaPart{{Type: "image", Ref: "media://1"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishOutboundMedia(context.Background(), msg); err != nil {
|
||||||
|
t.Fatalf("PublishOutboundMedia failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := <-mb.OutboundMediaChan()
|
||||||
|
if got.Channel != "slack" {
|
||||||
|
t.Fatalf("expected legacy channel slack, got %q", got.Channel)
|
||||||
|
}
|
||||||
|
if got.ChatID != "C001" {
|
||||||
|
t.Fatalf("expected legacy chat ID C001, got %q", got.ChatID)
|
||||||
|
}
|
||||||
|
if got.AgentID != "support" || got.SessionKey != "sk_v1_media" {
|
||||||
|
t.Fatalf("unexpected outbound media turn metadata: agent=%q session=%q", got.AgentID, got.SessionKey)
|
||||||
|
}
|
||||||
|
if got.Scope == nil || got.Scope.Values["chat"] != "channel:c001" {
|
||||||
|
t.Fatalf("unexpected outbound media scope: %+v", got.Scope)
|
||||||
|
}
|
||||||
|
if got.Context.Channel != "slack" || got.Context.ChatID != "C001" {
|
||||||
|
t.Fatalf("unexpected outbound media context: %+v", got.Context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishAudioChunkSubscribe(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
chunk := AudioChunk{
|
||||||
|
SessionID: "voice-1",
|
||||||
|
SpeakerID: "speaker-1",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Channel: "discord",
|
||||||
|
Sequence: 7,
|
||||||
|
Format: "opus",
|
||||||
|
Data: []byte{0x01, 0x02},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishAudioChunk(context.Background(), chunk); err != nil {
|
||||||
|
t.Fatalf("PublishAudioChunk failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := <-mb.AudioChunksChan()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("AudioChunksChan returned ok=false")
|
||||||
|
}
|
||||||
|
if got.SessionID != "voice-1" || got.Sequence != 7 {
|
||||||
|
t.Fatalf("unexpected audio chunk: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishVoiceControlSubscribe(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctrl := VoiceControl{
|
||||||
|
SessionID: "voice-1",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Type: "command",
|
||||||
|
Action: "start",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishVoiceControl(context.Background(), ctrl); err != nil {
|
||||||
|
t.Fatalf("PublishVoiceControl failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := <-mb.VoiceControlsChan()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("VoiceControlsChan returned ok=false")
|
||||||
|
}
|
||||||
|
if got.Type != "command" || got.Action != "start" {
|
||||||
|
t.Fatalf("unexpected voice control: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewOutboundContext_NormalizesReplyAddress(t *testing.T) {
|
||||||
|
ctx := NewOutboundContext(" telegram ", " chat-42 ", " msg-9 ")
|
||||||
|
if ctx.Channel != "telegram" {
|
||||||
|
t.Fatalf("expected channel telegram, got %q", ctx.Channel)
|
||||||
|
}
|
||||||
|
if ctx.ChatID != "chat-42" {
|
||||||
|
t.Fatalf("expected chat_id chat-42, got %q", ctx.ChatID)
|
||||||
|
}
|
||||||
|
if ctx.ReplyToMessageID != "msg-9" {
|
||||||
|
t.Fatalf("expected reply_to_message_id msg-9, got %q", ctx.ReplyToMessageID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPublishInbound_ContextCancel(t *testing.T) {
|
func TestPublishInbound_ContextCancel(t *testing.T) {
|
||||||
|
|
@ -68,7 +421,15 @@ func TestPublishInbound_ContextCancel(t *testing.T) {
|
||||||
// Fill the buffer
|
// Fill the buffer
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
for i := range defaultBusBufferSize {
|
for i := range defaultBusBufferSize {
|
||||||
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
|
if err := mb.PublishInbound(ctx, InboundMessage{
|
||||||
|
Context: InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "chat-fill",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "user-fill",
|
||||||
|
},
|
||||||
|
Content: "fill",
|
||||||
|
}); err != nil {
|
||||||
t.Fatalf("fill failed at %d: %v", i, err)
|
t.Fatalf("fill failed at %d: %v", i, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +438,15 @@ func TestPublishInbound_ContextCancel(t *testing.T) {
|
||||||
cancelCtx, cancel := context.WithCancel(context.Background())
|
cancelCtx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
err := mb.PublishInbound(cancelCtx, InboundMessage{Content: "overflow"})
|
err := mb.PublishInbound(cancelCtx, InboundMessage{
|
||||||
|
Context: InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "chat-overflow",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "user-overflow",
|
||||||
|
},
|
||||||
|
Content: "overflow",
|
||||||
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error from canceled context, got nil")
|
t.Fatal("expected error from canceled context, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +459,15 @@ func TestPublishInbound_BusClosed(t *testing.T) {
|
||||||
mb := NewMessageBus()
|
mb := NewMessageBus()
|
||||||
mb.Close()
|
mb.Close()
|
||||||
|
|
||||||
err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"})
|
err := mb.PublishInbound(context.Background(), InboundMessage{
|
||||||
|
Context: InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "chat1",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "user1",
|
||||||
|
},
|
||||||
|
Content: "test",
|
||||||
|
})
|
||||||
if err != ErrBusClosed {
|
if err != ErrBusClosed {
|
||||||
t.Fatalf("expected ErrBusClosed, got %v", err)
|
t.Fatalf("expected ErrBusClosed, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -100,7 +477,13 @@ func TestPublishOutbound_BusClosed(t *testing.T) {
|
||||||
mb := NewMessageBus()
|
mb := NewMessageBus()
|
||||||
mb.Close()
|
mb.Close()
|
||||||
|
|
||||||
err := mb.PublishOutbound(context.Background(), OutboundMessage{Content: "test"})
|
err := mb.PublishOutbound(context.Background(), OutboundMessage{
|
||||||
|
Context: InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "chat1",
|
||||||
|
},
|
||||||
|
Content: "test",
|
||||||
|
})
|
||||||
if err != ErrBusClosed {
|
if err != ErrBusClosed {
|
||||||
t.Fatalf("expected ErrBusClosed, got %v", err)
|
t.Fatalf("expected ErrBusClosed, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -112,14 +495,30 @@ func TestConsumeInbound_ContextCancel(t *testing.T) {
|
||||||
defer mb.Close()
|
defer mb.Close()
|
||||||
|
|
||||||
for i := range defaultBusBufferSize {
|
for i := range defaultBusBufferSize {
|
||||||
if err := mb.PublishInbound(context.Background(), InboundMessage{Content: "fill"}); err != nil {
|
if err := mb.PublishInbound(context.Background(), InboundMessage{
|
||||||
|
Context: InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "chat-fill",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "user-fill",
|
||||||
|
},
|
||||||
|
Content: "fill",
|
||||||
|
}); err != nil {
|
||||||
t.Fatalf("fill failed at %d: %v", i, err)
|
t.Fatalf("fill failed at %d: %v", i, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
mb.PublishInbound(ctx, InboundMessage{Content: "ContextCancel"})
|
mb.PublishInbound(ctx, InboundMessage{
|
||||||
|
Context: InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "chat-cancel",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "user-cancel",
|
||||||
|
},
|
||||||
|
Content: "ContextCancel",
|
||||||
|
})
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
|
@ -213,7 +612,15 @@ func TestPublishInbound_FullBuffer(t *testing.T) {
|
||||||
|
|
||||||
// Fill the buffer
|
// Fill the buffer
|
||||||
for i := range defaultBusBufferSize {
|
for i := range defaultBusBufferSize {
|
||||||
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
|
if err := mb.PublishInbound(ctx, InboundMessage{
|
||||||
|
Context: InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "chat-fill",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "user-fill",
|
||||||
|
},
|
||||||
|
Content: "fill",
|
||||||
|
}); err != nil {
|
||||||
t.Fatalf("fill failed at %d: %v", i, err)
|
t.Fatalf("fill failed at %d: %v", i, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -222,7 +629,15 @@ func TestPublishInbound_FullBuffer(t *testing.T) {
|
||||||
timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
err := mb.PublishInbound(timeoutCtx, InboundMessage{Content: "overflow"})
|
err := mb.PublishInbound(timeoutCtx, InboundMessage{
|
||||||
|
Context: InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "chat-overflow",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "user-overflow",
|
||||||
|
},
|
||||||
|
Content: "overflow",
|
||||||
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error when buffer is full and context times out")
|
t.Fatal("expected error when buffer is full and context times out")
|
||||||
}
|
}
|
||||||
|
|
@ -240,7 +655,15 @@ func TestCloseIdempotent(t *testing.T) {
|
||||||
mb.Close()
|
mb.Close()
|
||||||
|
|
||||||
// After close, publish should return ErrBusClosed
|
// After close, publish should return ErrBusClosed
|
||||||
err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"})
|
err := mb.PublishInbound(context.Background(), InboundMessage{
|
||||||
|
Context: InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "chat1",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "user1",
|
||||||
|
},
|
||||||
|
Content: "test",
|
||||||
|
})
|
||||||
if err != ErrBusClosed {
|
if err != ErrBusClosed {
|
||||||
t.Fatalf("expected ErrBusClosed after multiple closes, got %v", err)
|
t.Fatalf("expected ErrBusClosed after multiple closes, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
pkg/bus/inbound_context.go
Normal file
81
pkg/bus/inbound_context.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package bus
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// NormalizeInboundMessage ensures the inbound context is normalized and keeps
|
||||||
|
// convenience mirrors in sync for runtime consumers.
|
||||||
|
func NormalizeInboundMessage(msg InboundMessage) InboundMessage {
|
||||||
|
if msg.Context.Channel == "" {
|
||||||
|
msg.Context.Channel = msg.Channel
|
||||||
|
}
|
||||||
|
if msg.Context.ChatID == "" {
|
||||||
|
msg.Context.ChatID = msg.ChatID
|
||||||
|
}
|
||||||
|
if msg.Context.SenderID == "" {
|
||||||
|
msg.Context.SenderID = msg.SenderID
|
||||||
|
}
|
||||||
|
if msg.Context.MessageID == "" {
|
||||||
|
msg.Context.MessageID = msg.MessageID
|
||||||
|
}
|
||||||
|
msg.Context = normalizeInboundContext(msg.Context)
|
||||||
|
msg.Channel = msg.Context.Channel
|
||||||
|
msg.SenderID = msg.Context.SenderID
|
||||||
|
msg.ChatID = msg.Context.ChatID
|
||||||
|
if msg.MessageID == "" {
|
||||||
|
msg.MessageID = msg.Context.MessageID
|
||||||
|
}
|
||||||
|
if msg.Context.MessageID == "" {
|
||||||
|
msg.Context.MessageID = msg.MessageID
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx InboundContext) isZero() bool {
|
||||||
|
return ctx.Channel == "" &&
|
||||||
|
ctx.Account == "" &&
|
||||||
|
ctx.ChatID == "" &&
|
||||||
|
ctx.ChatType == "" &&
|
||||||
|
ctx.TopicID == "" &&
|
||||||
|
ctx.SpaceID == "" &&
|
||||||
|
ctx.SpaceType == "" &&
|
||||||
|
ctx.SenderID == "" &&
|
||||||
|
ctx.MessageID == "" &&
|
||||||
|
!ctx.Mentioned &&
|
||||||
|
ctx.ReplyToMessageID == "" &&
|
||||||
|
ctx.ReplyToSenderID == "" &&
|
||||||
|
len(ctx.ReplyHandles) == 0 &&
|
||||||
|
len(ctx.Raw) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeInboundContext(ctx InboundContext) InboundContext {
|
||||||
|
ctx.Channel = strings.TrimSpace(ctx.Channel)
|
||||||
|
ctx.Account = strings.TrimSpace(ctx.Account)
|
||||||
|
ctx.ChatID = strings.TrimSpace(ctx.ChatID)
|
||||||
|
ctx.ChatType = normalizeKind(ctx.ChatType)
|
||||||
|
ctx.TopicID = strings.TrimSpace(ctx.TopicID)
|
||||||
|
ctx.SpaceID = strings.TrimSpace(ctx.SpaceID)
|
||||||
|
ctx.SpaceType = normalizeKind(ctx.SpaceType)
|
||||||
|
ctx.SenderID = strings.TrimSpace(ctx.SenderID)
|
||||||
|
ctx.MessageID = strings.TrimSpace(ctx.MessageID)
|
||||||
|
ctx.ReplyToMessageID = strings.TrimSpace(ctx.ReplyToMessageID)
|
||||||
|
ctx.ReplyToSenderID = strings.TrimSpace(ctx.ReplyToSenderID)
|
||||||
|
ctx.ReplyHandles = cloneStringMap(ctx.ReplyHandles)
|
||||||
|
ctx.Raw = cloneStringMap(ctx.Raw)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringMap(src map[string]string) map[string]string {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := make(map[string]string, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
dst[k] = v
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeKind(kind string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(kind))
|
||||||
|
}
|
||||||
84
pkg/bus/outbound_context.go
Normal file
84
pkg/bus/outbound_context.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
package bus
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// NewOutboundContext builds the minimal normalized addressing context required
|
||||||
|
// to deliver an outbound text message or reply.
|
||||||
|
func NewOutboundContext(channel, chatID, replyToMessageID string) InboundContext {
|
||||||
|
return normalizeInboundContext(InboundContext{
|
||||||
|
Channel: strings.TrimSpace(channel),
|
||||||
|
ChatID: strings.TrimSpace(chatID),
|
||||||
|
ReplyToMessageID: strings.TrimSpace(replyToMessageID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeOutboundMessage ensures Context is normalized and keeps convenience
|
||||||
|
// mirrors in sync for runtime consumers.
|
||||||
|
func NormalizeOutboundMessage(msg OutboundMessage) OutboundMessage {
|
||||||
|
msg.Channel = strings.TrimSpace(msg.Channel)
|
||||||
|
msg.ChatID = strings.TrimSpace(msg.ChatID)
|
||||||
|
msg.ReplyToMessageID = strings.TrimSpace(msg.ReplyToMessageID)
|
||||||
|
if msg.Context.Channel == "" {
|
||||||
|
msg.Context.Channel = msg.Channel
|
||||||
|
}
|
||||||
|
if msg.Context.ChatID == "" {
|
||||||
|
msg.Context.ChatID = msg.ChatID
|
||||||
|
}
|
||||||
|
if msg.Context.ReplyToMessageID == "" {
|
||||||
|
msg.Context.ReplyToMessageID = msg.ReplyToMessageID
|
||||||
|
}
|
||||||
|
msg.Context = normalizeInboundContext(msg.Context)
|
||||||
|
if msg.Channel == "" {
|
||||||
|
msg.Channel = msg.Context.Channel
|
||||||
|
}
|
||||||
|
if msg.ChatID == "" {
|
||||||
|
msg.ChatID = msg.Context.ChatID
|
||||||
|
}
|
||||||
|
if msg.ReplyToMessageID == "" {
|
||||||
|
msg.ReplyToMessageID = msg.Context.ReplyToMessageID
|
||||||
|
}
|
||||||
|
if msg.Context.ReplyToMessageID == "" {
|
||||||
|
msg.Context.ReplyToMessageID = msg.ReplyToMessageID
|
||||||
|
}
|
||||||
|
msg.Scope = cloneOutboundScope(msg.Scope)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeOutboundMediaMessage ensures media outbound messages also carry a
|
||||||
|
// normalized context while keeping convenience mirrors in sync.
|
||||||
|
func NormalizeOutboundMediaMessage(msg OutboundMediaMessage) OutboundMediaMessage {
|
||||||
|
msg.Channel = strings.TrimSpace(msg.Channel)
|
||||||
|
msg.ChatID = strings.TrimSpace(msg.ChatID)
|
||||||
|
if msg.Context.Channel == "" {
|
||||||
|
msg.Context.Channel = msg.Channel
|
||||||
|
}
|
||||||
|
if msg.Context.ChatID == "" {
|
||||||
|
msg.Context.ChatID = msg.ChatID
|
||||||
|
}
|
||||||
|
msg.Context = normalizeInboundContext(msg.Context)
|
||||||
|
if msg.Channel == "" {
|
||||||
|
msg.Channel = msg.Context.Channel
|
||||||
|
}
|
||||||
|
if msg.ChatID == "" {
|
||||||
|
msg.ChatID = msg.Context.ChatID
|
||||||
|
}
|
||||||
|
msg.Scope = cloneOutboundScope(msg.Scope)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneOutboundScope(scope *OutboundScope) *OutboundScope {
|
||||||
|
if scope == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := *scope
|
||||||
|
if len(scope.Dimensions) > 0 {
|
||||||
|
cloned.Dimensions = append([]string(nil), scope.Dimensions...)
|
||||||
|
}
|
||||||
|
if len(scope.Values) > 0 {
|
||||||
|
cloned.Values = make(map[string]string, len(scope.Values))
|
||||||
|
for key, value := range scope.Values {
|
||||||
|
cloned.Values[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
package bus
|
package bus
|
||||||
|
|
||||||
// Peer identifies the routing peer for a message (direct, group, channel, etc.)
|
|
||||||
type Peer struct {
|
|
||||||
Kind string `json:"kind"` // "direct" | "group" | "channel" | ""
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SenderInfo provides structured sender identity information.
|
// SenderInfo provides structured sender identity information.
|
||||||
type SenderInfo struct {
|
type SenderInfo struct {
|
||||||
Platform string `json:"platform,omitempty"` // "telegram", "discord", "slack", ...
|
Platform string `json:"platform,omitempty"` // "telegram", "discord", "slack", ...
|
||||||
|
|
@ -15,26 +9,67 @@ type SenderInfo struct {
|
||||||
DisplayName string `json:"display_name,omitempty"` // display name
|
DisplayName string `json:"display_name,omitempty"` // display name
|
||||||
}
|
}
|
||||||
|
|
||||||
type InboundMessage struct {
|
// InboundContext captures the normalized, platform-agnostic facts about an
|
||||||
|
// inbound message. This is the source of truth for routing and session
|
||||||
|
// allocation.
|
||||||
|
type InboundContext struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
SenderID string `json:"sender_id"`
|
Account string `json:"account,omitempty"`
|
||||||
Sender SenderInfo `json:"sender"`
|
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
|
ChatType string `json:"chat_type,omitempty"` // direct / group / channel
|
||||||
|
TopicID string `json:"topic_id,omitempty"`
|
||||||
|
|
||||||
|
SpaceID string `json:"space_id,omitempty"`
|
||||||
|
SpaceType string `json:"space_type,omitempty"` // guild / team / workspace / tenant
|
||||||
|
|
||||||
|
SenderID string `json:"sender_id"`
|
||||||
|
MessageID string `json:"message_id,omitempty"`
|
||||||
|
|
||||||
|
Mentioned bool `json:"mentioned,omitempty"`
|
||||||
|
|
||||||
|
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
|
||||||
|
ReplyToSenderID string `json:"reply_to_sender_id,omitempty"`
|
||||||
|
|
||||||
|
ReplyHandles map[string]string `json:"reply_handles,omitempty"`
|
||||||
|
Raw map[string]string `json:"raw,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InboundMessage struct {
|
||||||
|
Context InboundContext `json:"context"`
|
||||||
|
Sender SenderInfo `json:"sender"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Media []string `json:"media,omitempty"`
|
Media []string `json:"media,omitempty"`
|
||||||
Peer Peer `json:"peer"` // routing peer
|
|
||||||
MessageID string `json:"message_id,omitempty"` // platform message ID
|
|
||||||
MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope
|
MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope
|
||||||
SessionKey string `json:"session_key"`
|
SessionKey string `json:"session_key"`
|
||||||
Metadata map[string]string `json:"metadata,omitempty"`
|
|
||||||
|
// Convenience mirrors derived from Context for runtime consumers.
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
SenderID string `json:"sender_id"`
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
MessageID string `json:"message_id,omitempty"` // platform message ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutboundScope captures the structured session scope associated with an
|
||||||
|
// outbound turn result without depending on the session package.
|
||||||
|
type OutboundScope struct {
|
||||||
|
Version int `json:"version,omitempty"`
|
||||||
|
AgentID string `json:"agent_id,omitempty"`
|
||||||
|
Channel string `json:"channel,omitempty"`
|
||||||
|
Account string `json:"account,omitempty"`
|
||||||
|
Dimensions []string `json:"dimensions,omitempty"`
|
||||||
|
Values map[string]string `json:"values,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OutboundMessage struct {
|
type OutboundMessage struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
|
Context InboundContext `json:"context"`
|
||||||
|
AgentID string `json:"agent_id,omitempty"`
|
||||||
|
SessionKey string `json:"session_key,omitempty"`
|
||||||
|
Scope *OutboundScope `json:"scope,omitempty"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
|
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
|
||||||
Metadata map[string]string `json:"metadata,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MediaPart describes a single media attachment to send.
|
// MediaPart describes a single media attachment to send.
|
||||||
|
|
@ -50,6 +85,10 @@ type MediaPart struct {
|
||||||
type OutboundMediaMessage struct {
|
type OutboundMediaMessage struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
|
Context InboundContext `json:"context"`
|
||||||
|
AgentID string `json:"agent_id,omitempty"`
|
||||||
|
SessionKey string `json:"session_key,omitempty"`
|
||||||
|
Scope *OutboundScope `json:"scope,omitempty"`
|
||||||
Parts []MediaPart `json:"parts"`
|
Parts []MediaPart `json:"parts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -260,12 +260,11 @@ func (c *BaseChannel) IsAllowedSender(sender bus.SenderInfo) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *BaseChannel) HandleMessage(
|
func (c *BaseChannel) HandleMessageWithContext(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
peer bus.Peer,
|
deliveryChatID, content string,
|
||||||
messageID, senderID, chatID, content string,
|
|
||||||
media []string,
|
media []string,
|
||||||
metadata map[string]string,
|
inboundCtx bus.InboundContext,
|
||||||
senderOpts ...bus.SenderInfo,
|
senderOpts ...bus.SenderInfo,
|
||||||
) {
|
) {
|
||||||
// Use SenderInfo-based allow check when available, else fall back to string
|
// Use SenderInfo-based allow check when available, else fall back to string
|
||||||
|
|
@ -273,6 +272,7 @@ func (c *BaseChannel) HandleMessage(
|
||||||
if len(senderOpts) > 0 {
|
if len(senderOpts) > 0 {
|
||||||
sender = senderOpts[0]
|
sender = senderOpts[0]
|
||||||
}
|
}
|
||||||
|
senderID := strings.TrimSpace(inboundCtx.SenderID)
|
||||||
if sender.CanonicalID != "" || sender.PlatformID != "" {
|
if sender.CanonicalID != "" || sender.PlatformID != "" {
|
||||||
if !c.IsAllowedSender(sender) {
|
if !c.IsAllowedSender(sender) {
|
||||||
return
|
return
|
||||||
|
|
@ -289,20 +289,28 @@ func (c *BaseChannel) HandleMessage(
|
||||||
resolvedSenderID = sender.CanonicalID
|
resolvedSenderID = sender.CanonicalID
|
||||||
}
|
}
|
||||||
|
|
||||||
scope := BuildMediaScope(c.name, chatID, messageID)
|
if resolvedSenderID == "" {
|
||||||
|
resolvedSenderID = senderID
|
||||||
|
}
|
||||||
|
|
||||||
|
inboundCtx.Channel = c.name
|
||||||
|
if inboundCtx.ChatID == "" {
|
||||||
|
inboundCtx.ChatID = deliveryChatID
|
||||||
|
}
|
||||||
|
if inboundCtx.SenderID == "" {
|
||||||
|
inboundCtx.SenderID = resolvedSenderID
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := BuildMediaScope(c.name, deliveryChatID, inboundCtx.MessageID)
|
||||||
|
|
||||||
msg := bus.InboundMessage{
|
msg := bus.InboundMessage{
|
||||||
Channel: c.name,
|
Context: inboundCtx,
|
||||||
SenderID: resolvedSenderID,
|
|
||||||
Sender: sender,
|
Sender: sender,
|
||||||
ChatID: chatID,
|
|
||||||
Content: content,
|
Content: content,
|
||||||
Media: media,
|
Media: media,
|
||||||
Peer: peer,
|
|
||||||
MessageID: messageID,
|
|
||||||
MediaScope: scope,
|
MediaScope: scope,
|
||||||
Metadata: metadata,
|
|
||||||
}
|
}
|
||||||
|
msg = bus.NormalizeInboundMessage(msg)
|
||||||
|
|
||||||
// Auto-trigger typing indicator, message reaction, and placeholder before publishing.
|
// Auto-trigger typing indicator, message reaction, and placeholder before publishing.
|
||||||
// Each capability is independent — all three may fire for the same message.
|
// Each capability is independent — all three may fire for the same message.
|
||||||
|
|
@ -313,14 +321,14 @@ func (c *BaseChannel) HandleMessage(
|
||||||
if c.owner != nil && c.placeholderRecorder != nil {
|
if c.owner != nil && c.placeholderRecorder != nil {
|
||||||
// Typing
|
// Typing
|
||||||
if tc, ok := c.owner.(TypingCapable); ok {
|
if tc, ok := c.owner.(TypingCapable); ok {
|
||||||
if stop, err := tc.StartTyping(ctx, chatID); err == nil {
|
if stop, err := tc.StartTyping(ctx, deliveryChatID); err == nil {
|
||||||
c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop)
|
c.placeholderRecorder.RecordTypingStop(c.name, deliveryChatID, stop)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Reaction
|
// Reaction
|
||||||
if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" {
|
if rc, ok := c.owner.(ReactionCapable); ok && msg.MessageID != "" {
|
||||||
if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil {
|
if undo, err := rc.ReactToMessage(ctx, deliveryChatID, msg.MessageID); err == nil {
|
||||||
c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo)
|
c.placeholderRecorder.RecordReactionUndo(c.name, deliveryChatID, undo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Placeholder — independent pipeline.
|
// Placeholder — independent pipeline.
|
||||||
|
|
@ -329,8 +337,8 @@ func (c *BaseChannel) HandleMessage(
|
||||||
// "Thinking…" only once the voice has been processed.
|
// "Thinking…" only once the voice has been processed.
|
||||||
if !audioAnnotationRe.MatchString(content) {
|
if !audioAnnotationRe.MatchString(content) {
|
||||||
if pc, ok := c.owner.(PlaceholderCapable); ok {
|
if pc, ok := c.owner.(PlaceholderCapable); ok {
|
||||||
if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" {
|
if phID, err := pc.SendPlaceholder(ctx, deliveryChatID); err == nil && phID != "" {
|
||||||
c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)
|
c.placeholderRecorder.RecordPlaceholder(c.name, deliveryChatID, phID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -339,12 +347,24 @@ func (c *BaseChannel) HandleMessage(
|
||||||
if err := c.bus.PublishInbound(ctx, msg); err != nil {
|
if err := c.bus.PublishInbound(ctx, msg); err != nil {
|
||||||
logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{
|
logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{
|
||||||
"channel": c.name,
|
"channel": c.name,
|
||||||
"chat_id": chatID,
|
"chat_id": deliveryChatID,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleInboundContext publishes a normalized inbound message using only the
|
||||||
|
// structured context.
|
||||||
|
func (c *BaseChannel) HandleInboundContext(
|
||||||
|
ctx context.Context,
|
||||||
|
deliveryChatID, content string,
|
||||||
|
media []string,
|
||||||
|
inboundCtx bus.InboundContext,
|
||||||
|
senderOpts ...bus.SenderInfo,
|
||||||
|
) {
|
||||||
|
c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *BaseChannel) SetRunning(running bool) {
|
func (c *BaseChannel) SetRunning(running bool) {
|
||||||
c.running.Store(running)
|
c.running.Store(running)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package channels
|
package channels
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
|
@ -263,3 +264,58 @@ func TestIsAllowedSender(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleInboundContext_PublishesNormalizedContext(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inbound bus.InboundContext
|
||||||
|
wantChat string
|
||||||
|
wantSender string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "direct uses sender as peer",
|
||||||
|
inbound: bus.InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "user-1",
|
||||||
|
MessageID: "msg-1",
|
||||||
|
},
|
||||||
|
wantChat: "chat-1",
|
||||||
|
wantSender: "user-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "group uses chat as peer",
|
||||||
|
inbound: bus.InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "group-1",
|
||||||
|
ChatType: "group",
|
||||||
|
SenderID: "user-2",
|
||||||
|
MessageID: "msg-2",
|
||||||
|
},
|
||||||
|
wantChat: "group-1",
|
||||||
|
wantSender: "user-2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
defer msgBus.Close()
|
||||||
|
|
||||||
|
ch := NewBaseChannel("test", nil, msgBus, nil)
|
||||||
|
ch.HandleInboundContext(context.Background(), tt.inbound.ChatID, "hello", nil, tt.inbound)
|
||||||
|
|
||||||
|
msg := <-msgBus.InboundChan()
|
||||||
|
if msg.ChatID != tt.wantChat {
|
||||||
|
t.Fatalf("ChatID = %q, want %q", msg.ChatID, tt.wantChat)
|
||||||
|
}
|
||||||
|
if msg.SenderID != tt.wantSender {
|
||||||
|
t.Fatalf("SenderID = %q, want %q", msg.SenderID, tt.wantSender)
|
||||||
|
}
|
||||||
|
if msg.Context.ChatType != tt.inbound.ChatType {
|
||||||
|
t.Fatalf("ChatType = %q, want %q", msg.Context.ChatType, tt.inbound.ChatType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -185,16 +185,15 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
|
||||||
"session_webhook": data.SessionWebhook,
|
"session_webhook": data.SessionWebhook,
|
||||||
}
|
}
|
||||||
|
|
||||||
var peer bus.Peer
|
var (
|
||||||
|
chatType string
|
||||||
|
isMentioned bool
|
||||||
|
)
|
||||||
if data.ConversationType == "1" {
|
if data.ConversationType == "1" {
|
||||||
peerID := senderID
|
chatType = "direct"
|
||||||
if peerID == "" {
|
|
||||||
peerID = chatID
|
|
||||||
}
|
|
||||||
peer = bus.Peer{Kind: "direct", ID: peerID}
|
|
||||||
} else {
|
} else {
|
||||||
peer = bus.Peer{Kind: "group", ID: data.ConversationId}
|
chatType = "group"
|
||||||
isMentioned := data.IsInAtList
|
isMentioned = data.IsInAtList
|
||||||
if isMentioned {
|
if isMentioned {
|
||||||
content = stripLeadingAtMentions(content)
|
content = stripLeadingAtMentions(content)
|
||||||
}
|
}
|
||||||
|
|
@ -232,8 +231,21 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle the message through the base channel
|
inboundCtx := bus.InboundContext{
|
||||||
c.HandleMessage(ctx, peer, "", resolvedSenderID, chatID, content, nil, metadata, sender)
|
Channel: "dingtalk",
|
||||||
|
ChatID: chatID,
|
||||||
|
ChatType: chatType,
|
||||||
|
SenderID: resolvedSenderID,
|
||||||
|
Mentioned: isMentioned,
|
||||||
|
Raw: metadata,
|
||||||
|
}
|
||||||
|
if data.SessionWebhook != "" {
|
||||||
|
inboundCtx.ReplyHandles = map[string]string{
|
||||||
|
"session_webhook": data.SessionWebhook,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleInboundContext(ctx, chatID, content, nil, inboundCtx, sender)
|
||||||
|
|
||||||
// Return nil to indicate we've handled the message asynchronously
|
// Return nil to indicate we've handled the message asynchronously
|
||||||
// The response will be sent through the message bus
|
// The response will be sent through the message bus
|
||||||
|
|
|
||||||
|
|
@ -75,8 +75,8 @@ func TestOnChatBotMessageReceived_GroupMentionOnlyUsesIsInAtListAndStripsMention
|
||||||
if inbound.ChatID != "group-abc" {
|
if inbound.ChatID != "group-abc" {
|
||||||
t.Fatalf("chat_id=%q", inbound.ChatID)
|
t.Fatalf("chat_id=%q", inbound.ChatID)
|
||||||
}
|
}
|
||||||
if inbound.Peer.Kind != "group" || inbound.Peer.ID != "group-abc" {
|
if inbound.Context.ChatType != "group" {
|
||||||
t.Fatalf("peer=%+v", inbound.Peer)
|
t.Fatalf("chat_type=%q", inbound.Context.ChatType)
|
||||||
}
|
}
|
||||||
if inbound.Content != "/help" {
|
if inbound.Content != "/help" {
|
||||||
t.Fatalf("content=%q", inbound.Content)
|
t.Fatalf("content=%q", inbound.Content)
|
||||||
|
|
@ -103,12 +103,15 @@ func TestOnChatBotMessageReceived_DirectFallbackSenderIDUsesConversationID(t *te
|
||||||
if inbound.ChatID != "conv-direct-42" {
|
if inbound.ChatID != "conv-direct-42" {
|
||||||
t.Fatalf("chat_id=%q", inbound.ChatID)
|
t.Fatalf("chat_id=%q", inbound.ChatID)
|
||||||
}
|
}
|
||||||
if inbound.Peer.Kind != "direct" || inbound.Peer.ID != "openid-user-42" {
|
if inbound.Context.ChatType != "direct" {
|
||||||
t.Fatalf("peer=%+v", inbound.Peer)
|
t.Fatalf("chat_type=%q", inbound.Context.ChatType)
|
||||||
}
|
}
|
||||||
if inbound.SenderID != "dingtalk:openid-user-42" {
|
if inbound.SenderID != "openid-user-42" {
|
||||||
t.Fatalf("sender_id=%q", inbound.SenderID)
|
t.Fatalf("sender_id=%q", inbound.SenderID)
|
||||||
}
|
}
|
||||||
|
if inbound.Sender.CanonicalID != "dingtalk:openid-user-42" {
|
||||||
|
t.Fatalf("sender canonical_id=%q", inbound.Sender.CanonicalID)
|
||||||
|
}
|
||||||
|
|
||||||
if _, ok := ch.sessionWebhooks.Load("conv-direct-42"); !ok {
|
if _, ok := ch.sessionWebhooks.Load("conv-direct-42"); !ok {
|
||||||
t.Fatal("expected session webhook keyed by conversation_id")
|
t.Fatal("expected session webhook keyed by conversation_id")
|
||||||
|
|
|
||||||
|
|
@ -408,8 +408,8 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
|
|
||||||
// In guild (group) channels, apply unified group trigger filtering
|
// In guild (group) channels, apply unified group trigger filtering
|
||||||
// DMs (GuildID is empty) always get a response
|
// DMs (GuildID is empty) always get a response
|
||||||
if m.GuildID != "" {
|
|
||||||
isMentioned := false
|
isMentioned := false
|
||||||
|
if m.GuildID != "" {
|
||||||
for _, mention := range m.Mentions {
|
for _, mention := range m.Mentions {
|
||||||
if mention.ID == c.botUserID {
|
if mention.ID == c.botUserID {
|
||||||
isMentioned = true
|
isMentioned = true
|
||||||
|
|
@ -506,14 +506,10 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
})
|
})
|
||||||
|
|
||||||
peerKind := "channel"
|
peerKind := "channel"
|
||||||
peerID := m.ChannelID
|
|
||||||
if m.GuildID == "" {
|
if m.GuildID == "" {
|
||||||
peerKind = "direct"
|
peerKind = "direct"
|
||||||
peerID = senderID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"user_id": senderID,
|
"user_id": senderID,
|
||||||
"username": m.Author.Username,
|
"username": m.Author.Username,
|
||||||
|
|
@ -522,8 +518,24 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
"channel_id": m.ChannelID,
|
"channel_id": m.ChannelID,
|
||||||
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
|
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
|
||||||
}
|
}
|
||||||
|
inboundCtx := bus.InboundContext{
|
||||||
|
Channel: c.Name(),
|
||||||
|
ChatID: m.ChannelID,
|
||||||
|
ChatType: peerKind,
|
||||||
|
SenderID: senderID,
|
||||||
|
MessageID: m.ID,
|
||||||
|
Mentioned: isMentioned,
|
||||||
|
Raw: metadata,
|
||||||
|
}
|
||||||
|
if m.GuildID != "" {
|
||||||
|
inboundCtx.SpaceID = m.GuildID
|
||||||
|
inboundCtx.SpaceType = "guild"
|
||||||
|
}
|
||||||
|
if m.MessageReference != nil {
|
||||||
|
inboundCtx.ReplyToMessageID = m.MessageReference.MessageID
|
||||||
|
}
|
||||||
|
|
||||||
c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender)
|
c.HandleInboundContext(c.ctx, m.ChannelID, content, mediaPaths, inboundCtx, sender)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startTyping starts a continuous typing indicator loop for the given chatID.
|
// startTyping starts a continuous typing indicator loop for the given chatID.
|
||||||
|
|
|
||||||
|
|
@ -445,17 +445,23 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
|
||||||
// Append media tags to content (like Telegram does)
|
// Append media tags to content (like Telegram does)
|
||||||
content = appendMediaTags(content, messageType, mediaRefs)
|
content = appendMediaTags(content, messageType, mediaRefs)
|
||||||
|
|
||||||
|
if content == "" {
|
||||||
|
content = "[empty message]"
|
||||||
|
}
|
||||||
chatType := stringValue(message.ChatType)
|
chatType := stringValue(message.ChatType)
|
||||||
metadata := buildInboundMetadata(message, sender)
|
metadata := buildInboundMetadata(message, sender)
|
||||||
|
|
||||||
var peer bus.Peer
|
var (
|
||||||
|
inboundChatType string
|
||||||
|
isMentioned bool
|
||||||
|
)
|
||||||
if chatType == "p2p" {
|
if chatType == "p2p" {
|
||||||
peer = bus.Peer{Kind: "direct", ID: senderID}
|
inboundChatType = "direct"
|
||||||
} else {
|
} else {
|
||||||
peer = bus.Peer{Kind: "group", ID: chatID}
|
inboundChatType = "group"
|
||||||
|
|
||||||
// Check if bot was mentioned
|
// Check if bot was mentioned
|
||||||
isMentioned := c.isBotMentioned(message)
|
isMentioned = c.isBotMentioned(message)
|
||||||
|
|
||||||
// Strip mention placeholders from content before group trigger check
|
// Strip mention placeholders from content before group trigger check
|
||||||
if len(message.Mentions) > 0 {
|
if len(message.Mentions) > 0 {
|
||||||
|
|
@ -490,7 +496,21 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
|
||||||
"thread_id": stringValue(message.ThreadId),
|
"thread_id": stringValue(message.ThreadId),
|
||||||
})
|
})
|
||||||
|
|
||||||
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo)
|
inboundCtx := bus.InboundContext{
|
||||||
|
Channel: "feishu",
|
||||||
|
ChatID: chatID,
|
||||||
|
ChatType: inboundChatType,
|
||||||
|
SenderID: senderID,
|
||||||
|
MessageID: messageID,
|
||||||
|
Mentioned: isMentioned,
|
||||||
|
Raw: metadata,
|
||||||
|
}
|
||||||
|
if sender != nil && sender.TenantKey != nil && *sender.TenantKey != "" {
|
||||||
|
inboundCtx.SpaceType = "tenant"
|
||||||
|
inboundCtx.SpaceID = *sender.TenantKey
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleInboundContext(ctx, chatID, content, mediaRefs, inboundCtx, senderInfo)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue