mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Compare commits
202 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cadc26c445 | |||
| fa6ed714c4 | |||
| f8472d6f27 | |||
| 44fdf9a20b | |||
| 17e4720203 | |||
| ef002d9a5d | |||
| 017601354b | |||
| 52ab6c4694 | |||
| f8462855d8 | |||
| 2861fd90ab | |||
| 40fe1b0a2d | |||
| 9955155389 | |||
| d955d5bbf3 | |||
| 2efbe5d560 | |||
| bc6179917c | |||
| 8a2c67fe70 | |||
| 355e83e07f | |||
| b9a8fad6fa | |||
| 0ab6924978 | |||
| 2ecdb893d5 | |||
| 921d753cc0 | |||
| 0bbd8f081e | |||
| fc90a5af23 | |||
| e2112e627c | |||
| 46b29a0ae9 | |||
| 13bf650807 | |||
| 0f86d9aacb | |||
| c215a4caaf | |||
| 5b9f9c85a9 | |||
| b40a1d92cc | |||
| fac5603daf | |||
| a4e8fe953e | |||
| 77017eb57d | |||
| 92a647bfcf | |||
| 8a246c2282 | |||
| 3bba6338ca | |||
| 12c36572a5 | |||
| 890780b924 | |||
| 1ab442b12c | |||
| 3f435c5e56 | |||
| 875cf4a2d4 | |||
| 5e7b84f429 | |||
| 1b3e887fc6 | |||
| d627dc8b57 | |||
| 0a3a7881c6 | |||
| 639f700c15 | |||
| cbb684be01 | |||
| 67eaa984c7 | |||
| ebb04abb38 | |||
| a011df1ddc | |||
| f037a112b2 | |||
| 10115f941c | |||
| db13367404 | |||
| 7c18fe8421 | |||
| 007b2ae8bd | |||
| 2d1fb953fc | |||
| b1d727ebaf | |||
| f7be21bb11 | |||
| 7d2b0c2a4d | |||
| c19e4e8db1 | |||
| ebf17aa152 | |||
| 4290aa8b5b | |||
| 5f0d368995 | |||
| ddabaa69a4 | |||
| ff7c92deee | |||
| 4752a67a7c | |||
| 89ee8f1b39 | |||
| b10f9cdf18 | |||
| 0b7aaac2b2 | |||
| 8e7e910f67 | |||
| 71524183b6 | |||
| 6c882ec5e7 | |||
| 9f246a6482 | |||
| 7a7e205cc8 | |||
| 1f2736915e | |||
| 12ca46b1ab | |||
| cc712a1adb | |||
| 52e3ea72ba | |||
| f0f809db35 | |||
| e5c7772d3c | |||
| 32ea611f0c | |||
| b6030f054d | |||
| 296a8ae287 | |||
| a6735517d2 | |||
| 5224b9a4bc | |||
| 976ecc68b7 | |||
| dbd76fe541 | |||
| 49e3a03def | |||
| d5bd06dc0d | |||
| d009ba32b7 | |||
| 9b0ab22b3d | |||
| 3e6abba803 | |||
| 79aefc5062 | |||
| 9da23e7804 | |||
| a90d8d35ee | |||
| b86ab71836 | |||
| 0ce6e20e08 | |||
| 36ca85ad09 | |||
| 734f53fb37 | |||
| 6e9b5071b0 | |||
| aa49d066b0 | |||
| 5f826f4448 | |||
| 04664ab514 | |||
| 9c71a44421 | |||
| e1d9a62e0e | |||
| 709c8b2b52 | |||
| 5d4840c979 | |||
| a502aa7f83 | |||
| e74ac70cf9 | |||
| 8dffd6ff03 | |||
| 1903a18235 | |||
| 004f9346c1 | |||
| 827cd32ffc | |||
| 379ab9af2f | |||
| e70a9fca7c | |||
| 99a7179e76 | |||
| 7b47872334 | |||
| 5927ecc394 | |||
| bb57e0498c | |||
| e42006c10d | |||
| 426046fca0 | |||
| 28eafaeef2 | |||
| 1cfa781925 | |||
| 5a997a86f0 | |||
| 672f86c670 | |||
| 4e3e90df26 | |||
| be13201f02 | |||
| 6e0e2906aa | |||
| c0f2714b66 | |||
| ba8065923b | |||
| 13e1833c81 | |||
| 1d8ef7dcfb | |||
| 93391223ea | |||
| 41a108c9af | |||
| 1ce353ba28 | |||
| 4b8761ce6d | |||
| 63ba146015 | |||
| 16c26338b6 | |||
| 2391f32fc1 | |||
| 46e5b59d5f | |||
| 995005a0ba | |||
| 1edb873ace | |||
| 2ff8b01cc6 | |||
| e1bada5b94 | |||
| e81d37108b | |||
| 4e280c5f5e | |||
| 6247f47628 | |||
| 32282beef8 | |||
| f9f53e30ee | |||
| a34669a2d8 | |||
| f797172a86 | |||
| 8e0964be24 | |||
| 85751492c6 | |||
| 0b7e18cd9e | |||
| e9e653fb13 | |||
| 5755b5b323 | |||
| 65c09d4270 | |||
| 28ec5793a8 | |||
| c5a016ccc6 | |||
| 9825b4782f | |||
| f5f6fdc1f9 | |||
| cfbddcd117 | |||
| 7be20bf70a | |||
| ab6d3946a5 | |||
| 7af40d49eb | |||
| 239a98e18b | |||
| d499cbece4 | |||
| d48fa2e2fd | |||
| e95bcaf3e3 | |||
| fbea699936 | |||
| 23e1485a98 | |||
| edcae17b41 | |||
| d609e83313 | |||
| 96b4c543f4 | |||
| 477028f8f2 | |||
| 9bb44b0a80 | |||
| 6a97b1b087 | |||
| 020bef2759 | |||
| 848bf77381 | |||
| 3a454593ca | |||
| ceebda35ee | |||
| 1bf0d898de | |||
| c05e5e29c6 | |||
| 987f117f31 | |||
| 5a4e42d1b6 | |||
| f09a7d67f7 | |||
| 2cce7b8abe | |||
| 044a9d1df6 | |||
| d3ac0a74c4 | |||
| 24e8285e73 | |||
| 33e5503e26 | |||
| fd08ebd3db | |||
| 34e73f6b1a | |||
| 3e30e8abc6 | |||
| 81bbef62b1 | |||
| 76175b4bcf | |||
| 0dfdb54198 | |||
| 1bc7abfb50 | |||
| 17cf91771c | |||
| f0dcba8c5a | |||
| fe7ded5c13 | |||
| 1502636bf0 |
@@ -1,3 +1,4 @@
|
||||
# Ensure shell scripts always use LF line endings regardless of OS.
|
||||
*.sh text eol=lf
|
||||
docker/entrypoint.sh text eol=lf
|
||||
.gitignore text eol=lf
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [sipeed]
|
||||
@@ -73,3 +73,4 @@ web/backend/dist/*
|
||||
docker/data
|
||||
|
||||
.omc/
|
||||
.worktrees/
|
||||
|
||||
@@ -27,7 +27,7 @@ endif
|
||||
VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev)
|
||||
GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev)
|
||||
BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev)
|
||||
GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown)
|
||||
GO_VERSION=$(if $(GO_VERSION_RAW),$(firstword $(GO_VERSION_RAW)),unknown)
|
||||
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
|
||||
|
||||
|
||||
@@ -64,6 +64,16 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-05-28 🚀 **v0.2.9 Released!** MCP server management in Web UI, configurable Sogou-backed web search, tool feedback animation in channels, `pretty_print` and `disable_escape_html` defaults, and numerous bug fixes across providers and channels.
|
||||
|
||||
2026-05-14 🚀 **v0.2.8 Released!** MCP CLI commands (`show`, `add`, `list`, `remove`, `test`, `edit`), empty object instead of null for MCP tool parameters, and build fixes.
|
||||
|
||||
2026-05-07 🚀 **v0.2.7 Released!** Configurable Sogou-backed web search, channel tool feedback animation, linter fixes.
|
||||
|
||||
2026-04-23 🚀 **v0.2.6 Released!** Hooks with respond action and comprehensive documentation, isolation support, help banner fix.
|
||||
|
||||
2026-04-11 🚀 **v0.2.5 Released!** Zoneinfo from TZ/ZONEINFO env, Matrix CommonMark rendering alignment, `read_file` by lines.
|
||||
|
||||
2026-03-31 📱 **Android Support!** PicoClaw now runs on Android! Download the APK at [picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 Released!** Agent architecture overhaul (SubTurn, Hooks, Steering, EventBus), WeChat/WeCom integration, security hardening (.security.yml, sensitive data filtering), new providers (AWS Bedrock, Azure, Xiaomi MiMo), and 35 bug fixes. PicoClaw has reached **26K Stars**!
|
||||
@@ -321,6 +331,8 @@ Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install d
|
||||
|
||||
**Option 2: Termux**
|
||||
|
||||
For a full command-line setup checklist, see the [Android Termux Guide](docs/guides/android-termux.md).
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (for resource-constrained environments)</b></summary>
|
||||
|
||||
@@ -413,12 +425,14 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | Not needed | Local models, self-hosted |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Not needed | Local deployment, OpenAI-compatible |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Varies | Proxy for 100+ providers |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Required | Enterprise Azure deployment |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | API key or Entra ID** | Enterprise Azure deployment |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Device code login |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | AWS credentials | Claude, Llama, Mistral on AWS |
|
||||
|
||||
> \* AWS Bedrock requires build tag: `go build -tags bedrock`. Set `api_base` to a region name (e.g., `us-east-1`) for automatic endpoint resolution across all AWS partitions (aws, aws-cn, aws-us-gov). When using a full endpoint URL instead, you must also configure `AWS_REGION` via environment variable or AWS config/profile.
|
||||
>
|
||||
> \*\* Azure OpenAI uses `api_key` when set. If `api_key` is omitted, the provider falls back to Microsoft Entra ID via `DefaultAzureCredential` (env vars, workload identity, managed identity, Azure CLI, etc.). The Entra ID path requires build tag: `go build -tags azidentity`.
|
||||
|
||||
<details>
|
||||
<summary><b>Local deployment (Ollama, vLLM, etc.)</b></summary>
|
||||
@@ -497,6 +511,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1500/month (daily allocation) | AI-powered, China-optimized |
|
||||
| [Tavily](https://tavily.com) | Required | 1000 queries/month | Optimized for AI Agents |
|
||||
| [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private |
|
||||
| [Kagi Search](https://help.kagi.com/kagi/api/search.html) | Required | Paid/limited by API setup | Premium search results |
|
||||
| [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search |
|
||||
| [SearXNG](https://github.com/searxng/searxng) | Not needed | Self-hosted | Free metasearch engine |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | Required | Varies | Zhipu web search |
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 327 KiB After Width: | Height: | Size: 261 KiB |
@@ -56,12 +56,23 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
|
||||
|
||||
// Print agent startup info (only for interactive mode)
|
||||
startupInfo := agentLoop.GetStartupInfo()
|
||||
logger.InfoCF("agent", "Agent initialized",
|
||||
map[string]any{
|
||||
"tools_count": startupInfo["tools"].(map[string]any)["count"],
|
||||
"skills_total": startupInfo["skills"].(map[string]any)["total"],
|
||||
"skills_available": startupInfo["skills"].(map[string]any)["available"],
|
||||
})
|
||||
toolsInfo, ok := startupInfo["tools"].(map[string]any)
|
||||
if !ok {
|
||||
toolsInfo = nil
|
||||
}
|
||||
skillsInfo, ok := startupInfo["skills"].(map[string]any)
|
||||
if !ok {
|
||||
skillsInfo = nil
|
||||
}
|
||||
logFields := map[string]any{}
|
||||
if toolsInfo != nil {
|
||||
logFields["tools_count"] = toolsInfo["count"]
|
||||
}
|
||||
if skillsInfo != nil {
|
||||
logFields["skills_total"] = skillsInfo["total"]
|
||||
logFields["skills_available"] = skillsInfo["available"]
|
||||
}
|
||||
logger.InfoCF("agent", "Agent initialized", logFields)
|
||||
|
||||
if message != "" {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -79,7 +79,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, target string)
|
||||
defer cancel()
|
||||
|
||||
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
|
||||
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
|
||||
return fmt.Errorf("\u2717 failed to create skills directory: %w", err)
|
||||
}
|
||||
|
||||
result, err := registry.DownloadAndInstall(ctx, target, "", targetDir)
|
||||
@@ -345,9 +345,11 @@ func copyDirectory(src, dst string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
_, copyErr := io.Copy(dstFile, srcFile)
|
||||
if closeErr := dstFile.Close(); closeErr != nil && copyErr == nil {
|
||||
return fmt.Errorf("close destination file %s: %w", dstPath, closeErr)
|
||||
}
|
||||
return copyErr
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -33,6 +35,49 @@ import (
|
||||
|
||||
var rootNoColor bool
|
||||
|
||||
// initTermuxSSL detects Termux environment and sets SSL_CERT_FILE if not already set.
|
||||
// This fixes X509 certificate errors when running PicoClaw inside Termux or termux-chroot.
|
||||
// See: https://github.com/sipeed/picoclaw/issues/2944
|
||||
func initTermuxSSL() {
|
||||
// Only applicable on Linux/Android
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "android" {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if already set
|
||||
if os.Getenv("SSL_CERT_FILE") != "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for Termux prefix in PATH or HOME
|
||||
home := os.Getenv("HOME")
|
||||
path := os.Getenv("PATH")
|
||||
|
||||
isTermux := strings.Contains(home, "com.termux") ||
|
||||
strings.Contains(path, "com.termux") ||
|
||||
strings.Contains(home, "/data/data/com.termux")
|
||||
|
||||
if !isTermux {
|
||||
return
|
||||
}
|
||||
|
||||
// Check common CA bundle locations in Termux
|
||||
caPaths := []string{
|
||||
"$PREFIX/etc/tls/cert.pem",
|
||||
os.Getenv("PREFIX") + "/etc/tls/cert.pem",
|
||||
"/data/data/com.termux/files/usr/etc/tls/cert.pem",
|
||||
"/usr/etc/tls/cert.pem",
|
||||
}
|
||||
|
||||
for _, caPath := range caPaths {
|
||||
expanded := os.ExpandEnv(caPath)
|
||||
if _, err := os.Stat(expanded); err == nil {
|
||||
os.Setenv("SSL_CERT_FILE", expanded)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncCliUIColor(root *cobra.Command) {
|
||||
no, _ := root.PersistentFlags().GetBool("no-color")
|
||||
cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb")
|
||||
@@ -123,6 +168,9 @@ const (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Initialize Termux SSL certificate detection before anything else
|
||||
initTermuxSSL()
|
||||
|
||||
cliui.Init(earlyColorDisabled())
|
||||
|
||||
if earlyColorDisabled() {
|
||||
|
||||
@@ -329,6 +329,13 @@
|
||||
"base_url": "",
|
||||
"max_results": 0
|
||||
},
|
||||
"kagi": {
|
||||
"enabled": false,
|
||||
"api_key": "",
|
||||
"api_keys": ["YOUR_KAGI_API_KEY"],
|
||||
"base_url": "https://kagi.com/api/v1/search",
|
||||
"max_results": 5
|
||||
},
|
||||
"provider": "auto",
|
||||
"sogou": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Task-oriented guides for setup, configuration, and common PicoClaw workflows.
|
||||
|
||||
- [Docker & Quick Start Guide](docker.md): install and run PicoClaw with Docker or the launcher.
|
||||
- [Android Termux Guide](android-termux.md): run the PicoClaw terminal binary on an ARM64 Android phone.
|
||||
- [Configuration Guide](configuration.md): environment variables, workspace layout, routing, and sandbox settings.
|
||||
- [Session Guide](session-guide.md): how session scope affects memory sharing, summaries, and isolation.
|
||||
- [Routing Guide](routing-guide.md): agent dispatch, session overrides, and light-model routing.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Android Termux Guide
|
||||
|
||||
> Back to [Guides](README.md)
|
||||
|
||||
This guide covers running the PicoClaw terminal binary on an ARM64 Android phone with Termux. Use the APK from [picoclaw.io](https://picoclaw.io/download/) if you want the Android app experience; use Termux when you want a lightweight command-line install on an older or resource-constrained device.
|
||||
|
||||
## Requirements
|
||||
|
||||
- ARM64 Android device. Run `uname -m` in Termux and use this guide when it prints `aarch64`.
|
||||
- Termux installed from [Termux GitHub Releases](https://github.com/termux/termux-app/releases) or F-Droid.
|
||||
- Network access for downloading the release and calling your LLM provider.
|
||||
- An API key for at least one configured model provider.
|
||||
|
||||
## Install PicoClaw
|
||||
|
||||
Open Termux and install the packages used by the release archive and chroot wrapper:
|
||||
|
||||
```bash
|
||||
pkg update
|
||||
pkg install -y wget tar proot
|
||||
```
|
||||
|
||||
Download and unpack the ARM64 Linux release:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/picoclaw
|
||||
cd ~/picoclaw
|
||||
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
|
||||
tar xzf picoclaw_Linux_arm64.tar.gz
|
||||
chmod +x ./picoclaw
|
||||
```
|
||||
|
||||
Start first-run setup through `termux-chroot`, which gives the Linux binary a more standard filesystem layout than a raw Android userspace:
|
||||
|
||||
```bash
|
||||
termux-chroot ./picoclaw onboard
|
||||
```
|
||||
|
||||
## Configure
|
||||
|
||||
Edit the generated config and add at least one model provider API key:
|
||||
|
||||
```bash
|
||||
vim ~/.picoclaw/config.json
|
||||
```
|
||||
|
||||
The default workspace is `~/.picoclaw/workspace`. If you want PicoClaw to read or write Android shared storage, run `termux-setup-storage` first and then point the workspace or any file paths at the mounted storage directory.
|
||||
|
||||
See [Configuration Guide](configuration.md) and [Providers & Model Configuration](providers.md) for the available config fields and provider examples.
|
||||
|
||||
## Run
|
||||
|
||||
Use one-shot agent mode to confirm the installation:
|
||||
|
||||
```bash
|
||||
termux-chroot ./picoclaw agent -m "Hello from Termux"
|
||||
```
|
||||
|
||||
For long-running use, start the gateway:
|
||||
|
||||
```bash
|
||||
termux-chroot ./picoclaw gateway
|
||||
```
|
||||
|
||||
Keep the Termux session open while PicoClaw is running. Android battery optimization can stop background work, so disable battery optimization for Termux if you expect PicoClaw to keep running after the screen locks.
|
||||
|
||||
## Update
|
||||
|
||||
Your config and workspace live under `~/.picoclaw`, so updating the binary does not remove them:
|
||||
|
||||
```bash
|
||||
cd ~/picoclaw
|
||||
rm -f picoclaw_Linux_arm64.tar.gz
|
||||
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
|
||||
tar xzf picoclaw_Linux_arm64.tar.gz
|
||||
chmod +x ./picoclaw
|
||||
termux-chroot ./picoclaw version
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
|---------|-------|
|
||||
| `permission denied` | Run `chmod +x ./picoclaw` after unpacking the archive. |
|
||||
| `not found` after running `./picoclaw` | Confirm `uname -m` prints `aarch64` and that you downloaded `picoclaw_Linux_arm64.tar.gz`. |
|
||||
| Files or paths behave differently than Linux | Run PicoClaw through `termux-chroot` instead of calling the binary directly. |
|
||||
| Provider requests fail | Check the API key and network access in `~/.picoclaw/config.json`. |
|
||||
| PicoClaw stops when the phone sleeps | Disable Android battery optimization for Termux and keep a foreground Termux session active. |
|
||||
@@ -400,6 +400,7 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous
|
||||
|------------|------|---------|-------------|
|
||||
| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace |
|
||||
| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace |
|
||||
| `tools.message.media_enabled` | bool | `false` | Allows the `message` tool to attach local media files by path. This is separate from `tools.send_file.enabled`; enable it only when unified text/media/caption delivery is intended. |
|
||||
|
||||
### Read File Mode
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ Consumer products, routers, and industrial devices that have been tested with Pi
|
||||
|
||||
Any ARM64 Android phone (2015+) with 1GB+ RAM. Install [Termux](https://github.com/termux/termux-app), use `proot` to run PicoClaw.
|
||||
|
||||
> See [README: Run on old Android Phones](../../README.md#-run-on-old-android-phones) for setup instructions.
|
||||
> See the [Android Termux Guide](android-termux.md) for setup instructions.
|
||||
|
||||
### Desktop / Server / Cloud
|
||||
|
||||
|
||||
@@ -26,6 +26,41 @@ picoclaw cron add --name "Daily summary" --message "Summarize today's logs" --cr
|
||||
picoclaw cron add --name "Ping" --message "heartbeat" --every 300 --deliver
|
||||
```
|
||||
|
||||
## Agent Tool Actions
|
||||
|
||||
The agent-facing `cron` tool supports these actions:
|
||||
|
||||
- `add`: create a new job.
|
||||
- `list`: show accessible job names, ids, and schedules.
|
||||
- `get`: fetch one accessible persisted job by `job_id`, including its saved payload.
|
||||
- `update`: partially update one accessible job by `job_id`; omitted fields are preserved.
|
||||
- `remove`, `enable`, `disable`: existing management actions.
|
||||
|
||||
When rescheduling an existing task, use `list -> get -> update`. Do not use
|
||||
`remove -> add` just to change the schedule, because recreating a job can drop
|
||||
the original prompt, delivery target, or command payload.
|
||||
|
||||
Remote channel access is scoped to the current `channel/chat_id`: remote callers
|
||||
can only list, get, or update jobs whose saved `payload.channel` and `payload.to`
|
||||
match the current conversation. Command jobs include a shell command payload, so
|
||||
they can only be listed, inspected, or updated from internal channels.
|
||||
|
||||
Example tool calls:
|
||||
|
||||
```json
|
||||
{"action":"get","job_id":"79095b2f5685a0f2"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"action":"update","job_id":"79095b2f5685a0f2","cron_expr":"30 10 * * *"}
|
||||
```
|
||||
|
||||
`update` accepts `name`, `message`, `command`, and exactly one schedule field
|
||||
(`at_seconds`, `every_seconds`, or `cron_expr`).
|
||||
Omit `command` to preserve it, set `command` to a non-empty string to replace
|
||||
it, or set `command` to `""` to clear it. Command updates require the same
|
||||
internal channel and confirmation gates as command creation.
|
||||
|
||||
## Execution Modes
|
||||
|
||||
Jobs are stored with a message payload and can execute in three stable user-facing modes:
|
||||
|
||||
@@ -126,6 +126,44 @@ Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfa
|
||||
| `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) |
|
||||
| `max_results` | int | 5 | Maximum number of results |
|
||||
|
||||
### Kagi Search
|
||||
|
||||
Kagi Search uses the official Kagi OpenAPI client for `POST /search` and returns normal web results from `data.search`.
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|---------------|----------|---------------------------------------|------------------------------------------------|
|
||||
| `enabled` | bool | false | Enable Kagi Search |
|
||||
| `api_key` | string | - | Kagi API key |
|
||||
| `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) |
|
||||
| `base_url` | string | `https://kagi.com/api/v1/search` | Kagi Search API endpoint |
|
||||
| `max_results` | int | 5 | Maximum number of results |
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"provider": "kagi",
|
||||
"kagi": {
|
||||
"enabled": true,
|
||||
"max_results": 5,
|
||||
"base_url": "https://kagi.com/api/v1/search"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Store Kagi API keys in `.security.yml`:
|
||||
|
||||
```yaml
|
||||
web:
|
||||
kagi:
|
||||
api_keys:
|
||||
- "YOUR_KAGI_API_KEY"
|
||||
```
|
||||
|
||||
Kagi API usage may be billed or limited separately from a normal Kagi subscription, depending on your account and API setup.
|
||||
|
||||
### Tavily
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
@@ -171,6 +209,7 @@ At runtime, the `web_search` tool accepts the following parameters:
|
||||
| `range` | string | no | Optional time filter: `d` (day), `w` (week), `m` (month), `y` (year) |
|
||||
|
||||
If `range` is omitted, PicoClaw performs an unrestricted search.
|
||||
For Kagi, `d`, `w`, and `m` map to Kagi lens `time_relative`; `y` maps to a lens `time_after` date one year before the current day.
|
||||
|
||||
### Example `web_search` Call
|
||||
|
||||
|
||||
@@ -248,13 +248,16 @@ channel_list:
|
||||
|
||||
### Web Tools
|
||||
|
||||
**Brave, Tavily, Perplexity:**
|
||||
**Brave, Tavily, Perplexity, Kagi:**
|
||||
```yaml
|
||||
web:
|
||||
brave:
|
||||
api_keys:
|
||||
- "key-1"
|
||||
- "key-2"
|
||||
kagi:
|
||||
api_keys:
|
||||
- "your-kagi-api-key"
|
||||
```
|
||||
- Use `api_keys` (plural) array format
|
||||
|
||||
@@ -315,16 +318,19 @@ model_list:
|
||||
- **Rate limit management**: Distribute usage across multiple keys
|
||||
- **High availability**: Reduce downtime during API provider issues
|
||||
|
||||
### Web Tools (Brave/Tavily/Perplexity) - Single key
|
||||
### Web Tools (Brave/Tavily/Perplexity/Kagi) - Single key
|
||||
|
||||
```yaml
|
||||
web:
|
||||
brave:
|
||||
api_keys:
|
||||
- "BSA-your-key"
|
||||
kagi:
|
||||
api_keys:
|
||||
- "your-kagi-api-key"
|
||||
```
|
||||
|
||||
### Web Tools (Brave/Tavily/Perplexity) - Multiple keys
|
||||
### Web Tools (Brave/Tavily/Perplexity/Kagi) - Multiple keys
|
||||
|
||||
```yaml
|
||||
web:
|
||||
@@ -332,6 +338,10 @@ web:
|
||||
api_keys:
|
||||
- "BSA-key-1"
|
||||
- "BSA-key-2"
|
||||
kagi:
|
||||
api_keys:
|
||||
- "kagi-key-1"
|
||||
- "kagi-key-2"
|
||||
```
|
||||
|
||||
### Web Tool (GLMSearch/BaiduSearch) - Single key only
|
||||
@@ -558,7 +568,7 @@ go test ./pkg/config -run TestSecurityConfig
|
||||
|
||||
- Ensure you're using `api_keys` (plural) in `.security.yml` for models and web tools (except GLMSearch/BaiduSearch)
|
||||
- Check that the array format is correct in YAML (proper indentation with dashes)
|
||||
- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format)
|
||||
- Remember: Models, Brave, Tavily, Perplexity, Kagi MUST use `api_keys` (array format)
|
||||
- GLMSearch and BaiduSearch MUST use `api_key` (single string format)
|
||||
|
||||
### Load Balancing/Failover Issues
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
module github.com/sipeed/picoclaw
|
||||
|
||||
go 1.25.10
|
||||
go 1.25.11
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.1
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
|
||||
github.com/SevereCloud/vksdk/v3 v3.3.1
|
||||
github.com/adhocore/gronx v1.19.7
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/adhocore/gronx v1.20.0
|
||||
github.com/anthropics/anthropic-sdk-go v1.46.0
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.11
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.53.3
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/caarlos0/env/v11 v11.4.0
|
||||
github.com/caarlos0/env/v11 v11.4.1
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||
@@ -22,8 +24,9 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/h2non/filetype v1.1.3
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.5
|
||||
github.com/line/line-bot-sdk-go/v8 v8.19.0
|
||||
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.9.4
|
||||
github.com/line/line-bot-sdk-go/v8 v8.20.0
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
github.com/minio/selfupdate v0.6.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.5.0
|
||||
@@ -31,7 +34,7 @@ require (
|
||||
github.com/mymmrac/telego v1.9.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/openai/openai-go/v3 v3.22.0
|
||||
github.com/pion/rtp v1.10.1
|
||||
github.com/pion/rtp v1.10.2
|
||||
github.com/pion/webrtc/v3 v3.3.6
|
||||
github.com/pmezard/go-difflib v1.0.0
|
||||
github.com/rs/zerolog v1.35.1
|
||||
@@ -48,18 +51,20 @@ require (
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
maunium.net/go/mautrix v0.27.0
|
||||
modernc.org/sqlite v1.50.1
|
||||
modernc.org/sqlite v1.51.0
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
require (
|
||||
aead.dev/minisign v0.2.0 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
|
||||
@@ -67,9 +72,11 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
|
||||
github.com/aws/smithy-go v1.25.1 // indirect
|
||||
github.com/aws/smithy-go v1.27.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/beeper/argo-go v1.1.2 // indirect
|
||||
github.com/buger/jsonparser v1.1.2 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
@@ -82,8 +89,12 @@ require (
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
@@ -91,13 +102,16 @@ require (
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.mau.fi/libsignal v0.2.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
@@ -135,9 +149,9 @@ require (
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/net v0.54.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.44.0
|
||||
golang.org/x/sys v0.45.0
|
||||
)
|
||||
|
||||
replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532
|
||||
|
||||
@@ -5,40 +5,52 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
|
||||
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg=
|
||||
github.com/SevereCloud/vksdk/v3 v3.3.1/go.mod h1:c6WaA5aocUYsXfkcUbg2qy45V9M1VDcqHHmHIN14NAw=
|
||||
github.com/adhocore/gronx v1.19.7 h1:7hhFwChgDw9eHC3+TQ+OKKBqJnP44oWkDCnnW9nrsuA=
|
||||
github.com/adhocore/gronx v1.19.7/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
||||
github.com/adhocore/gronx v1.20.0 h1:PD13Mo0wekkZ7ZZR9yb1TqeqTfybs7/K3ez9DmjQwEs=
|
||||
github.com/adhocore/gronx v1.20.0/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||
github.com/anthropics/anthropic-sdk-go v1.46.0 h1:yl3n+el5ZfNgiCtQ7zQ7s/NXxB11YbrKXdc3uLPNWlU=
|
||||
github.com/anthropics/anthropic-sdk-go v1.46.0/go.mod h1:bx5vWuHFuGPkELH8Z4KUiNSohFnUwScdpTyr+50myPo=
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.11 h1:9PRf7jyTMEUM6fuNRAJa2mO/skJfrF50rENJwf2LXqw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.11/go.mod h1:iiUX27gOXRuYaoeUVXhUpPwjJHzISfPAjjcuhUbLSVs=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 h1:oRtsqWgxbpeXrOlxOoQStx2M9WNbIkPq4C4Xn1or6bc=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12/go.mod h1:Zg0Oe9qT+9wcezlm1a64wGJp2qZdRElVxo/seJf7jYU=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27 h1:8sPbKi1/KRHwl5oR3qN9mUXestCeHuaRutxylnr/eVY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27/go.mod h1:QV9IVIopJ1dpQUno0f9VYDUwOEjj8u0iEJ4JiZVre3Y=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27 h1:9d8AoASQY9UwrOSmiJ7uSM0MGUPFhnenwSvpaFfat2c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27/go.mod h1:x0rldpsnUQaQIs4Rh+Vwm9Z/0vI6BxadGtsgJfZFb8s=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6 h1:Wbo1WlWyGaAXlr6C7OGXq9avbdJhIV9cQ4M6E34b5x8=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6/go.mod h1:uY1fJe6m3I3w/m8UAkQ89Cm/ZAt/um6LW+AOZU33LDI=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.53.3 h1:HTzzFDJiFSNkZX1Al72+insR4dre/vUeT3YZ4b9h0MA=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.53.3/go.mod h1:dFhfMfXoFrnX6XK/gXDh+4azdybtKll2QnP239wm2O8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
|
||||
@@ -51,20 +63,24 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
|
||||
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
||||
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aws/smithy-go v1.27.0 h1:ZoFioDKJxkSIW2otF9T0aPtNlUwhdVCcuZh/rzH9Hus=
|
||||
github.com/aws/smithy-go v1.27.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
||||
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
||||
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
|
||||
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
|
||||
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
|
||||
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw=
|
||||
github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
@@ -164,6 +180,13 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62 h1:nyUi7Wel3KlVSa5ArgX/snlizqfaxU48qtvXS/JK5GE=
|
||||
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62/go.mod h1:vONkS+clG730HSKOw3nZVa22TjB21r6csKYzYt0a9zI=
|
||||
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
|
||||
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
@@ -179,12 +202,16 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.5 h1:dimv+ZAGia01f4xCDGvCiBHKWMf4K1AB7fGsM+lv5Jw=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.5/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/line/line-bot-sdk-go/v8 v8.19.0 h1:5FD/1SprRZ8Y0FiUI6syYiBewOs0ak2tuUBMYN0wzE4=
|
||||
github.com/line/line-bot-sdk-go/v8 v8.19.0/go.mod h1:AeSRUuu7WGgveGDJb6DyKyFUOst2UB2aF6LO2cQeuXs=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.9.4 h1:oMgcY7NBjJv1QXJqFAfcoN/TbScCkCuRZfbb1mCwZmI=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.9.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/line/line-bot-sdk-go/v8 v8.20.0 h1:Jv22DV3JuQ5qZvniqUbg504bJrVzffXs2CMpyoiuIZU=
|
||||
github.com/line/line-bot-sdk-go/v8 v8.20.0/go.mod h1:QMXJwPka2ysSeVQKWXkBp8DzBFs+CFAXFNo75KJtWho=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -221,10 +248,12 @@ github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VR
|
||||
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/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
|
||||
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||
github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
|
||||
github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
|
||||
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
|
||||
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -253,6 +282,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI=
|
||||
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -293,6 +324,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
||||
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/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
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=
|
||||
@@ -354,8 +387,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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
@@ -384,12 +417,13 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -471,8 +505,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
|
||||
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -350,11 +350,13 @@ func (al *AgentLoop) buildCommandsRuntime(
|
||||
}
|
||||
history := agent.Sessions.GetHistory(opts.SessionKey)
|
||||
return &commands.ContextStats{
|
||||
UsedTokens: usage.UsedTokens,
|
||||
TotalTokens: usage.TotalTokens,
|
||||
CompressAtTokens: usage.CompressAtTokens,
|
||||
UsedPercent: usage.UsedPercent,
|
||||
MessageCount: len(history),
|
||||
UsedTokens: usage.UsedTokens,
|
||||
TotalTokens: usage.TotalTokens,
|
||||
HistoryTokens: usage.HistoryTokens,
|
||||
CompressAtTokens: usage.CompressAtTokens,
|
||||
SummarizeAtTokens: usage.SummarizeAtTokens,
|
||||
UsedPercent: usage.UsedPercent,
|
||||
MessageCount: len(history),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-4
@@ -161,26 +161,58 @@ func registerSharedTools(
|
||||
// Message tool
|
||||
if cfg.Tools.IsToolEnabled("message") {
|
||||
messageTool := tools.NewMessageTool()
|
||||
if cfg.Tools.Message.MediaEnabled {
|
||||
messageTool.ConfigureLocalMedia(
|
||||
agent.Workspace,
|
||||
cfg.Agents.Defaults.RestrictToWorkspace,
|
||||
cfg.Agents.Defaults.GetMaxMediaSize(),
|
||||
allowReadPaths,
|
||||
)
|
||||
}
|
||||
messageTool.SetSendCallback(func(
|
||||
ctx context.Context,
|
||||
channel, chatID, content, replyToMessageID string,
|
||||
mediaParts []bus.MediaPart,
|
||||
) 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{
|
||||
if len(mediaParts) > 0 {
|
||||
outboundMedia := bus.OutboundMediaMessage{
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
Context: outboundCtx,
|
||||
AgentID: outboundAgentID,
|
||||
SessionKey: outboundSessionKey,
|
||||
Scope: outboundScope,
|
||||
Parts: mediaParts,
|
||||
}
|
||||
if al.channelManager != nil && channel != "" {
|
||||
return al.channelManager.SendMedia(ctx, outboundMedia)
|
||||
}
|
||||
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer pubCancel()
|
||||
return msgBus.PublishOutboundMedia(pubCtx, outboundMedia)
|
||||
}
|
||||
outboundMessage := bus.OutboundMessage{
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
Context: outboundCtx,
|
||||
AgentID: outboundAgentID,
|
||||
SessionKey: outboundSessionKey,
|
||||
Scope: outboundScope,
|
||||
Content: content,
|
||||
ReplyToMessageID: replyToMessageID,
|
||||
})
|
||||
}
|
||||
if al.channelManager != nil && channel != "" {
|
||||
return al.channelManager.SendMessage(ctx, outboundMessage)
|
||||
}
|
||||
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer pubCancel()
|
||||
return msgBus.PublishOutbound(pubCtx, outboundMessage)
|
||||
})
|
||||
agent.Tools.Register(messageTool)
|
||||
}
|
||||
|
||||
+46
-1
@@ -377,7 +377,11 @@ func TestPublishResponseIfNeeded_DismissesToolFeedbackWhenMessageToolAlreadySent
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
mt := tools.NewMessageTool()
|
||||
mt.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
|
||||
mt.SetSendCallback(func(
|
||||
ctx context.Context,
|
||||
channel, chatID, content, replyToMessageID string,
|
||||
mediaParts []bus.MediaPart,
|
||||
) error {
|
||||
return nil
|
||||
})
|
||||
defaultAgent.Tools.Register(mt)
|
||||
@@ -605,6 +609,43 @@ func TestProcessMessage_PassesExplicitThinkingOffToProviderWithoutThinkingCapabi
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMessage_PassesDeepSeekThinkingLevelToThinkingCapableProvider(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: t.TempDir(),
|
||||
ModelName: "deepseek-v4-flash",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{{
|
||||
ModelName: "deepseek-v4-flash",
|
||||
Provider: "deepseek",
|
||||
Model: "deepseek-v4-flash",
|
||||
ThinkingLevel: "xhigh",
|
||||
}},
|
||||
}
|
||||
|
||||
provider := &thinkingRecordingProvider{}
|
||||
al := NewAgentLoop(cfg, bus.NewMessageBus(), provider)
|
||||
|
||||
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||
Channel: "pico",
|
||||
ChatID: "chat-1",
|
||||
Content: "hello",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("processMessage() error = %v", err)
|
||||
}
|
||||
if response != "Mock response" {
|
||||
t.Fatalf("processMessage() response = %q, want %q", response, "Mock response")
|
||||
}
|
||||
if got := provider.lastOptions["thinking_level"]; got != "xhigh" {
|
||||
t.Fatalf("thinking_level option = %#v, want %q", got, "xhigh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMessage_SuppressesReasoningWhenThinkingOff(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
@@ -1062,6 +1103,8 @@ func TestProcessMessage_BtwCommandRunsWithoutPersistingHistory(t *testing.T) {
|
||||
defaultAgent.Sessions.SetHistory(sessionKey, initialHistory)
|
||||
defaultAgent.Sessions.SetSummary(sessionKey, "The team decided to keep state request-scoped.")
|
||||
|
||||
initialHistory = defaultAgent.Sessions.GetHistory(sessionKey)
|
||||
|
||||
response, err := al.processMessage(context.Background(), msg)
|
||||
if err != nil {
|
||||
t.Fatalf("processMessage() error = %v", err)
|
||||
@@ -1180,6 +1223,8 @@ func TestProcessMessage_BtwCommandUsesIsolatedProvider(t *testing.T) {
|
||||
}
|
||||
defaultAgent.Sessions.SetHistory(mainSessionKey, initialHistory)
|
||||
|
||||
initialHistory = defaultAgent.Sessions.GetHistory(mainSessionKey)
|
||||
|
||||
// Process a /btw command
|
||||
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||
Channel: "telegram",
|
||||
|
||||
@@ -102,7 +102,13 @@ func NewContextBuilder(workspace string) *ContextBuilder {
|
||||
// Use the skills/ directory under the current working directory
|
||||
builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))
|
||||
if builtinSkillsDir == "" {
|
||||
wd, _ := os.Getwd()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
// os.Getwd failure is extremely rare; fall back to empty
|
||||
// string so that filepath.Join produces a relative "skills"
|
||||
// path, preserving the original lookup behavior.
|
||||
wd = ""
|
||||
}
|
||||
builtinSkillsDir = filepath.Join(wd, "skills")
|
||||
}
|
||||
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
|
||||
|
||||
@@ -115,3 +115,55 @@ func isOverContextBudget(
|
||||
|
||||
return total > contextWindow
|
||||
}
|
||||
|
||||
// trimHistoryToFitContextWindow rebuilds the prompt from progressively newer
|
||||
// history slices until it fits within the context window. Oldest complete turns
|
||||
// are dropped first so tool-call sequences remain intact.
|
||||
func trimHistoryToFitContextWindow(
|
||||
history []providers.Message,
|
||||
build func([]providers.Message) []providers.Message,
|
||||
contextWindow int,
|
||||
toolDefs []providers.ToolDefinition,
|
||||
maxTokens int,
|
||||
) ([]providers.Message, []providers.Message, bool) {
|
||||
messages := build(history)
|
||||
if !isOverContextBudget(contextWindow, messages, toolDefs, maxTokens) {
|
||||
return history, messages, true
|
||||
}
|
||||
|
||||
trimmedHistory := append([]providers.Message(nil), history...)
|
||||
for len(trimmedHistory) > 0 {
|
||||
dropUntil := nextHistoryTrimStart(trimmedHistory)
|
||||
if dropUntil <= 0 || dropUntil >= len(trimmedHistory) {
|
||||
trimmedHistory = nil
|
||||
} else {
|
||||
trimmedHistory = append([]providers.Message(nil), trimmedHistory[dropUntil:]...)
|
||||
}
|
||||
|
||||
messages = build(trimmedHistory)
|
||||
if !isOverContextBudget(contextWindow, messages, toolDefs, maxTokens) {
|
||||
return trimmedHistory, messages, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, messages, false
|
||||
}
|
||||
|
||||
func nextHistoryTrimStart(history []providers.Message) int {
|
||||
if len(history) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
turns := parseTurnBoundaries(history)
|
||||
if len(turns) >= 2 {
|
||||
return turns[1]
|
||||
}
|
||||
if len(turns) == 1 {
|
||||
if turns[0] > 0 {
|
||||
return turns[0]
|
||||
}
|
||||
return len(history)
|
||||
}
|
||||
|
||||
return len(history)
|
||||
}
|
||||
|
||||
@@ -844,3 +844,64 @@ func TestIsOverContextBudget_RealisticSession(t *testing.T) {
|
||||
t.Error("realistic session should exceed 500 context window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimHistoryToFitContextWindow_DropsOldestTurns(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
msgUser(strings.Repeat("u1 ", 120)),
|
||||
msgAssistant(strings.Repeat("a1 ", 120)),
|
||||
msgUser(strings.Repeat("u2 ", 120)),
|
||||
msgAssistant(strings.Repeat("a2 ", 120)),
|
||||
msgUser(strings.Repeat("u3 ", 120)),
|
||||
msgAssistant(strings.Repeat("a3 ", 120)),
|
||||
}
|
||||
|
||||
build := func(history []providers.Message) []providers.Message {
|
||||
return append([]providers.Message(nil), history...)
|
||||
}
|
||||
|
||||
trimmedHistory, messages, fit := trimHistoryToFitContextWindow(
|
||||
history,
|
||||
build,
|
||||
700,
|
||||
nil,
|
||||
0,
|
||||
)
|
||||
if !fit {
|
||||
t.Fatal("expected trimmed history to fit context window")
|
||||
}
|
||||
if len(trimmedHistory) != 4 {
|
||||
t.Fatalf("trimmed history len = %d, want 4", len(trimmedHistory))
|
||||
}
|
||||
if trimmedHistory[0].Content != history[2].Content {
|
||||
t.Fatalf("first kept message = %q, want second turn start", trimmedHistory[0].Content)
|
||||
}
|
||||
if isOverContextBudget(700, messages, nil, 0) {
|
||||
t.Fatal("trimmed messages should be within budget")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimHistoryToFitContextWindow_ClearsSingleOversizedTurn(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
msgUser(strings.Repeat("oversized ", 200)),
|
||||
msgAssistant(strings.Repeat("oversized ", 200)),
|
||||
}
|
||||
|
||||
trimmedHistory, messages, fit := trimHistoryToFitContextWindow(
|
||||
history,
|
||||
func(history []providers.Message) []providers.Message {
|
||||
return append([]providers.Message(nil), history...)
|
||||
},
|
||||
200,
|
||||
nil,
|
||||
0,
|
||||
)
|
||||
if !fit {
|
||||
t.Fatal("expected empty history rebuild to fit context window")
|
||||
}
|
||||
if len(trimmedHistory) != 0 {
|
||||
t.Fatalf("trimmed history len = %d, want 0", len(trimmedHistory))
|
||||
}
|
||||
if len(messages) != 0 {
|
||||
t.Fatalf("messages len = %d, want 0", len(messages))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
@@ -200,6 +201,7 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
|
||||
ModelName: msg.ModelName,
|
||||
ReasoningContent: msg.ReasoningContent,
|
||||
TokenCount: tokenizer.EstimateMessageTokens(msg),
|
||||
CreatedAt: normalizeSeahorseMessageCreatedAt(msg.CreatedAt),
|
||||
}
|
||||
|
||||
// Convert ToolCalls → MessageParts
|
||||
@@ -235,6 +237,13 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeSeahorseMessageCreatedAt(createdAt *time.Time) time.Time {
|
||||
if createdAt == nil || createdAt.IsZero() {
|
||||
return time.Time{}
|
||||
}
|
||||
return createdAt.UTC().Truncate(time.Second)
|
||||
}
|
||||
|
||||
// seahorseToProviderMessages converts a seahorse.AssembleResult to []providers.Message.
|
||||
func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes.Message {
|
||||
messages := make([]protocoltypes.Message, 0, len(result.Messages))
|
||||
|
||||
@@ -171,11 +171,13 @@ func TestProviderToSeahorseMessageWithMedia(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProviderToSeahorseMessageWithReasoning(t *testing.T) {
|
||||
createdAt := time.Date(2026, 5, 6, 7, 8, 9, 123000000, time.UTC)
|
||||
msg := protocoltypes.Message{
|
||||
Role: "assistant",
|
||||
Content: "response text",
|
||||
ModelName: "gpt-5.4-mini",
|
||||
ReasoningContent: "I thought about this carefully",
|
||||
CreatedAt: &createdAt,
|
||||
}
|
||||
|
||||
result := providerToSeahorseMessage(msg)
|
||||
@@ -185,6 +187,9 @@ func TestProviderToSeahorseMessageWithReasoning(t *testing.T) {
|
||||
if result.ModelName != "gpt-5.4-mini" {
|
||||
t.Errorf("ModelName = %q, want %q", result.ModelName, "gpt-5.4-mini")
|
||||
}
|
||||
if !result.CreatedAt.Equal(time.Date(2026, 5, 6, 7, 8, 9, 0, time.UTC)) {
|
||||
t.Errorf("CreatedAt = %v, want 2026-05-06 07:08:09 UTC", result.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeahorseToProviderMessagesWithReasoning(t *testing.T) {
|
||||
@@ -288,6 +293,74 @@ func TestSeahorseToProviderMessagesWithToolCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeahorseAssemblePreservesActiveToolTurnAcrossSanitization(t *testing.T) {
|
||||
engine, err := seahorse.NewEngine(seahorse.Config{
|
||||
DBPath: t.TempDir() + "/seahorse.db",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEngine: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
sessionKey := "test:active-tool-turn"
|
||||
_, err = engine.Ingest(ctx, sessionKey, []seahorse.Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "older context",
|
||||
TokenCount: 20,
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: "inspect the file",
|
||||
TokenCount: 5,
|
||||
},
|
||||
{
|
||||
Role: "assistant",
|
||||
TokenCount: 5,
|
||||
Parts: []seahorse.MessagePart{{
|
||||
Type: "tool_use",
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"/tmp/test.txt"}`,
|
||||
ToolCallID: "tc_1",
|
||||
}},
|
||||
},
|
||||
{
|
||||
Role: "tool",
|
||||
TokenCount: 200,
|
||||
Parts: []seahorse.MessagePart{{
|
||||
Type: "tool_result",
|
||||
ToolCallID: "tc_1",
|
||||
Text: "very large tool output",
|
||||
}},
|
||||
},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "done",
|
||||
TokenCount: 5,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
|
||||
result, err := engine.Assemble(ctx, sessionKey, seahorse.AssembleInput{Budget: 210})
|
||||
if err != nil {
|
||||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
|
||||
sanitized := sanitizeHistoryForProvider(seahorseToProviderMessages(result))
|
||||
if len(sanitized) != 4 {
|
||||
t.Fatalf("sanitized history len = %d, want 4 protected-turn messages", len(sanitized))
|
||||
}
|
||||
assertRoles(t, sanitized, "user", "assistant", "tool", "assistant")
|
||||
if len(sanitized[1].ToolCalls) != 1 || sanitized[1].ToolCalls[0].ID != "tc_1" {
|
||||
t.Fatalf("assistant tool calls = %+v, want preserved tool call tc_1", sanitized[1].ToolCalls)
|
||||
}
|
||||
if sanitized[2].ToolCallID != "tc_1" {
|
||||
t.Fatalf("tool result id = %q, want tc_1", sanitized[2].ToolCallID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeahorseToProviderMessagesToolResult(t *testing.T) {
|
||||
msg := seahorse.Message{
|
||||
Role: "tool",
|
||||
|
||||
@@ -61,6 +61,17 @@ func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUs
|
||||
// proactive trigger (msgTokens + toolTokens + maxTokens > contextWindow).
|
||||
compressAt := effectiveWindow
|
||||
|
||||
// summarizeAt = soft summarization trigger: matches maybeSummarize's
|
||||
// threshold (contextWindow * SummarizeTokenPercent / 100).
|
||||
//
|
||||
// The engine compares this against history-message tokens ONLY (not
|
||||
// UsedTokens). HistoryTokens is exposed alongside UsedTokens so the
|
||||
// UI can show both values and avoid user confusion.
|
||||
summarizeAt := contextWindow * agent.SummarizeTokenPercent / 100
|
||||
if summarizeAt <= 0 {
|
||||
summarizeAt = compressAt
|
||||
}
|
||||
|
||||
usedPercent := 0
|
||||
if compressAt > 0 {
|
||||
usedPercent = usedTokens * 100 / compressAt
|
||||
@@ -70,9 +81,11 @@ func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUs
|
||||
}
|
||||
|
||||
return &bus.ContextUsage{
|
||||
UsedTokens: usedTokens,
|
||||
TotalTokens: contextWindow,
|
||||
CompressAtTokens: compressAt,
|
||||
UsedPercent: usedPercent,
|
||||
UsedTokens: usedTokens,
|
||||
TotalTokens: contextWindow,
|
||||
HistoryTokens: historyTokens,
|
||||
CompressAtTokens: compressAt,
|
||||
SummarizeAtTokens: summarizeAt,
|
||||
UsedPercent: usedPercent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -414,7 +413,10 @@ func compilePatterns(patterns []string) []*regexp.Regexp {
|
||||
for _, p := range patterns {
|
||||
re, err := regexp.Compile(p)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: invalid path pattern %q: %v\n", p, err)
|
||||
logger.WarnCF("agent", "invalid path pattern in compilePatterns", map[string]any{
|
||||
"pattern": p,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
compiled = append(compiled, re)
|
||||
|
||||
@@ -2,11 +2,13 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
const defaultEventSubscriberBuffer = 16
|
||||
@@ -88,7 +90,14 @@ func (al *AgentLoop) UnsubscribeEvents(id uint64) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sub := value.(legacyEventSubscription)
|
||||
sub, ok := value.(legacyEventSubscription)
|
||||
if !ok {
|
||||
logger.WarnCF("agent", "UnsubscribeEvents: unexpected type in subscription map", map[string]any{
|
||||
"id": id,
|
||||
"type": fmt.Sprintf("%T", value),
|
||||
})
|
||||
return
|
||||
}
|
||||
sub.cancel()
|
||||
if sub.sub != nil {
|
||||
_ = sub.sub.Close()
|
||||
|
||||
+103
-52
@@ -280,22 +280,8 @@ func (p *Pipeline) CallLLM(
|
||||
}
|
||||
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
isTimeoutError := errors.Is(err, context.DeadlineExceeded) ||
|
||||
strings.Contains(errMsg, "deadline exceeded") ||
|
||||
strings.Contains(errMsg, "client.timeout") ||
|
||||
strings.Contains(errMsg, "timed out") ||
|
||||
strings.Contains(errMsg, "timeout exceeded")
|
||||
|
||||
isNetworkError := !isTimeoutError && (strings.Contains(errMsg, "connection reset") ||
|
||||
strings.Contains(errMsg, "connection refused") ||
|
||||
strings.Contains(errMsg, "broken pipe") ||
|
||||
strings.Contains(errMsg, "no such host") ||
|
||||
strings.Contains(errMsg, "network is unreachable") ||
|
||||
strings.Contains(errMsg, "read tcp") ||
|
||||
strings.Contains(errMsg, "write tcp") ||
|
||||
strings.Contains(errMsg, "eof"))
|
||||
|
||||
isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") ||
|
||||
retryReason, isTransientError := transientLLMRetryReason(err)
|
||||
isContextError := !isTransientError && (strings.Contains(errMsg, "context_length_exceeded") ||
|
||||
strings.Contains(errMsg, "context window") ||
|
||||
strings.Contains(errMsg, "context_window") ||
|
||||
strings.Contains(errMsg, "maximum context length") ||
|
||||
@@ -306,7 +292,7 @@ func (p *Pipeline) CallLLM(
|
||||
strings.Contains(errMsg, "prompt is too long") ||
|
||||
strings.Contains(errMsg, "request too large"))
|
||||
|
||||
if isTimeoutError && retry < maxRetries {
|
||||
if isTransientError && retry < maxRetries {
|
||||
backoff := time.Duration(retry+1) * time.Duration(backoffSecs) * time.Second
|
||||
al.emitEvent(
|
||||
runtimeevents.KindAgentLLMRetry,
|
||||
@@ -314,42 +300,14 @@ func (p *Pipeline) CallLLM(
|
||||
LLMRetryPayload{
|
||||
Attempt: retry + 1,
|
||||
MaxRetries: maxRetries,
|
||||
Reason: "timeout",
|
||||
Reason: retryReason,
|
||||
Error: err.Error(),
|
||||
Backoff: backoff,
|
||||
},
|
||||
)
|
||||
logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{
|
||||
"error": err.Error(),
|
||||
"retry": retry,
|
||||
"backoff": backoff.String(),
|
||||
})
|
||||
if sleepErr := sleepWithContext(turnCtx, backoff); sleepErr != nil {
|
||||
if ts.hardAbortRequested() {
|
||||
_ = ts.requestHardAbort()
|
||||
return ControlBreak, nil
|
||||
}
|
||||
err = sleepErr
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if isNetworkError && retry < maxRetries {
|
||||
backoff := time.Duration(retry+1) * time.Duration(backoffSecs) * time.Second
|
||||
al.emitEvent(
|
||||
runtimeevents.KindAgentLLMRetry,
|
||||
ts.eventMeta("runTurn", "turn.llm.retry"),
|
||||
LLMRetryPayload{
|
||||
Attempt: retry + 1,
|
||||
MaxRetries: maxRetries,
|
||||
Reason: "network",
|
||||
Error: err.Error(),
|
||||
Backoff: backoff,
|
||||
},
|
||||
)
|
||||
logger.WarnCF("agent", "Network error, retrying after backoff", map[string]any{
|
||||
logger.WarnCF("agent", "Transient LLM error, retrying after backoff", map[string]any{
|
||||
"error": err.Error(),
|
||||
"reason": retryReason,
|
||||
"retry": retry,
|
||||
"backoff": backoff.String(),
|
||||
})
|
||||
@@ -415,14 +373,65 @@ func (p *Pipeline) CallLLM(
|
||||
contextualSkills = ts.agent.ContextBuilder.ResolveActiveSkillsForContext(ts.activeSkills)
|
||||
}
|
||||
ts.recordSkillContextSnapshot(skillContextTriggerContextRetryRebuild, contextualSkills)
|
||||
rebuildPromptReq := promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil, p.Cfg)
|
||||
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
||||
exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
|
||||
exec.callMessages = exec.messages
|
||||
stableHistory, protectedTurnTail := splitHistoryForActiveTurn(
|
||||
exec.history,
|
||||
ts.persistedMessagesSnapshot(),
|
||||
)
|
||||
buildMessages := func(trimmedHistory []providers.Message) []providers.Message {
|
||||
fullHistory := append(append([]providers.Message(nil), trimmedHistory...), protectedTurnTail...)
|
||||
rebuildPromptReq := promptBuildRequestForTurn(ts, fullHistory, exec.summary, "", nil, p.Cfg)
|
||||
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
||||
return ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
|
||||
}
|
||||
originalHistoryCount := len(exec.history)
|
||||
var fit bool
|
||||
var trimmedStableHistory []providers.Message
|
||||
trimmedStableHistory, exec.callMessages, fit = trimHistoryToFitContextWindow(
|
||||
stableHistory,
|
||||
func(trimmedHistory []providers.Message) []providers.Message {
|
||||
rebuilt := buildMessages(trimmedHistory)
|
||||
if exec.gracefulTerminal {
|
||||
return append(append([]providers.Message(nil), rebuilt...), ts.interruptHintMessage())
|
||||
}
|
||||
return rebuilt
|
||||
},
|
||||
ts.agent.ContextWindow,
|
||||
exec.providerToolDefs,
|
||||
ts.agent.MaxTokens,
|
||||
)
|
||||
exec.history = append(trimmedStableHistory, protectedTurnTail...)
|
||||
exec.messages = buildMessages(trimmedStableHistory)
|
||||
if exec.gracefulTerminal {
|
||||
msgs := append([]providers.Message(nil), exec.messages...)
|
||||
exec.callMessages = append(msgs, ts.interruptHintMessage())
|
||||
}
|
||||
if dropped := originalHistoryCount - len(exec.history); dropped > 0 {
|
||||
logger.WarnCF("agent", "Trimmed rebuilt history after context retry compaction", map[string]any{
|
||||
"session_key": ts.sessionKey,
|
||||
"retry": retry,
|
||||
"dropped_msgs": dropped,
|
||||
"remaining_msgs": len(exec.history),
|
||||
"context_window": ts.agent.ContextWindow,
|
||||
"max_tokens": ts.agent.MaxTokens,
|
||||
"still_overlimit": !fit,
|
||||
})
|
||||
} else if !fit {
|
||||
logger.WarnCF("agent", "Context still exceeds budget after retry compaction rebuild", map[string]any{
|
||||
"session_key": ts.sessionKey,
|
||||
"retry": retry,
|
||||
"history_msgs": len(exec.history),
|
||||
"protected_turn_msgs": len(protectedTurnTail),
|
||||
"context_window": ts.agent.ContextWindow,
|
||||
"max_tokens": ts.agent.MaxTokens,
|
||||
})
|
||||
}
|
||||
if !fit {
|
||||
err = fmt.Errorf(
|
||||
"context window still exceeded after retry compaction; refusing to drop active turn messages: %w",
|
||||
err,
|
||||
)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
break
|
||||
@@ -684,3 +693,45 @@ func providerForFallbackCandidate(
|
||||
}
|
||||
return activeProvider, nil
|
||||
}
|
||||
|
||||
func transientLLMRetryReason(err error) (string, bool) {
|
||||
if err == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if failErr := providers.ClassifyError(err, "", ""); failErr != nil {
|
||||
switch failErr.Reason {
|
||||
case providers.FailoverTimeout:
|
||||
if failErr.Status >= 500 {
|
||||
return "server_error", true
|
||||
}
|
||||
return "timeout", true
|
||||
case providers.FailoverNetwork:
|
||||
return "network", true
|
||||
case providers.FailoverRateLimit, providers.FailoverOverloaded:
|
||||
return "rate_limit", true
|
||||
}
|
||||
}
|
||||
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
if errors.Is(err, context.DeadlineExceeded) ||
|
||||
strings.Contains(errMsg, "deadline exceeded") ||
|
||||
strings.Contains(errMsg, "client.timeout") ||
|
||||
strings.Contains(errMsg, "timed out") ||
|
||||
strings.Contains(errMsg, "timeout exceeded") {
|
||||
return "timeout", true
|
||||
}
|
||||
|
||||
if strings.Contains(errMsg, "connection reset") ||
|
||||
strings.Contains(errMsg, "connection refused") ||
|
||||
strings.Contains(errMsg, "broken pipe") ||
|
||||
strings.Contains(errMsg, "no such host") ||
|
||||
strings.Contains(errMsg, "network is unreachable") ||
|
||||
strings.Contains(errMsg, "read tcp") ||
|
||||
strings.Contains(errMsg, "write tcp") ||
|
||||
strings.Contains(errMsg, "eof") {
|
||||
return "network", true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -66,10 +66,45 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
||||
history = resp.History
|
||||
summary = resp.Summary
|
||||
}
|
||||
rebuildPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media, cfg)
|
||||
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
||||
messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
|
||||
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
|
||||
originalHistoryCount := len(history)
|
||||
var fit bool
|
||||
history, messages, fit = trimHistoryToFitContextWindow(
|
||||
history,
|
||||
func(trimmedHistory []providers.Message) []providers.Message {
|
||||
rebuildPromptReq := promptBuildRequestForTurn(
|
||||
ts,
|
||||
trimmedHistory,
|
||||
summary,
|
||||
ts.userMessage,
|
||||
ts.media,
|
||||
cfg,
|
||||
)
|
||||
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
||||
rebuilt := ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
|
||||
return resolveMediaRefs(rebuilt, p.MediaStore, maxMediaSize)
|
||||
},
|
||||
ts.agent.ContextWindow,
|
||||
toolDefs,
|
||||
ts.agent.MaxTokens,
|
||||
)
|
||||
if dropped := originalHistoryCount - len(history); dropped > 0 {
|
||||
logger.WarnCF("agent", "Trimmed rebuilt history after proactive compaction", map[string]any{
|
||||
"session_key": ts.sessionKey,
|
||||
"dropped_msgs": dropped,
|
||||
"remaining_msgs": len(history),
|
||||
"context_window": ts.agent.ContextWindow,
|
||||
"max_tokens": ts.agent.MaxTokens,
|
||||
"still_overlimit": !fit,
|
||||
})
|
||||
} else if !fit {
|
||||
logger.WarnCF("agent", "Context still exceeds budget "+
|
||||
"after proactive compaction rebuild", map[string]any{
|
||||
"session_key": ts.sessionKey,
|
||||
"history_msgs": len(history),
|
||||
"context_window": ts.agent.ContextWindow,
|
||||
"max_tokens": ts.agent.MaxTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1618,6 +1618,8 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) {
|
||||
}
|
||||
defaultAgent.Sessions.SetHistory(sessionKey, originalHistory)
|
||||
|
||||
originalHistory = defaultAgent.Sessions.GetHistory(sessionKey)
|
||||
|
||||
runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest(
|
||||
t,
|
||||
al,
|
||||
|
||||
@@ -193,6 +193,38 @@ func (p *errorProvider) GetDefaultModel() string {
|
||||
return "error-model"
|
||||
}
|
||||
|
||||
type failOnceLLMProvider struct {
|
||||
err error
|
||||
response string
|
||||
callCount int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (p *failOnceLLMProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
p.mu.Lock()
|
||||
p.callCount++
|
||||
callCount := p.callCount
|
||||
p.mu.Unlock()
|
||||
|
||||
if callCount == 1 {
|
||||
return nil, p.err
|
||||
}
|
||||
return &providers.LLMResponse{
|
||||
Content: p.response,
|
||||
FinishReason: "stop",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *failOnceLLMProvider) GetDefaultModel() string {
|
||||
return "fail-once-model"
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Test Helper Functions
|
||||
// =============================================================================
|
||||
@@ -586,6 +618,59 @@ func TestPipeline_CallLLM_TimeoutRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipeline_CallLLM_HTTP5xxRetry(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
provider := &failOnceLLMProvider{
|
||||
err: errors.New("API request failed:\n Status: 500\n Body: internal server error"),
|
||||
response: "Recovered from server error",
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
MaxLLMRetries: 1,
|
||||
LLMRetryBackoffSecs: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
defer al.Close()
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
if agent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
|
||||
pipeline := NewPipeline(al)
|
||||
ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{
|
||||
turnID: "turn-1",
|
||||
context: newTurnContext(nil, nil, nil),
|
||||
})
|
||||
|
||||
exec, err := pipeline.SetupTurn(context.Background(), ts)
|
||||
if err != nil {
|
||||
t.Fatalf("SetupTurn failed: %v", err)
|
||||
}
|
||||
|
||||
ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("expected HTTP 500 retry to recover, got error: %v", err)
|
||||
}
|
||||
if ctrl != ControlBreak {
|
||||
t.Fatalf("expected ControlBreak, got %v", ctrl)
|
||||
}
|
||||
if exec.finalContent != "Recovered from server error" {
|
||||
t.Fatalf("finalContent = %q, want recovered response", exec.finalContent)
|
||||
}
|
||||
if provider.callCount != 2 {
|
||||
t.Fatalf("callCount = %d, want 2", provider.callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipeline_CallLLM_ContextLengthError(t *testing.T) {
|
||||
errorPrv := &errorProvider{errType: "context_length"}
|
||||
al, agent, cleanup := newTurnCoordTestLoop(t, errorPrv)
|
||||
|
||||
@@ -105,9 +105,10 @@ func TestTurnProfile_DisabledPreservesDefaultHistoryAndPrompt(t *testing.T) {
|
||||
al := newTurnProfileAgentLoop(t, cfg, provider)
|
||||
agent := al.GetRegistry().GetDefaultAgent()
|
||||
sessionKey := "agent:default:test-default"
|
||||
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
initialHistory := []providers.Message{
|
||||
{Role: "user", Content: "old user"},
|
||||
{Role: "assistant", Content: "old assistant"},
|
||||
{Role: "user", Content: "old user", CreatedAt: &ts},
|
||||
{Role: "assistant", Content: "old assistant", CreatedAt: &ts},
|
||||
}
|
||||
agent.Sessions.SetHistory(sessionKey, initialHistory)
|
||||
agent.Sessions.SetSummary(sessionKey, "old summary")
|
||||
@@ -154,9 +155,10 @@ func TestTurnProfile_HistoryOffSuppressesHistoryAndPersistence(t *testing.T) {
|
||||
al := newTurnProfileAgentLoop(t, cfg, provider)
|
||||
agent := al.GetRegistry().GetDefaultAgent()
|
||||
sessionKey := "agent:default:test-history-off"
|
||||
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
initialHistory := []providers.Message{
|
||||
{Role: "user", Content: "old user"},
|
||||
{Role: "assistant", Content: "old assistant"},
|
||||
{Role: "user", Content: "old user", CreatedAt: &ts},
|
||||
{Role: "assistant", Content: "old assistant", CreatedAt: &ts},
|
||||
}
|
||||
agent.Sessions.SetHistory(sessionKey, initialHistory)
|
||||
agent.Sessions.SetSummary(sessionKey, "old summary")
|
||||
|
||||
+78
-4
@@ -643,13 +643,17 @@ func (ts *turnState) recordPersistedMessage(msg providers.Message) {
|
||||
ts.persistedMessages = append(ts.persistedMessages, msg)
|
||||
}
|
||||
|
||||
func (ts *turnState) persistedMessagesSnapshot() []providers.Message {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
return append([]providers.Message(nil), ts.persistedMessages...)
|
||||
}
|
||||
|
||||
func (ts *turnState) refreshRestorePointFromSession(agent *AgentInstance) {
|
||||
history := agent.Sessions.GetHistory(ts.sessionKey)
|
||||
summary := agent.Sessions.GetSummary(ts.sessionKey)
|
||||
|
||||
ts.mu.RLock()
|
||||
persisted := append([]providers.Message(nil), ts.persistedMessages...)
|
||||
ts.mu.RUnlock()
|
||||
persisted := ts.persistedMessagesSnapshot()
|
||||
|
||||
if matched := matchingTurnMessageTail(history, persisted); matched > 0 {
|
||||
history = append([]providers.Message(nil), history[:len(history)-matched]...)
|
||||
@@ -689,13 +693,83 @@ func (ts *turnState) restoreSession(agent *AgentInstance) error {
|
||||
func matchingTurnMessageTail(history, persisted []providers.Message) int {
|
||||
maxMatch := min(len(history), len(persisted))
|
||||
for size := maxMatch; size > 0; size-- {
|
||||
if reflect.DeepEqual(history[len(history)-size:], persisted[len(persisted)-size:]) {
|
||||
if messageSlicesEquivalent(history[len(history)-size:], persisted[len(persisted)-size:]) {
|
||||
return size
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func splitHistoryForActiveTurn(
|
||||
history []providers.Message,
|
||||
persisted []providers.Message,
|
||||
) ([]providers.Message, []providers.Message) {
|
||||
matched := matchingTurnMessageTail(history, persisted)
|
||||
if matched <= 0 {
|
||||
return append([]providers.Message(nil), history...), nil
|
||||
}
|
||||
|
||||
stable := append([]providers.Message(nil), history[:len(history)-matched]...)
|
||||
protected := append([]providers.Message(nil), history[len(history)-matched:]...)
|
||||
return stable, protected
|
||||
}
|
||||
|
||||
func messageSlicesEquivalent(a, b []providers.Message) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if !messagesEquivalent(a[i], b[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func messagesEquivalent(a, b providers.Message) bool {
|
||||
return reflect.DeepEqual(normalizeMessageForComparison(a), normalizeMessageForComparison(b))
|
||||
}
|
||||
|
||||
func normalizeMessageForComparison(msg providers.Message) providers.Message {
|
||||
msg.PromptLayer = ""
|
||||
msg.PromptSlot = ""
|
||||
msg.PromptSource = ""
|
||||
|
||||
if len(msg.Media) == 0 {
|
||||
msg.Media = nil
|
||||
}
|
||||
if len(msg.Attachments) == 0 {
|
||||
msg.Attachments = nil
|
||||
}
|
||||
if len(msg.SystemParts) == 0 {
|
||||
msg.SystemParts = nil
|
||||
} else {
|
||||
msg.SystemParts = append([]providers.ContentBlock(nil), msg.SystemParts...)
|
||||
for i := range msg.SystemParts {
|
||||
msg.SystemParts[i].PromptLayer = ""
|
||||
msg.SystemParts[i].PromptSlot = ""
|
||||
msg.SystemParts[i].PromptSource = ""
|
||||
}
|
||||
}
|
||||
if len(msg.ToolCalls) == 0 {
|
||||
msg.ToolCalls = nil
|
||||
} else {
|
||||
msg.ToolCalls = append([]providers.ToolCall(nil), msg.ToolCalls...)
|
||||
for i := range msg.ToolCalls {
|
||||
msg.ToolCalls[i].Name = ""
|
||||
msg.ToolCalls[i].Arguments = nil
|
||||
msg.ToolCalls[i].ThoughtSignature = ""
|
||||
if msg.ToolCalls[i].Function != nil {
|
||||
fn := *msg.ToolCalls[i].Function
|
||||
fn.ThoughtSignature = ""
|
||||
msg.ToolCalls[i].Function = &fn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
func (ts *turnState) interruptHintMessage() providers.Message {
|
||||
_, hint := ts.gracefulInterruptRequested()
|
||||
content := "Interrupt requested. Stop scheduling tools and provide a short final summary."
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func TestMatchingTurnMessageTail_IgnoresInternalRuntimeFields(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "question"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"/tmp/test"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
persisted := []providers.Message{
|
||||
userPromptMessage("question", nil),
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Name: "read_file",
|
||||
Arguments: map[string]any{"path": "/tmp/test"},
|
||||
ThoughtSignature: "internal-signature",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"/tmp/test"}`,
|
||||
ThoughtSignature: "internal-signature",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if got := matchingTurnMessageTail(history, persisted); got != 2 {
|
||||
t.Fatalf("matchingTurnMessageTail() = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitHistoryForActiveTurn_ProtectsPersistedTail(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "old question"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
{Role: "user", Content: "current question"},
|
||||
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
|
||||
}
|
||||
|
||||
persisted := []providers.Message{
|
||||
userPromptMessage("current question", nil),
|
||||
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
|
||||
}
|
||||
|
||||
stable, protected := splitHistoryForActiveTurn(history, persisted)
|
||||
if len(stable) != 2 {
|
||||
t.Fatalf("stable history len = %d, want 2", len(stable))
|
||||
}
|
||||
if len(protected) != 2 {
|
||||
t.Fatalf("protected tail len = %d, want 2", len(protected))
|
||||
}
|
||||
if protected[0].Content != "current question" {
|
||||
t.Fatalf("protected[0].Content = %q, want current question", protected[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimHistoryToFitContextWindow_WithProtectedTurnTailKeepsActiveTurn(t *testing.T) {
|
||||
current := strings.Repeat("current turn ", 80)
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: strings.Repeat("old turn ", 60)},
|
||||
{Role: "assistant", Content: strings.Repeat("old reply ", 60)},
|
||||
{Role: "user", Content: current},
|
||||
}
|
||||
|
||||
stable, protected := splitHistoryForActiveTurn(history, []providers.Message{
|
||||
userPromptMessage(current, nil),
|
||||
})
|
||||
trimmedStable, messages, fit := trimHistoryToFitContextWindow(
|
||||
stable,
|
||||
func(trimmedHistory []providers.Message) []providers.Message {
|
||||
return append(append([]providers.Message(nil), trimmedHistory...), protected...)
|
||||
},
|
||||
120,
|
||||
nil,
|
||||
0,
|
||||
)
|
||||
|
||||
if fit {
|
||||
t.Fatal("expected protected active turn alone to remain over budget")
|
||||
}
|
||||
if len(trimmedStable) != 0 {
|
||||
t.Fatalf("trimmed stable history len = %d, want 0", len(trimmedStable))
|
||||
}
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("messages len = %d, want 1 protected active-turn message", len(messages))
|
||||
}
|
||||
if messages[0].Content != current {
|
||||
t.Fatalf("messages[0].Content = %q, want protected current turn", messages[0].Content)
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -64,10 +64,12 @@ type OutboundScope struct {
|
||||
// ContextUsage describes how much of the model's context window the current
|
||||
// session consumes, and how far it is from triggering compression.
|
||||
type ContextUsage struct {
|
||||
UsedTokens int `json:"used_tokens"`
|
||||
TotalTokens int `json:"total_tokens"` // model context window
|
||||
CompressAtTokens int `json:"compress_at_tokens"` // threshold that triggers compression
|
||||
UsedPercent int `json:"used_percent"` // 0-100
|
||||
UsedTokens int `json:"used_tokens"`
|
||||
TotalTokens int `json:"total_tokens"` // model context window
|
||||
HistoryTokens int `json:"history_tokens"` // history-message tokens only (what maybeSummarize checks)
|
||||
CompressAtTokens int `json:"compress_at_tokens"` // hard budget compression threshold (contextWindow - maxTokens)
|
||||
SummarizeAtTokens int `json:"summarize_at_tokens"` // soft summarization trigger (vs history tokens)
|
||||
UsedPercent int `json:"used_percent"` // 0-100, relative to compressAt
|
||||
}
|
||||
|
||||
type OutboundMessage struct {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -605,10 +606,11 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||
scope := channels.BuildMediaScope("discord", m.ChannelID, m.ID)
|
||||
|
||||
// Helper to register a local file with the media store
|
||||
storeMedia := func(localPath, filename string) string {
|
||||
storeMedia := func(localPath string, attachment *discordgo.MessageAttachment) string {
|
||||
if store := c.GetMediaStore(); store != nil {
|
||||
ref, err := store.Store(localPath, media.MediaMeta{
|
||||
Filename: filename,
|
||||
Filename: attachment.Filename,
|
||||
ContentType: attachment.ContentType,
|
||||
Source: "discord",
|
||||
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
||||
}, scope)
|
||||
@@ -620,22 +622,16 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||
}
|
||||
|
||||
for _, attachment := range m.Attachments {
|
||||
isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)
|
||||
|
||||
if isAudio {
|
||||
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
|
||||
if localPath != "" {
|
||||
mediaPaths = append(mediaPaths, storeMedia(localPath, attachment.Filename))
|
||||
content = appendContent(content, fmt.Sprintf("[audio: %s]", attachment.Filename))
|
||||
} else {
|
||||
logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{
|
||||
"url": attachment.URL,
|
||||
"filename": attachment.Filename,
|
||||
})
|
||||
mediaPaths = append(mediaPaths, attachment.URL)
|
||||
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
|
||||
}
|
||||
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
|
||||
if localPath != "" {
|
||||
mediaPaths = append(mediaPaths, storeMedia(localPath, attachment))
|
||||
tag := attachmentMediaTag(attachment.Filename, attachment.ContentType)
|
||||
content = appendContent(content, fmt.Sprintf("[%s: %s]", tag, attachment.Filename))
|
||||
} else {
|
||||
logger.WarnCF("discord", "Failed to download attachment", map[string]any{
|
||||
"url": attachment.URL,
|
||||
"filename": attachment.Filename,
|
||||
})
|
||||
mediaPaths = append(mediaPaths, attachment.URL)
|
||||
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
|
||||
}
|
||||
@@ -748,6 +744,30 @@ func (c *DiscordChannel) downloadAttachment(url, filename string) string {
|
||||
})
|
||||
}
|
||||
|
||||
func attachmentMediaTag(filename, contentType string) string {
|
||||
ct := strings.ToLower(contentType)
|
||||
switch {
|
||||
case strings.HasPrefix(ct, "image/"):
|
||||
return "image"
|
||||
case strings.HasPrefix(ct, "audio/"), ct == "application/ogg", ct == "application/x-ogg":
|
||||
return "audio"
|
||||
case strings.HasPrefix(ct, "video/"):
|
||||
return "video"
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp":
|
||||
return "image"
|
||||
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma":
|
||||
return "audio"
|
||||
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
|
||||
return "video"
|
||||
}
|
||||
|
||||
return "file"
|
||||
}
|
||||
|
||||
func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error {
|
||||
var proxyFunc func(*http.Request) (*url.URL, error)
|
||||
if proxyAddr != "" {
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
func ClassifySendError(statusCode int, rawErr error) error {
|
||||
switch {
|
||||
case statusCode == http.StatusTooManyRequests:
|
||||
return fmt.Errorf("%w: %v", ErrRateLimit, rawErr)
|
||||
return fmt.Errorf("%w: %w", ErrRateLimit, rawErr)
|
||||
case statusCode >= 500:
|
||||
return fmt.Errorf("%w: %v", ErrTemporary, rawErr)
|
||||
return fmt.Errorf("%w: %w", ErrTemporary, rawErr)
|
||||
case statusCode >= 400:
|
||||
return fmt.Errorf("%w: %v", ErrSendFailed, rawErr)
|
||||
return fmt.Errorf("%w: %w", ErrSendFailed, rawErr)
|
||||
default:
|
||||
return rawErr
|
||||
}
|
||||
@@ -26,5 +26,5 @@ func ClassifyNetError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrTemporary, err)
|
||||
return fmt.Errorf("%w: %w", ErrTemporary, err)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ type FeishuChannel struct {
|
||||
|
||||
progress *channels.ToolFeedbackAnimator
|
||||
deleteMessageFn func(context.Context, string, string) error
|
||||
sendMediaPartFn func(context.Context, string, bus.MediaPart, media.MediaStore) error
|
||||
sendTextFn func(context.Context, string, string) (string, error)
|
||||
}
|
||||
|
||||
type cachedMessage struct {
|
||||
@@ -78,6 +80,8 @@ func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.M
|
||||
client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...),
|
||||
}
|
||||
ch.deleteMessageFn = ch.deleteMessageAPI
|
||||
ch.sendMediaPartFn = ch.sendMediaPart
|
||||
ch.sendTextFn = ch.sendText
|
||||
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
|
||||
ch.SetOwner(ch)
|
||||
return ch, nil
|
||||
@@ -304,7 +308,7 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
|
||||
}
|
||||
|
||||
req := larkim.NewCreateMessageReqBuilder().
|
||||
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
||||
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
|
||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||
ReceiveId(chatID).
|
||||
MsgType(larkim.MsgTypeInteractive).
|
||||
@@ -497,8 +501,16 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
|
||||
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||
}
|
||||
|
||||
caption := firstMediaCaption(msg.Parts)
|
||||
sentAny := false
|
||||
for _, part := range msg.Parts {
|
||||
if err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil {
|
||||
if err := c.sendMediaPartFn(ctx, msg.ChatID, part, store); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sentAny = true
|
||||
}
|
||||
if sentAny && caption != "" {
|
||||
if _, err := c.sendTextFn(ctx, msg.ChatID, caption); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -557,6 +569,15 @@ func (c *FeishuChannel) sendMediaPart(
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstMediaCaption(parts []bus.MediaPart) string {
|
||||
for _, part := range parts {
|
||||
if caption := strings.TrimSpace(part.Caption); caption != "" {
|
||||
return caption
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- Inbound message handling ---
|
||||
|
||||
func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
|
||||
@@ -725,8 +746,8 @@ func (c *FeishuChannel) isBotMentioned(message *larkim.EventMessage) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
knownID, _ := c.botOpenID.Load().(string)
|
||||
if knownID == "" {
|
||||
knownID, ok := c.botOpenID.Load().(string)
|
||||
if !ok || knownID == "" {
|
||||
logger.DebugCF("feishu", "Bot open_id unknown, cannot detect @mention", nil)
|
||||
return false
|
||||
}
|
||||
@@ -992,7 +1013,13 @@ func (c *FeishuChannel) storeResourceFile(
|
||||
})
|
||||
return ""
|
||||
}
|
||||
out.Close()
|
||||
if closeErr := out.Close(); closeErr != nil {
|
||||
logger.ErrorCF("feishu", "Failed to close downloaded resource file", map[string]any{
|
||||
"error": closeErr.Error(),
|
||||
})
|
||||
os.Remove(localPath)
|
||||
return ""
|
||||
}
|
||||
|
||||
ref, err := store.Store(localPath, media.MediaMeta{
|
||||
Filename: filename,
|
||||
@@ -1047,7 +1074,7 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string {
|
||||
// sendCard sends an interactive card message to a chat.
|
||||
func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) (string, error) {
|
||||
req := larkim.NewCreateMessageReqBuilder().
|
||||
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
||||
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
|
||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||
ReceiveId(chatID).
|
||||
MsgType(larkim.MsgTypeInteractive).
|
||||
@@ -1080,7 +1107,7 @@ func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) (stri
|
||||
content, _ := json.Marshal(map[string]string{"text": text})
|
||||
|
||||
req := larkim.NewCreateMessageReqBuilder().
|
||||
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
||||
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
|
||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||
ReceiveId(chatID).
|
||||
MsgType(larkim.MsgTypeText).
|
||||
@@ -1134,7 +1161,7 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F
|
||||
// Send image message
|
||||
content, _ := json.Marshal(map[string]string{"image_key": imageKey})
|
||||
req := larkim.NewCreateMessageReqBuilder().
|
||||
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
||||
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
|
||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||
ReceiveId(chatID).
|
||||
MsgType(larkim.MsgTypeImage).
|
||||
@@ -1190,7 +1217,7 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi
|
||||
// Send file message
|
||||
content, _ := json.Marshal(map[string]string{"file_key": fileKey})
|
||||
req := larkim.NewCreateMessageReqBuilder().
|
||||
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
||||
ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
|
||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||
ReceiveId(chatID).
|
||||
MsgType(larkim.MsgTypeFile).
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
)
|
||||
|
||||
func TestExtractContent(t *testing.T) {
|
||||
@@ -319,6 +321,43 @@ func TestFinalizeTrackedToolFeedbackMessage_ClearAfterSuccessfulEdit(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMedia_SendsCaptionFallbackAfterMedia(t *testing.T) {
|
||||
ch := &FeishuChannel{
|
||||
BaseChannel: channels.NewBaseChannel("feishu", nil, nil, nil),
|
||||
progress: channels.NewToolFeedbackAnimator(nil),
|
||||
}
|
||||
ch.SetRunning(true)
|
||||
ch.SetMediaStore(media.NewFileMediaStore())
|
||||
|
||||
var mediaOrder []string
|
||||
var textCalls []string
|
||||
ch.sendMediaPartFn = func(ctx context.Context, chatID string, part bus.MediaPart, store media.MediaStore) error {
|
||||
mediaOrder = append(mediaOrder, part.Type)
|
||||
return nil
|
||||
}
|
||||
ch.sendTextFn = func(ctx context.Context, chatID, text string) (string, error) {
|
||||
textCalls = append(textCalls, chatID+"|"+text)
|
||||
return "msg-1", nil
|
||||
}
|
||||
|
||||
_, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
ChatID: "oc_123",
|
||||
Parts: []bus.MediaPart{
|
||||
{Type: "image", Caption: "shared caption"},
|
||||
{Type: "file"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMedia() error = %v", err)
|
||||
}
|
||||
if len(mediaOrder) != 2 {
|
||||
t.Fatalf("media sends = %v, want 2 sends", mediaOrder)
|
||||
}
|
||||
if len(textCalls) != 1 || textCalls[0] != "oc_123|shared caption" {
|
||||
t.Fatalf("textCalls = %v, want [oc_123|shared caption]", textCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) {
|
||||
ch := &FeishuChannel{
|
||||
progress: channels.NewToolFeedbackAnimator(nil),
|
||||
|
||||
@@ -17,7 +17,7 @@ func toChannelHashes(cfg *config.Config) map[string]string {
|
||||
_ = json.Unmarshal(marshal, &channelConfig)
|
||||
|
||||
for key, value := range channelConfig {
|
||||
if !value["enabled"].(bool) {
|
||||
if enabled, ok := value["enabled"].(bool); !ok || !enabled {
|
||||
continue
|
||||
}
|
||||
hiddenValues(key, value, ch.Get(key))
|
||||
@@ -94,7 +94,15 @@ func hiddenValues(key string, value map[string]any, ch *config.Channel) {
|
||||
vv := value["webhooks"]
|
||||
webhooks := make(map[string]string)
|
||||
if vv != nil {
|
||||
webhooks = vv.(map[string]string)
|
||||
if m, ok := vv.(map[string]string); ok {
|
||||
webhooks = m
|
||||
} else if m, ok := vv.(map[string]any); ok {
|
||||
for k, w := range m {
|
||||
if s, ok := w.(string); ok {
|
||||
webhooks[k] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if settings, ok := v.(*config.TeamsWebhookSettings); ok {
|
||||
for name, target := range settings.Webhooks {
|
||||
|
||||
@@ -151,3 +151,57 @@ func TestToChannelHashes_RealWorldChannel(t *testing.T) {
|
||||
assert.Equal(t, 1, len(h))
|
||||
assert.Contains(t, h, "telegram")
|
||||
}
|
||||
|
||||
func TestToChannelHashes_MissingEnabledKey(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Channels["test"] = &config.Channel{
|
||||
Settings: config.RawNode(`{"key":"value"}`),
|
||||
}
|
||||
|
||||
// Should not panic — the ok check safely handles the missing/false case
|
||||
assert.NotPanics(t, func() {
|
||||
_ = toChannelHashes(cfg)
|
||||
})
|
||||
h := toChannelHashes(cfg)
|
||||
assert.Equal(t, 0, len(h), "channel with Enabled=false (default) skipped")
|
||||
}
|
||||
|
||||
func TestToChannelHashes_EnabledNotBool(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Channels["test"] = &config.Channel{
|
||||
Enabled: false,
|
||||
Settings: config.RawNode(`{"enabled":"yes","boolField":true}`),
|
||||
}
|
||||
|
||||
// Should not panic — string "enabled" won't match bool assertion, ok=false
|
||||
assert.NotPanics(t, func() {
|
||||
_ = toChannelHashes(cfg)
|
||||
})
|
||||
h := toChannelHashes(cfg)
|
||||
assert.Equal(t, 0, len(h), "string enabled not treated as true")
|
||||
}
|
||||
|
||||
func TestToChannelHashes_TeamsWebhookWithWebhooks(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
// teams_webhook with configured webhooks — this is the real-world
|
||||
// scenario where the map type from JSON unmarshal (map[string]any)
|
||||
// would cause a panic on the old unchecked vv.(map[string]string)
|
||||
settings, _ := json.Marshal(map[string]any{
|
||||
"enabled": true,
|
||||
"webhooks": map[string]any{
|
||||
"hook1": "https://example.com/webhook",
|
||||
},
|
||||
})
|
||||
cfg.Channels["teams_webhook"] = &config.Channel{
|
||||
Enabled: true,
|
||||
Type: config.ChannelTeamsWebHook,
|
||||
Settings: config.RawNode(settings),
|
||||
}
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
_ = toChannelHashes(cfg)
|
||||
})
|
||||
h := toChannelHashes(cfg)
|
||||
assert.Equal(t, 1, len(h))
|
||||
assert.Contains(t, h, "teams_webhook")
|
||||
}
|
||||
|
||||
@@ -995,7 +995,6 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||
|
||||
senderID := strconv.FormatInt(userID, 10)
|
||||
var chatID string
|
||||
var contextChatID string
|
||||
var contextChatType string
|
||||
|
||||
metadata := map[string]string{}
|
||||
@@ -1007,13 +1006,11 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||
switch raw.MessageType {
|
||||
case "private":
|
||||
chatID = "private:" + senderID
|
||||
contextChatID = senderID
|
||||
contextChatType = "direct"
|
||||
|
||||
case "group":
|
||||
groupIDStr := strconv.FormatInt(groupID, 10)
|
||||
chatID = "group:" + groupIDStr
|
||||
contextChatID = groupIDStr
|
||||
contextChatType = "group"
|
||||
metadata["group_id"] = groupIDStr
|
||||
|
||||
@@ -1080,7 +1077,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||
|
||||
inboundCtx := bus.InboundContext{
|
||||
Channel: c.Name(),
|
||||
ChatID: contextChatID,
|
||||
ChatID: chatID,
|
||||
ChatType: contextChatType,
|
||||
SenderID: senderID,
|
||||
MessageID: messageID,
|
||||
|
||||
@@ -1394,10 +1394,12 @@ func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) {
|
||||
return
|
||||
}
|
||||
payload["context_usage"] = map[string]any{
|
||||
"used_tokens": u.UsedTokens,
|
||||
"total_tokens": u.TotalTokens,
|
||||
"compress_at_tokens": u.CompressAtTokens,
|
||||
"used_percent": u.UsedPercent,
|
||||
"used_tokens": u.UsedTokens,
|
||||
"total_tokens": u.TotalTokens,
|
||||
"history_tokens": u.HistoryTokens,
|
||||
"compress_at_tokens": u.CompressAtTokens,
|
||||
"summarize_at_tokens": u.SummarizeAtTokens,
|
||||
"used_percent": u.UsedPercent,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -602,10 +602,12 @@ func TestBeginStream_FinalizeIncludesContextUsage(t *testing.T) {
|
||||
t.Fatal("streamer should support FinalizeWithContext")
|
||||
}
|
||||
if err := contextStreamer.FinalizeWithContext(context.Background(), "final", &bus.ContextUsage{
|
||||
UsedTokens: 10,
|
||||
TotalTokens: 100,
|
||||
CompressAtTokens: 80,
|
||||
UsedPercent: 10,
|
||||
UsedTokens: 10,
|
||||
TotalTokens: 100,
|
||||
HistoryTokens: 5,
|
||||
CompressAtTokens: 80,
|
||||
SummarizeAtTokens: 60,
|
||||
UsedPercent: 10,
|
||||
}); err != nil {
|
||||
t.Fatalf("FinalizeWithContext() error = %v", err)
|
||||
}
|
||||
@@ -627,6 +629,12 @@ func TestBeginStream_FinalizeIncludesContextUsage(t *testing.T) {
|
||||
if got := rawUsage["used_tokens"]; got != float64(10) {
|
||||
t.Fatalf("used_tokens = %#v, want 10", got)
|
||||
}
|
||||
if got := rawUsage["history_tokens"]; got != float64(5) {
|
||||
t.Fatalf("history_tokens = %#v, want 5", got)
|
||||
}
|
||||
if got := rawUsage["summarize_at_tokens"]; got != float64(60) {
|
||||
t.Fatalf("summarize_at_tokens = %#v, want 60", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndAddConnection_RespectsMaxConnectionsConcurrently(t *testing.T) {
|
||||
@@ -835,6 +843,75 @@ func TestSendMedia_DismissesTrackedToolFeedbackMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMedia_IncludesCaptionAndAttachmentsInSinglePayload(t *testing.T) {
|
||||
ch := newTestPicoChannel(t)
|
||||
store := media.NewFileMediaStore()
|
||||
ch.SetMediaStore(store)
|
||||
|
||||
if err := ch.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
defer ch.Stop(context.Background())
|
||||
|
||||
clientConn, received, cleanup := newTestPicoWebSocket(t)
|
||||
defer cleanup()
|
||||
ch.addConnForTest(&picoConn{id: "conn-1", conn: clientConn, sessionID: "sess-1"})
|
||||
|
||||
localPath := filepath.Join(t.TempDir(), "photo.png")
|
||||
if err := os.WriteFile(localPath, []byte("png-body"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
ref, err := store.Store(localPath, media.MediaMeta{
|
||||
Filename: "photo.png",
|
||||
ContentType: "image/png",
|
||||
}, "test-scope")
|
||||
if err != nil {
|
||||
t.Fatalf("Store() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
ChatID: "pico:sess-1",
|
||||
Parts: []bus.MediaPart{{
|
||||
Ref: ref,
|
||||
Type: "image",
|
||||
Filename: "photo.png",
|
||||
ContentType: "image/png",
|
||||
Caption: "recipe translation",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMedia() error = %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case msg := <-received:
|
||||
if msg.Type != TypeMessageCreate {
|
||||
t.Fatalf("message type = %q, want %q", msg.Type, TypeMessageCreate)
|
||||
}
|
||||
payload := msg.Payload
|
||||
if got := payload[PayloadKeyContent]; got != "recipe translation" {
|
||||
t.Fatalf("content = %#v, want %q", got, "recipe translation")
|
||||
}
|
||||
rawAttachments, ok := payload["attachments"].([]any)
|
||||
if !ok || len(rawAttachments) != 1 {
|
||||
t.Fatalf("attachments = %#v, want 1 attachment", payload["attachments"])
|
||||
}
|
||||
attachment, ok := rawAttachments[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("attachment = %#v, want map", rawAttachments[0])
|
||||
}
|
||||
if got := attachment["type"]; got != "image" {
|
||||
t.Fatalf("attachment type = %#v, want image", got)
|
||||
}
|
||||
if got := attachment["filename"]; got != "photo.png" {
|
||||
t.Fatalf("attachment filename = %#v, want photo.png", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected media payload to be delivered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPicoDownloadURLForRef(t *testing.T) {
|
||||
got, err := picoDownloadURLForRef("media://attachment-1")
|
||||
if err != nil {
|
||||
|
||||
@@ -29,6 +29,8 @@ type SlackChannel struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
pendingAcks sync.Map
|
||||
uploadFileFn func(context.Context, slack.UploadFileParameters) error
|
||||
postTextFn func(context.Context, string, string, string) error
|
||||
}
|
||||
|
||||
type slackMessageRef struct {
|
||||
@@ -63,6 +65,18 @@ func NewSlackChannel(
|
||||
config: cfg,
|
||||
api: api,
|
||||
socketClient: socketClient,
|
||||
uploadFileFn: func(ctx context.Context, params slack.UploadFileParameters) error {
|
||||
_, err := api.UploadFileContext(ctx, params)
|
||||
return err
|
||||
},
|
||||
postTextFn: func(ctx context.Context, channelID, threadTS, text string) error {
|
||||
opts := []slack.MsgOption{slack.MsgOptionText(text, false)}
|
||||
if threadTS != "" {
|
||||
opts = append(opts, slack.MsgOptionTS(threadTS))
|
||||
}
|
||||
_, _, err := api.PostMessageContext(ctx, channelID, opts...)
|
||||
return err
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -140,7 +154,10 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]str
|
||||
}
|
||||
|
||||
if ref, ok := c.pendingAcks.LoadAndDelete(deliveryChatID); ok {
|
||||
msgRef := ref.(slackMessageRef)
|
||||
msgRef, ok := ref.(slackMessageRef)
|
||||
if !ok {
|
||||
return []string{ts}, nil
|
||||
}
|
||||
c.api.AddReaction("white_check_mark", slack.ItemRef{
|
||||
Channel: msgRef.ChannelID,
|
||||
Timestamp: msgRef.Timestamp,
|
||||
@@ -171,6 +188,8 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
|
||||
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||
}
|
||||
|
||||
caption := slackFirstMediaCaption(msg.Parts)
|
||||
sentAny := false
|
||||
for _, part := range msg.Parts {
|
||||
localPath, err := store.Resolve(part.Ref)
|
||||
if err != nil {
|
||||
@@ -191,7 +210,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
|
||||
title = filename
|
||||
}
|
||||
|
||||
_, err = c.api.UploadFileContext(ctx, slack.UploadFileParameters{
|
||||
err = c.uploadFileFn(ctx, slack.UploadFileParameters{
|
||||
Channel: channelID,
|
||||
ThreadTimestamp: threadTS,
|
||||
File: localPath,
|
||||
@@ -205,6 +224,13 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
|
||||
})
|
||||
return nil, fmt.Errorf("slack send media: %w", channels.ErrTemporary)
|
||||
}
|
||||
sentAny = true
|
||||
}
|
||||
|
||||
if sentAny && caption != "" {
|
||||
if err := c.postTextFn(ctx, channelID, threadTS, caption); err != nil {
|
||||
return nil, fmt.Errorf("slack send media caption fallback: %w", channels.ErrTemporary)
|
||||
}
|
||||
}
|
||||
|
||||
// UploadFile does not expose the posted message timestamp in its
|
||||
@@ -212,6 +238,15 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func slackFirstMediaCaption(parts []bus.MediaPart) string {
|
||||
for _, part := range parts {
|
||||
if caption := strings.TrimSpace(part.Caption); caption != "" {
|
||||
return caption
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ReactToMessage implements channels.ReactionCapable.
|
||||
// It adds an "eyes" (👀) reaction to the inbound message and returns an undo function
|
||||
// that removes the reaction.
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
slacksdk "github.com/slack-go/slack"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
)
|
||||
|
||||
func TestParseSlackChatID(t *testing.T) {
|
||||
@@ -184,3 +191,74 @@ func TestSlackChannelIsAllowed(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSendMedia_SendsCaptionFallbackAfterUploads(t *testing.T) {
|
||||
ch := &SlackChannel{
|
||||
BaseChannel: channels.NewBaseChannel("slack", nil, nil, nil),
|
||||
}
|
||||
ch.SetRunning(true)
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
ch.SetMediaStore(store)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
localPath := filepath.Join(tmpDir, "report.txt")
|
||||
if err := os.WriteFile(localPath, []byte("attachment body"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
ref, err := store.Store(localPath, media.MediaMeta{
|
||||
Filename: "report.txt",
|
||||
ContentType: "text/plain",
|
||||
}, "test-scope")
|
||||
if err != nil {
|
||||
t.Fatalf("Store() error = %v", err)
|
||||
}
|
||||
|
||||
var uploaded []slackUploadRecord
|
||||
var posted []string
|
||||
ch.uploadFileFn = func(ctx context.Context, params slacksdk.UploadFileParameters) error {
|
||||
uploaded = append(uploaded, slackUploadRecord{
|
||||
Channel: params.Channel,
|
||||
Thread: params.ThreadTimestamp,
|
||||
File: params.File,
|
||||
Name: params.Filename,
|
||||
Title: params.Title,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
ch.postTextFn = func(ctx context.Context, channelID, threadTS, text string) error {
|
||||
posted = append(posted, channelID+"|"+threadTS+"|"+text)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
ChatID: "C123456/1234567890.123456",
|
||||
Parts: []bus.MediaPart{{
|
||||
Ref: ref,
|
||||
Type: "file",
|
||||
Filename: "report.txt",
|
||||
ContentType: "text/plain",
|
||||
Caption: "shared caption",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMedia() error = %v", err)
|
||||
}
|
||||
if len(uploaded) != 1 {
|
||||
t.Fatalf("uploads = %v, want 1 upload", uploaded)
|
||||
}
|
||||
if uploaded[0].Title != "shared caption" {
|
||||
t.Fatalf("upload title = %q, want shared caption", uploaded[0].Title)
|
||||
}
|
||||
if len(posted) != 1 || posted[0] != "C123456|1234567890.123456|shared caption" {
|
||||
t.Fatalf("posted = %v, want fallback text in same thread", posted)
|
||||
}
|
||||
}
|
||||
|
||||
type slackUploadRecord struct {
|
||||
Channel string
|
||||
Thread string
|
||||
File string
|
||||
Name string
|
||||
Title string
|
||||
}
|
||||
|
||||
@@ -44,7 +44,10 @@ var (
|
||||
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
||||
)
|
||||
|
||||
const defaultMediaGroupDelay = 500 * time.Millisecond
|
||||
const (
|
||||
defaultMediaGroupDelay = 500 * time.Millisecond
|
||||
telegramCaptionLimit = 1024
|
||||
)
|
||||
|
||||
type TelegramChannel struct {
|
||||
*channels.BaseChannel
|
||||
@@ -639,6 +642,34 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
||||
}
|
||||
|
||||
var messageIDs []string
|
||||
leadingCaption := telegramLeadingCaption(msg.Parts)
|
||||
if len([]rune(leadingCaption)) > telegramCaptionLimit {
|
||||
leadingIDs, leadingErr := c.sendCaptionText(ctx, chatID, threadID, leadingCaption)
|
||||
if leadingErr != nil {
|
||||
return nil, leadingErr
|
||||
}
|
||||
messageIDs = append(messageIDs, leadingIDs...)
|
||||
msg = telegramClearMediaCaptions(msg)
|
||||
}
|
||||
|
||||
if len(msg.Parts) > 1 && telegramCanSendMediaGroup(msg.Parts) {
|
||||
groupIDs, err := c.sendImageMediaGroups(ctx, chatID, threadID, store, msg.Parts)
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Failed to send media group", map[string]any{
|
||||
"count": len(msg.Parts),
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("telegram send media group: %w", channels.ErrTemporary)
|
||||
}
|
||||
if len(groupIDs) > 0 {
|
||||
messageIDs = append(messageIDs, groupIDs...)
|
||||
if hasTrackedMsg {
|
||||
c.dismissTrackedToolFeedbackMessage(ctx, trackedChatID, trackedMsgID)
|
||||
}
|
||||
return messageIDs, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, part := range msg.Parts {
|
||||
localPath, err := store.Resolve(part.Ref)
|
||||
if err != nil {
|
||||
@@ -742,6 +773,154 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
||||
return messageIDs, nil
|
||||
}
|
||||
|
||||
func telegramCanSendMediaGroup(parts []bus.MediaPart) bool {
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, part := range parts {
|
||||
if part.Type != "image" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) sendImageMediaGroups(
|
||||
ctx context.Context,
|
||||
chatID int64,
|
||||
threadID int,
|
||||
store media.MediaStore,
|
||||
parts []bus.MediaPart,
|
||||
) ([]string, error) {
|
||||
const maxGroupSize = 10
|
||||
|
||||
messageIDs := make([]string, 0, len(parts))
|
||||
for start := 0; start < len(parts); start += maxGroupSize {
|
||||
end := start + maxGroupSize
|
||||
if end > len(parts) {
|
||||
end = len(parts)
|
||||
}
|
||||
groupIDs, err := c.sendSingleImageMediaGroup(ctx, chatID, threadID, store, parts[start:end])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messageIDs = append(messageIDs, groupIDs...)
|
||||
}
|
||||
return messageIDs, nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) sendSingleImageMediaGroup(
|
||||
ctx context.Context,
|
||||
chatID int64,
|
||||
threadID int,
|
||||
store media.MediaStore,
|
||||
parts []bus.MediaPart,
|
||||
) ([]string, error) {
|
||||
opened := make([]*os.File, 0, len(parts))
|
||||
defer func() {
|
||||
for _, file := range opened {
|
||||
file.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
inputMedia := make([]telego.InputMedia, 0, len(parts))
|
||||
for i, part := range parts {
|
||||
localPath, err := store.Resolve(part.Ref)
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Failed to resolve media ref for media group", map[string]any{
|
||||
"ref": part.Ref,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Failed to open media file for media group", map[string]any{
|
||||
"path": localPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
opened = append(opened, file)
|
||||
|
||||
mediaItem := &telego.InputMediaPhoto{
|
||||
Type: telego.MediaTypePhoto,
|
||||
Media: telego.InputFile{File: file},
|
||||
}
|
||||
if i == 0 {
|
||||
mediaItem.Caption = part.Caption
|
||||
}
|
||||
inputMedia = append(inputMedia, mediaItem)
|
||||
}
|
||||
|
||||
results, err := c.bot.SendMediaGroup(ctx, &telego.SendMediaGroupParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
MessageThreadID: threadID,
|
||||
Media: inputMedia,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
messageIDs := make([]string, 0, len(results))
|
||||
for _, result := range results {
|
||||
messageIDs = append(messageIDs, strconv.Itoa(result.MessageID))
|
||||
}
|
||||
return messageIDs, nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) sendCaptionText(
|
||||
ctx context.Context,
|
||||
chatID int64,
|
||||
threadID int,
|
||||
text string,
|
||||
) ([]string, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil, nil
|
||||
}
|
||||
chunks := channels.SplitMessage(text, c.MaxMessageLength())
|
||||
messageIDs := make([]string, 0, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
chunk = strings.TrimSpace(chunk)
|
||||
if chunk == "" {
|
||||
continue
|
||||
}
|
||||
msgID, err := c.sendChunk(ctx, sendChunkParams{
|
||||
chatID: chatID,
|
||||
threadID: threadID,
|
||||
content: chunk,
|
||||
mdFallback: chunk,
|
||||
useMarkdownV2: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messageIDs = append(messageIDs, msgID)
|
||||
}
|
||||
return messageIDs, nil
|
||||
}
|
||||
|
||||
func telegramLeadingCaption(parts []bus.MediaPart) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(parts[0].Caption)
|
||||
}
|
||||
|
||||
func telegramClearMediaCaptions(msg bus.OutboundMediaMessage) bus.OutboundMediaMessage {
|
||||
if len(msg.Parts) == 0 {
|
||||
return msg
|
||||
}
|
||||
cloned := msg
|
||||
cloned.Parts = append([]bus.MediaPart(nil), msg.Parts...)
|
||||
for i := range cloned.Parts {
|
||||
cloned.Parts[i].Caption = ""
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
|
||||
if message != nil && strings.TrimSpace(message.MediaGroupID) != "" {
|
||||
return c.bufferMediaGroupMessage(ctx, message)
|
||||
@@ -1048,6 +1227,13 @@ func (c *TelegramChannel) collectTelegramMessageParts(
|
||||
if caption := strings.TrimSpace(msg.Caption); caption != "" {
|
||||
parts.content = append(parts.content, caption)
|
||||
}
|
||||
if msg.Location != nil {
|
||||
parts.content = append(parts.content, fmt.Sprintf(
|
||||
"[User location: lat=%.6f, lng=%.6f]",
|
||||
msg.Location.Latitude,
|
||||
msg.Location.Longitude,
|
||||
))
|
||||
}
|
||||
if len(msg.Photo) > 0 {
|
||||
photo := msg.Photo[len(msg.Photo)-1]
|
||||
photoPath := c.downloadPhoto(ctx, photo.FileID)
|
||||
|
||||
@@ -110,6 +110,17 @@ func successResponseWithMessageID(t *testing.T, messageID int) *ta.Response {
|
||||
return &ta.Response{Ok: true, Result: b}
|
||||
}
|
||||
|
||||
func successMediaGroupResponse(t *testing.T, messageIDs ...int) *ta.Response {
|
||||
t.Helper()
|
||||
messages := make([]telego.Message, 0, len(messageIDs))
|
||||
for _, messageID := range messageIDs {
|
||||
messages = append(messages, telego.Message{MessageID: messageID})
|
||||
}
|
||||
b, err := json.Marshal(messages)
|
||||
require.NoError(t, err)
|
||||
return &ta.Response{Ok: true, Result: b}
|
||||
}
|
||||
|
||||
func successUserResponse(t *testing.T, user *telego.User) *ta.Response {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(user)
|
||||
@@ -237,6 +248,276 @@ func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) {
|
||||
assert.NotContains(t, caller.calls[0].URL, "sendDocument")
|
||||
}
|
||||
|
||||
func TestSendMedia_MultipleImagesUseMediaGroup(t *testing.T) {
|
||||
constructor := &multipartRecordingConstructor{}
|
||||
caller := &stubCaller{
|
||||
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||
if strings.Contains(url, "sendMediaGroup") {
|
||||
return successMediaGroupResponse(t, 101, 102), nil
|
||||
}
|
||||
t.Fatalf("unexpected API call: %s", url)
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
ch := newTestChannelWithConstructor(t, caller, constructor)
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
ch.SetMediaStore(store)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
firstPath := filepath.Join(tmpDir, "first.png")
|
||||
secondPath := filepath.Join(tmpDir, "second.png")
|
||||
require.NoError(t, os.WriteFile(firstPath, []byte("first-image"), 0o644))
|
||||
require.NoError(t, os.WriteFile(secondPath, []byte("second-image"), 0o644))
|
||||
|
||||
firstRef, err := store.Store(firstPath, media.MediaMeta{Filename: "first.png", ContentType: "image/png"}, "scope-1")
|
||||
require.NoError(t, err)
|
||||
secondRef, err := store.Store(
|
||||
secondPath,
|
||||
media.MediaMeta{Filename: "second.png", ContentType: "image/png"},
|
||||
"scope-1",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
ChatID: "12345",
|
||||
Parts: []bus.MediaPart{
|
||||
{Type: "image", Ref: firstRef, Caption: "album caption"},
|
||||
{Type: "image", Ref: secondRef},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"101", "102"}, ids)
|
||||
require.Len(t, caller.calls, 1)
|
||||
assert.Contains(t, caller.calls[0].URL, "sendMediaGroup")
|
||||
require.Len(t, constructor.calls, 1)
|
||||
require.Len(t, constructor.calls[0].FileSizes, 2)
|
||||
|
||||
var mediaPayload []map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(constructor.calls[0].Parameters["media"]), &mediaPayload))
|
||||
require.Len(t, mediaPayload, 2)
|
||||
assert.Equal(t, "album caption", mediaPayload[0]["caption"])
|
||||
_, hasSecondCaption := mediaPayload[1]["caption"]
|
||||
assert.False(t, hasSecondCaption)
|
||||
}
|
||||
|
||||
func TestSendMedia_MoreThanTenImagesSplitIntoMediaGroups(t *testing.T) {
|
||||
constructor := &multipartRecordingConstructor{}
|
||||
callIndex := 0
|
||||
caller := &stubCaller{
|
||||
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||
if !strings.Contains(url, "sendMediaGroup") {
|
||||
t.Fatalf("unexpected API call: %s", url)
|
||||
}
|
||||
callIndex++
|
||||
if callIndex == 1 {
|
||||
return successMediaGroupResponse(t, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010), nil
|
||||
}
|
||||
if callIndex == 2 {
|
||||
return successMediaGroupResponse(t, 1011, 1012, 1013, 1014, 1015), nil
|
||||
}
|
||||
t.Fatalf("unexpected sendMediaGroup call #%d", callIndex)
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
ch := newTestChannelWithConstructor(t, caller, constructor)
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
ch.SetMediaStore(store)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
parts := make([]bus.MediaPart, 0, 15)
|
||||
for i := 0; i < 15; i++ {
|
||||
path := filepath.Join(tmpDir, "image-"+strconv.Itoa(i)+".png")
|
||||
require.NoError(t, os.WriteFile(path, []byte("img-"+strconv.Itoa(i)), 0o644))
|
||||
ref, err := store.Store(
|
||||
path,
|
||||
media.MediaMeta{Filename: filepath.Base(path), ContentType: "image/png"},
|
||||
"scope-1",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
part := bus.MediaPart{Type: "image", Ref: ref}
|
||||
if i == 0 {
|
||||
part.Caption = "long album caption"
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
|
||||
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
ChatID: "12345",
|
||||
Parts: parts,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{
|
||||
"1001", "1002", "1003", "1004", "1005",
|
||||
"1006", "1007", "1008", "1009", "1010",
|
||||
"1011", "1012", "1013", "1014", "1015",
|
||||
}, ids)
|
||||
require.Len(t, caller.calls, 2)
|
||||
require.Len(t, constructor.calls, 2)
|
||||
}
|
||||
|
||||
func TestSendMedia_SingleImageLongCaptionSendsTextFirst(t *testing.T) {
|
||||
constructor := &multipartRecordingConstructor{}
|
||||
longCaption := strings.Repeat("a", telegramCaptionLimit) + " tail overflow"
|
||||
caller := &stubCaller{
|
||||
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(url, "sendMessage"):
|
||||
return successResponseWithMessageID(t, 201), nil
|
||||
case strings.Contains(url, "sendPhoto"):
|
||||
return successResponseWithMessageID(t, 202), nil
|
||||
default:
|
||||
t.Fatalf("unexpected API call: %s", url)
|
||||
return nil, nil
|
||||
}
|
||||
},
|
||||
}
|
||||
ch := newTestChannelWithConstructor(t, caller, constructor)
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
ch.SetMediaStore(store)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "image.png")
|
||||
require.NoError(t, os.WriteFile(path, []byte("img"), 0o644))
|
||||
ref, err := store.Store(path, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
ChatID: "12345",
|
||||
Parts: []bus.MediaPart{{
|
||||
Type: "image",
|
||||
Ref: ref,
|
||||
Caption: longCaption,
|
||||
}},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"201", "202"}, ids)
|
||||
require.Len(t, caller.calls, 2)
|
||||
assert.Contains(t, caller.calls[0].URL, "sendMessage")
|
||||
assert.Contains(t, caller.calls[1].URL, "sendPhoto")
|
||||
assert.Equal(t, "", constructor.calls[0].Parameters["caption"])
|
||||
}
|
||||
|
||||
func TestSendMedia_MediaGroupLongCaptionSendsTextFirst(t *testing.T) {
|
||||
constructor := &multipartRecordingConstructor{}
|
||||
longCaption := strings.Repeat("b", telegramCaptionLimit) + " trailing explanation"
|
||||
caller := &stubCaller{
|
||||
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(url, "sendMessage"):
|
||||
return successResponseWithMessageID(t, 301), nil
|
||||
case strings.Contains(url, "sendMediaGroup"):
|
||||
return successMediaGroupResponse(t, 302, 303), nil
|
||||
default:
|
||||
t.Fatalf("unexpected API call: %s", url)
|
||||
return nil, nil
|
||||
}
|
||||
},
|
||||
}
|
||||
ch := newTestChannelWithConstructor(t, caller, constructor)
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
ch.SetMediaStore(store)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
firstPath := filepath.Join(tmpDir, "first.png")
|
||||
secondPath := filepath.Join(tmpDir, "second.png")
|
||||
require.NoError(t, os.WriteFile(firstPath, []byte("first-image"), 0o644))
|
||||
require.NoError(t, os.WriteFile(secondPath, []byte("second-image"), 0o644))
|
||||
|
||||
firstRef, err := store.Store(firstPath, media.MediaMeta{Filename: "first.png", ContentType: "image/png"}, "scope-1")
|
||||
require.NoError(t, err)
|
||||
secondRef, err := store.Store(
|
||||
secondPath,
|
||||
media.MediaMeta{Filename: "second.png", ContentType: "image/png"},
|
||||
"scope-1",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
ChatID: "12345",
|
||||
Parts: []bus.MediaPart{
|
||||
{Type: "image", Ref: firstRef, Caption: longCaption},
|
||||
{Type: "image", Ref: secondRef},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"301", "302", "303"}, ids)
|
||||
require.Len(t, caller.calls, 2)
|
||||
assert.Contains(t, caller.calls[0].URL, "sendMessage")
|
||||
assert.Contains(t, caller.calls[1].URL, "sendMediaGroup")
|
||||
}
|
||||
|
||||
func TestSendMedia_MultiGroupLongCaptionSendsTextBeforeGroups(t *testing.T) {
|
||||
constructor := &multipartRecordingConstructor{}
|
||||
longCaption := strings.Repeat("c", telegramCaptionLimit) + " overflow before second album"
|
||||
callOrder := make([]string, 0, 3)
|
||||
caller := &stubCaller{
|
||||
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(url, "sendMessage"):
|
||||
callOrder = append(callOrder, "text")
|
||||
return successResponseWithMessageID(t, 499), nil
|
||||
case strings.Contains(url, "sendMediaGroup"):
|
||||
callOrder = append(callOrder, "group")
|
||||
if len(callOrder) == 2 {
|
||||
return successMediaGroupResponse(t, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410), nil
|
||||
}
|
||||
if len(callOrder) == 3 {
|
||||
return successMediaGroupResponse(t, 411, 412, 413, 414, 415), nil
|
||||
}
|
||||
t.Fatalf("unexpected sendMediaGroup order: %v", callOrder)
|
||||
return nil, nil
|
||||
default:
|
||||
t.Fatalf("unexpected API call: %s", url)
|
||||
return nil, nil
|
||||
}
|
||||
},
|
||||
}
|
||||
ch := newTestChannelWithConstructor(t, caller, constructor)
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
ch.SetMediaStore(store)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
parts := make([]bus.MediaPart, 0, 15)
|
||||
for i := 0; i < 15; i++ {
|
||||
path := filepath.Join(tmpDir, "image-"+strconv.Itoa(i)+".png")
|
||||
require.NoError(t, os.WriteFile(path, []byte("img-"+strconv.Itoa(i)), 0o644))
|
||||
ref, err := store.Store(
|
||||
path,
|
||||
media.MediaMeta{Filename: filepath.Base(path), ContentType: "image/png"},
|
||||
"scope-1",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
part := bus.MediaPart{Type: "image", Ref: ref}
|
||||
if i == 0 {
|
||||
part.Caption = longCaption
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
|
||||
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
ChatID: "12345",
|
||||
Parts: parts,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{
|
||||
"499",
|
||||
"401", "402", "403", "404", "405",
|
||||
"406", "407", "408", "409", "410",
|
||||
"411", "412", "413", "414", "415",
|
||||
}, ids)
|
||||
assert.Equal(t, []string{"text", "group", "group"}, callOrder)
|
||||
}
|
||||
|
||||
func TestSend_EmptyContent(t *testing.T) {
|
||||
caller := &stubCaller{
|
||||
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||
@@ -1216,6 +1497,42 @@ func TestHandleMessage_EmptyContent_Ignored(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessage_LocationForwardedAsText(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch := &TelegramChannel{
|
||||
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||
chatIDs: make(map[string]int64),
|
||||
ctx: context.Background(),
|
||||
}
|
||||
|
||||
msg := &telego.Message{
|
||||
MessageID: 3049,
|
||||
Location: &telego.Location{
|
||||
Latitude: 35.197713,
|
||||
Longitude: 136.885705,
|
||||
},
|
||||
Chat: telego.Chat{
|
||||
ID: 456,
|
||||
Type: "private",
|
||||
},
|
||||
From: &telego.User{
|
||||
ID: 789,
|
||||
FirstName: "User",
|
||||
},
|
||||
}
|
||||
|
||||
err := ch.handleMessage(context.Background(), msg)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case inbound := <-messageBus.InboundChan():
|
||||
assert.Equal(t, "[User location: lat=35.197713, lng=136.885705]", inbound.Content)
|
||||
assert.Equal(t, "3049", inbound.Context.MessageID)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for location message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessage_MediaGroupCombinesCaptionMessages(t *testing.T) {
|
||||
messageBus, ch := newMediaGroupTestChannel(10 * time.Millisecond)
|
||||
base := testMediaGroupMessage("album-1")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -319,3 +320,61 @@ func TestSelectInboundMediaItemFallsBackToRefMessage(t *testing.T) {
|
||||
t.Fatalf("selectInboundMediaItem().Type = %d, want %d", item.Type, MessageItemTypeImage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendUploadedMedia_SendsCaptionAsSeparateTextBeforeMedia(t *testing.T) {
|
||||
var requests []SendMessageReq
|
||||
ch := &WeixinChannel{
|
||||
api: &ApiClient{
|
||||
BaseURL: "https://ilinkai.weixin.qq.com/",
|
||||
HttpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/ilink/bot/sendmessage" {
|
||||
t.Fatalf("sendmessage path = %q, want /ilink/bot/sendmessage", r.URL.Path)
|
||||
}
|
||||
var req SendMessageReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatalf("decode sendmessage req: %v", err)
|
||||
}
|
||||
requests = append(requests, req)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"ret":0,"errcode":0}`))),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})},
|
||||
},
|
||||
typingCache: make(map[string]typingTicketCacheEntry),
|
||||
}
|
||||
|
||||
err := ch.sendUploadedMedia(
|
||||
context.Background(),
|
||||
"user-1",
|
||||
"ctx-1",
|
||||
"recipe translation",
|
||||
UploadMediaTypeImage,
|
||||
&uploadedFileInfo{
|
||||
downloadParam: "download-token",
|
||||
aesKeyHex: "31323334353637383930616263646566",
|
||||
fileSize: 11,
|
||||
cipherSize: 16,
|
||||
filename: "photo.png",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("sendUploadedMedia() error = %v", err)
|
||||
}
|
||||
if len(requests) != 2 {
|
||||
t.Fatalf("sendUploadedMedia() sent %d requests, want 2", len(requests))
|
||||
}
|
||||
if len(requests[0].Msg.ItemList) != 1 || requests[0].Msg.ItemList[0].Type != MessageItemTypeText {
|
||||
t.Fatalf("first request item = %+v, want text item", requests[0].Msg.ItemList)
|
||||
}
|
||||
if got := requests[0].Msg.ItemList[0].TextItem.Text; got != "recipe translation" {
|
||||
t.Fatalf("first request text = %q, want recipe translation", got)
|
||||
}
|
||||
if len(requests[1].Msg.ItemList) != 1 || requests[1].Msg.ItemList[0].Type != MessageItemTypeImage {
|
||||
t.Fatalf("second request item = %+v, want image item", requests[1].Msg.ItemList)
|
||||
}
|
||||
if requests[1].Msg.ItemList[0].ImageItem == nil || requests[1].Msg.ItemList[0].ImageItem.Media == nil {
|
||||
t.Fatalf("second request image media = %+v, want media ref", requests[1].Msg.ItemList[0].ImageItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,9 +269,9 @@ func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (c *WhatsAppNativeChannel) eventHandler(evt any) {
|
||||
switch evt.(type) {
|
||||
switch v := evt.(type) {
|
||||
case *events.Message:
|
||||
c.handleIncoming(evt.(*events.Message))
|
||||
c.handleIncoming(v)
|
||||
case *events.Disconnected:
|
||||
logger.InfoCF("whatsapp", "WhatsApp disconnected, will attempt reconnection", nil)
|
||||
c.reconnectMu.Lock()
|
||||
|
||||
@@ -29,14 +29,17 @@ func formatContextStats(s *ContextStats) string {
|
||||
remaining = 0
|
||||
}
|
||||
usedWindowPercent := s.UsedTokens * 100 / max(s.TotalTokens, 1)
|
||||
return fmt.Sprintf(
|
||||
"Context usage \nMessages: %d \nUsed: ~%d / %d tokens (%d%%) \nCompress at: %d tokens \nCompression progress: %d%% \nRemaining: ~%d tokens",
|
||||
msg := fmt.Sprintf(
|
||||
"Context usage \nMessages: %d \nUsed: ~%d / %d tokens (%d%%) \nHistory: ~%d tokens \nCompress at: %d tokens \nSummarize at: %d tokens \nCompression progress: %d%% \nRemaining: ~%d tokens",
|
||||
s.MessageCount,
|
||||
s.UsedTokens,
|
||||
s.TotalTokens,
|
||||
usedWindowPercent,
|
||||
s.HistoryTokens,
|
||||
s.CompressAtTokens,
|
||||
s.SummarizeAtTokens,
|
||||
s.UsedPercent,
|
||||
remaining,
|
||||
)
|
||||
return msg
|
||||
}
|
||||
|
||||
@@ -29,11 +29,13 @@ type MCPToolInfo struct {
|
||||
|
||||
// ContextStats describes current session context window usage.
|
||||
type ContextStats struct {
|
||||
UsedTokens int
|
||||
TotalTokens int // model context window
|
||||
CompressAtTokens int // compression threshold
|
||||
UsedPercent int // 0-100
|
||||
MessageCount int
|
||||
UsedTokens int
|
||||
TotalTokens int // model context window
|
||||
HistoryTokens int // history-only tokens (what maybeSummarize checks)
|
||||
CompressAtTokens int // hard budget compression threshold
|
||||
SummarizeAtTokens int // soft summarization trigger
|
||||
UsedPercent int // 0-100
|
||||
MessageCount int
|
||||
}
|
||||
|
||||
// StopResult describes the outcome of a stop request for the current session.
|
||||
|
||||
+84
-2
@@ -7,6 +7,7 @@ import (
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -194,6 +195,9 @@ type ExposePath struct {
|
||||
// Uses strings.Replacer for O(n+m) performance (computed once per SecurityConfig).
|
||||
// Short content (below FilterMinLength) is returned unchanged for performance.
|
||||
func (c *Config) FilterSensitiveData(content string) string {
|
||||
if c == nil {
|
||||
return content
|
||||
}
|
||||
// Check if filtering is enabled (default: true)
|
||||
if !c.Tools.IsFilterSensitiveDataEnabled() {
|
||||
return content
|
||||
@@ -254,7 +258,7 @@ func (c *Config) MarshalJSON() ([]byte, error) {
|
||||
Alias: (*Alias)(c),
|
||||
}
|
||||
|
||||
if len(c.Session.Dimensions) > 0 || len(c.Session.IdentityLinks) > 0 {
|
||||
if len(c.Session.Dimensions) > 0 || len(c.Session.IdentityLinks) > 0 || c.Session.DmScope != "" {
|
||||
sessionCfg := c.Session
|
||||
aux.Session = &sessionCfg
|
||||
}
|
||||
@@ -346,6 +350,47 @@ type DispatchSelector struct {
|
||||
type SessionConfig struct {
|
||||
Dimensions []string `json:"dimensions,omitempty"`
|
||||
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
|
||||
DmScope string `json:"dm_scope,omitempty"`
|
||||
}
|
||||
|
||||
// ApplyDmScope translates the user-facing dm_scope value into the internal
|
||||
// dimensions array that the routing layer consumes. It is a no-op when
|
||||
// DmScope is empty or when Dimensions is already set (explicit Dimensions
|
||||
// take precedence over the derived value).
|
||||
func (s *SessionConfig) ApplyDmScope() {
|
||||
if s.DmScope == "" || len(s.Dimensions) > 0 {
|
||||
return
|
||||
}
|
||||
switch s.DmScope {
|
||||
case "per-channel-peer":
|
||||
s.Dimensions = []string{"chat", "sender"}
|
||||
case "per-channel":
|
||||
s.Dimensions = []string{"chat"}
|
||||
case "per-peer":
|
||||
s.Dimensions = []string{"sender"}
|
||||
case "global":
|
||||
s.Dimensions = nil
|
||||
}
|
||||
}
|
||||
|
||||
// DeriveDmScope sets DmScope based on Dimensions when DmScope is empty.
|
||||
// This handles legacy/fresh configs that only have explicit Dimensions
|
||||
// without a corresponding DmScope value, ensuring the API response always
|
||||
// includes a dm_scope that matches the actual runtime dimensions.
|
||||
func (s *SessionConfig) DeriveDmScope() {
|
||||
if s.DmScope != "" || len(s.Dimensions) == 0 {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case slices.Equal(s.Dimensions, []string{"chat", "sender"}):
|
||||
s.DmScope = "per-channel-peer"
|
||||
case slices.Equal(s.Dimensions, []string{"chat"}):
|
||||
s.DmScope = "per-channel"
|
||||
case slices.Equal(s.Dimensions, []string{"sender"}):
|
||||
s.DmScope = "per-peer"
|
||||
}
|
||||
// Dimensions not matching any known scope mapping (custom array)
|
||||
// is fine — DmScope stays empty and the UI can handle it.
|
||||
}
|
||||
|
||||
// RoutingConfig controls the intelligent model routing feature.
|
||||
@@ -814,6 +859,12 @@ type ToolConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"ENABLED"`
|
||||
}
|
||||
|
||||
type MessageToolsConfig struct {
|
||||
ToolConfig `yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
|
||||
|
||||
MediaEnabled bool `json:"media_enabled" yaml:"-" env:"PICOCLAW_TOOLS_MESSAGE_MEDIA_ENABLED"`
|
||||
}
|
||||
|
||||
type BraveConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
||||
APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"`
|
||||
@@ -865,6 +916,31 @@ func (c *TavilyConfig) SetAPIKeys(keys []string) {
|
||||
}
|
||||
}
|
||||
|
||||
type KagiConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_KAGI_ENABLED"`
|
||||
APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_KAGI_API_KEYS"`
|
||||
BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_TOOLS_WEB_KAGI_BASE_URL"`
|
||||
MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_KAGI_MAX_RESULTS"`
|
||||
}
|
||||
|
||||
// APIKey returns the Kagi API key
|
||||
func (c *KagiConfig) APIKey() string {
|
||||
if len(c.APIKeys) == 0 {
|
||||
return ""
|
||||
}
|
||||
return c.APIKeys[0].String()
|
||||
}
|
||||
|
||||
// SetAPIKey sets the Kagi API key
|
||||
func (c *KagiConfig) SetAPIKey(key string) {
|
||||
c.APIKeys = SimpleSecureStrings(key)
|
||||
}
|
||||
|
||||
// SetAPIKeys sets the Kagi API keys
|
||||
func (c *KagiConfig) SetAPIKeys(keys []string) {
|
||||
c.APIKeys = SimpleSecureStrings(keys...)
|
||||
}
|
||||
|
||||
type DuckDuckGoConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
|
||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
|
||||
@@ -928,6 +1004,7 @@ type WebToolsConfig struct {
|
||||
ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_"`
|
||||
Brave BraveConfig `yaml:"brave,omitempty" json:"brave"`
|
||||
Tavily TavilyConfig `yaml:"tavily,omitempty" json:"tavily"`
|
||||
Kagi KagiConfig `yaml:"kagi,omitempty" json:"kagi"`
|
||||
Sogou SogouConfig `yaml:"-" json:"sogou"`
|
||||
DuckDuckGo DuckDuckGoConfig `yaml:"-" json:"duckduckgo"`
|
||||
Gemini GeminiSearchConfig `yaml:"gemini,omitempty" json:"gemini"`
|
||||
@@ -1026,7 +1103,7 @@ type ToolsConfig struct {
|
||||
InstallSkill ToolConfig `json:"install_skill" yaml:"-" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
||||
ListDir ToolConfig `json:"list_dir" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
||||
LoadImage ToolConfig `json:"load_image" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LOAD_IMAGE_"`
|
||||
Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
|
||||
Message MessageToolsConfig `json:"message" yaml:"-"`
|
||||
ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
||||
Serial ToolConfig `json:"serial" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SERIAL_"`
|
||||
SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
||||
@@ -1441,6 +1518,9 @@ func LoadConfig(path string) (*Config, error) {
|
||||
cfg.Agents.Defaults.Workspace = filepath.Join(homePath, pkg.WorkspaceName)
|
||||
}
|
||||
|
||||
cfg.Session.ApplyDmScope()
|
||||
cfg.Session.DeriveDmScope()
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -1662,6 +1742,8 @@ func ResetToDefaults(configPath string) error {
|
||||
return fmt.Errorf("backup before reset: %w", err)
|
||||
}
|
||||
cfg := DefaultConfig()
|
||||
cfg.Session.ApplyDmScope()
|
||||
cfg.Session.DeriveDmScope()
|
||||
if err := cfg.SecurityCopyFrom(configPath); err != nil {
|
||||
logger.WarnF("could not preserve security config", map[string]any{"error": err})
|
||||
}
|
||||
|
||||
@@ -907,6 +907,29 @@ func TestDefaultConfig_WorkspacePath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_AnthropicModelsUseClaudeAPIIDs verifies that first-party
|
||||
// Anthropic defaults use Claude API model IDs, not dotted display names or
|
||||
// Bedrock-style provider prefixes. See:
|
||||
// https://platform.claude.com/docs/en/about-claude/models/model-ids-and-versions
|
||||
func TestDefaultConfig_AnthropicModelsUseClaudeAPIIDs(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
checked := 0
|
||||
for _, model := range cfg.ModelList {
|
||||
if model.Provider != "anthropic" {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
if strings.Contains(model.Model, ".") {
|
||||
t.Fatalf("Anthropic default model %q uses dotted ID %q", model.ModelName, model.Model)
|
||||
}
|
||||
}
|
||||
|
||||
if checked == 0 {
|
||||
t.Fatal("DefaultConfig() missing Anthropic models")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_MaxTokens verifies max tokens has default value
|
||||
func TestDefaultConfig_MaxTokens(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
@@ -1480,6 +1503,16 @@ func TestLoadConfig_LoadImageCanBeDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_MessageMediaDisabled(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if !cfg.Tools.Message.Enabled {
|
||||
t.Fatal("DefaultConfig().Tools.Message.Enabled should be true")
|
||||
}
|
||||
if cfg.Tools.Message.MediaEnabled {
|
||||
t.Fatal("DefaultConfig().Tools.Message.MediaEnabled should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsConfig_GetFilterMinLength(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -1695,6 +1728,166 @@ func TestDefaultConfig_SessionDimensions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionConfig_ApplyDmScope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dmScope string
|
||||
dimensions []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "per-channel-peer",
|
||||
dmScope: "per-channel-peer",
|
||||
want: []string{"chat", "sender"},
|
||||
},
|
||||
{
|
||||
name: "per-channel",
|
||||
dmScope: "per-channel",
|
||||
want: []string{"chat"},
|
||||
},
|
||||
{
|
||||
name: "per-peer",
|
||||
dmScope: "per-peer",
|
||||
want: []string{"sender"},
|
||||
},
|
||||
{
|
||||
name: "global",
|
||||
dmScope: "global",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "explicit dimensions take precedence",
|
||||
dmScope: "per-channel-peer",
|
||||
dimensions: []string{"sender"},
|
||||
want: []string{"sender"},
|
||||
},
|
||||
{
|
||||
name: "empty dm_scope is no-op",
|
||||
dmScope: "",
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := &SessionConfig{
|
||||
DmScope: tt.dmScope,
|
||||
Dimensions: tt.dimensions,
|
||||
}
|
||||
s.ApplyDmScope()
|
||||
if len(s.Dimensions) != len(tt.want) {
|
||||
t.Fatalf("Dimensions = %v, want %v", s.Dimensions, tt.want)
|
||||
}
|
||||
for i, v := range tt.want {
|
||||
if s.Dimensions[i] != v {
|
||||
t.Errorf("Dimensions[%d] = %q, want %q", i, s.Dimensions[i], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionConfig_DeriveDmScope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dimensions []string
|
||||
dmScope string
|
||||
wantScope string
|
||||
}{
|
||||
{
|
||||
name: "per-channel-peer from dimensions",
|
||||
dimensions: []string{"chat", "sender"},
|
||||
wantScope: "per-channel-peer",
|
||||
},
|
||||
{
|
||||
name: "per-channel from dimensions",
|
||||
dimensions: []string{"chat"},
|
||||
wantScope: "per-channel",
|
||||
},
|
||||
{
|
||||
name: "per-peer from dimensions",
|
||||
dimensions: []string{"sender"},
|
||||
wantScope: "per-peer",
|
||||
},
|
||||
{
|
||||
name: "custom dimensions does not set scope",
|
||||
dimensions: []string{"chat", "extra"},
|
||||
wantScope: "",
|
||||
},
|
||||
{
|
||||
name: "empty dimensions does not set scope",
|
||||
dimensions: nil,
|
||||
wantScope: "",
|
||||
},
|
||||
{
|
||||
name: "existing dm_scope is not overwritten",
|
||||
dimensions: []string{"chat", "sender"},
|
||||
dmScope: "per-channel",
|
||||
wantScope: "per-channel",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := &SessionConfig{
|
||||
DmScope: tt.dmScope,
|
||||
Dimensions: tt.dimensions,
|
||||
}
|
||||
s.DeriveDmScope()
|
||||
if s.DmScope != tt.wantScope {
|
||||
t.Errorf("DmScope = %q, want %q", s.DmScope, tt.wantScope)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionConfig_ApplyDmScope_ClearsStaleDimensions(t *testing.T) {
|
||||
// Simulates the PATCH handler scenario: dm_scope changed but stale
|
||||
// dimensions remain from the old scope. After clearing dimensions,
|
||||
// ApplyDmScope should re-derive from the new dm_scope.
|
||||
tests := []struct {
|
||||
name string
|
||||
dmScope string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "per-channel-peer to per-channel",
|
||||
dmScope: "per-channel",
|
||||
want: []string{"chat"},
|
||||
},
|
||||
{
|
||||
name: "per-channel-peer to per-peer",
|
||||
dmScope: "per-peer",
|
||||
want: []string{"sender"},
|
||||
},
|
||||
{
|
||||
name: "per-channel-peer to global",
|
||||
dmScope: "global",
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := &SessionConfig{
|
||||
DmScope: tt.dmScope,
|
||||
Dimensions: []string{"chat", "sender"}, // stale from per-channel-peer
|
||||
}
|
||||
// Simulate what the PATCH handler does: clear dimensions when dm_scope changes
|
||||
s.Dimensions = nil
|
||||
s.ApplyDmScope()
|
||||
if len(s.Dimensions) != len(tt.want) {
|
||||
t.Fatalf("Dimensions = %v, want %v", s.Dimensions, tt.want)
|
||||
}
|
||||
for i, v := range tt.want {
|
||||
if s.Dimensions[i] != v {
|
||||
t.Errorf("Dimensions[%d] = %q, want %q", i, s.Dimensions[i], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_WorkspacePath_Default(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_HOME", "")
|
||||
|
||||
|
||||
+11
-3
@@ -88,7 +88,7 @@ func DefaultConfig() *Config {
|
||||
{
|
||||
ModelName: "claude-sonnet-4.6",
|
||||
Provider: "anthropic",
|
||||
Model: "claude-sonnet-4.6",
|
||||
Model: "claude-sonnet-4-6",
|
||||
APIBase: "https://api.anthropic.com/v1",
|
||||
},
|
||||
|
||||
@@ -333,6 +333,11 @@ func DefaultConfig() *Config {
|
||||
Enabled: false,
|
||||
MaxResults: 5,
|
||||
},
|
||||
Kagi: KagiConfig{
|
||||
Enabled: false,
|
||||
BaseURL: "https://kagi.com/api/v1/search",
|
||||
MaxResults: 5,
|
||||
},
|
||||
Sogou: SogouConfig{
|
||||
Enabled: true,
|
||||
MaxResults: 5,
|
||||
@@ -447,8 +452,11 @@ func DefaultConfig() *Config {
|
||||
LoadImage: ToolConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
Message: ToolConfig{
|
||||
Enabled: true,
|
||||
Message: MessageToolsConfig{
|
||||
ToolConfig: ToolConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
MediaEnabled: false,
|
||||
},
|
||||
ReadFile: ReadFileToolConfig{
|
||||
Enabled: true,
|
||||
|
||||
@@ -51,7 +51,7 @@ channels:
|
||||
token: "your-discord-bot-token"
|
||||
|
||||
# Web Tool Keys
|
||||
# Brave, Tavily, Perplexity: Use 'api_keys' array
|
||||
# Brave, Tavily, Perplexity, Kagi: Use 'api_keys' array
|
||||
# GLMSearch, BaiduSearch: Use 'api_key' single string
|
||||
web:
|
||||
|
||||
@@ -65,6 +65,9 @@ web:
|
||||
perplexity:
|
||||
api_keys:
|
||||
- "pplx-your-perplexity-api-key" # Single key in array format
|
||||
kagi:
|
||||
api_keys:
|
||||
- "your-kagi-api-key" # Single key in array format
|
||||
glm_search:
|
||||
api_key: "your-glm-search-api-key" # Single key (not array)
|
||||
baidu_search:
|
||||
@@ -239,7 +242,7 @@ channels:
|
||||
|
||||
## Web Tool API Keys
|
||||
|
||||
**Brave, Tavily, Perplexity:**
|
||||
**Brave, Tavily, Perplexity, Kagi:**
|
||||
```yaml
|
||||
web:
|
||||
|
||||
@@ -253,6 +256,9 @@ web:
|
||||
perplexity:
|
||||
api_keys:
|
||||
- "pplx-key"
|
||||
kagi:
|
||||
api_keys:
|
||||
- "kagi-key"
|
||||
|
||||
```
|
||||
Use `api_keys` (plural) array format.
|
||||
@@ -443,7 +449,7 @@ web:
|
||||
|
||||
## Single Key Format
|
||||
|
||||
**Models, Brave, Tavily, Perplexity:**
|
||||
**Models, Brave, Tavily, Perplexity, Kagi:**
|
||||
```yaml
|
||||
model_list:
|
||||
|
||||
@@ -565,7 +571,7 @@ and .security.yml values.
|
||||
## Multiple API Keys Not Working
|
||||
- Ensure you're using `api_keys` (plural) in .security.yml for models and web tools (except GLMSearch/BaiduSearch)
|
||||
- Check that the array format is correct in YAML (proper indentation with dashes)
|
||||
- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format)
|
||||
- Remember: Models, Brave, Tavily, Perplexity, Kagi MUST use `api_keys` (array format)
|
||||
- GLMSearch and BaiduSearch MUST use `api_key` (single string format)
|
||||
|
||||
## Keys Not Being Applied
|
||||
|
||||
@@ -88,7 +88,12 @@ func ResolveGatewayLogLevel(path string) string {
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
_ = json.Unmarshal(data, &cfg)
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
logger.WarnCF("config", "failed to parse gateway config, using defaults", map[string]any{
|
||||
"path": path,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if envLevel := os.Getenv("PICOCLAW_LOG_LEVEL"); envLevel != "" {
|
||||
|
||||
@@ -501,7 +501,10 @@ func mergeModelListsWithMap(mainML []any, secML map[string]any) error {
|
||||
for i, m := range mainML {
|
||||
if mVal, ok := m.(map[string]any); ok {
|
||||
if name, hasName := mVal["model_name"]; hasName {
|
||||
nameStr := name.(string)
|
||||
nameStr, ok := name.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("model_name must be a string, got %T", name)
|
||||
}
|
||||
index := countMap[nameStr]
|
||||
indexedKeys[fmt.Sprintf("%s:%d", nameStr, index)] = i
|
||||
if _, ok := indexedKeys[nameStr]; !ok {
|
||||
|
||||
@@ -191,6 +191,10 @@ func TestAllSecurityKeysAccessible(t *testing.T) {
|
||||
err = os.WriteFile(perplexityAPIKeyFile, []byte("pplx-perplexity-from-file-22222"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
kagiAPIKeyFile := filepath.Join(tmpDir, "kagi_api_key.txt")
|
||||
err = os.WriteFile(kagiAPIKeyFile, []byte("kagi-from-file-33333"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
githubTokenFile := filepath.Join(tmpDir, "github_token.txt")
|
||||
err = os.WriteFile(githubTokenFile, []byte("ghp-github-from-file-abc123"), 0o600)
|
||||
require.NoError(t, err)
|
||||
@@ -270,6 +274,9 @@ func TestAllSecurityKeysAccessible(t *testing.T) {
|
||||
"perplexity": {
|
||||
"enabled": true
|
||||
},
|
||||
"kagi": {
|
||||
"enabled": true
|
||||
},
|
||||
"glm_search": {
|
||||
"enabled": true
|
||||
}
|
||||
@@ -331,6 +338,9 @@ web:
|
||||
perplexity:
|
||||
api_keys:
|
||||
- "file://perplexity_api_key.txt"
|
||||
kagi:
|
||||
api_keys:
|
||||
- "file://kagi_api_key.txt"
|
||||
glm_search:
|
||||
api_key: "glm-test-glm-search-key"
|
||||
|
||||
@@ -456,6 +466,9 @@ skills:
|
||||
assert.Equal(t, "pplx-perplexity-from-file-22222", cfg.Tools.Web.Perplexity.APIKey())
|
||||
t.Logf("Perplexity APIKey(): %s", cfg.Tools.Web.Perplexity.APIKey())
|
||||
|
||||
assert.Equal(t, "kagi-from-file-33333", cfg.Tools.Web.Kagi.APIKey())
|
||||
t.Logf("Kagi APIKey(): %s", cfg.Tools.Web.Kagi.APIKey())
|
||||
|
||||
// GLM Search - Note: GLM uses SetAPIKey (lowercase) internally
|
||||
t.Logf("GLMSearch APIKey(): %s", cfg.Tools.Web.GLMSearch.APIKey.String())
|
||||
assert.Equal(t, "glm-test-glm-search-key", cfg.Tools.Web.GLMSearch.APIKey.String())
|
||||
|
||||
+61
-2
@@ -447,14 +447,37 @@ func (cs *CronService) AddJob(
|
||||
return &job, nil
|
||||
}
|
||||
|
||||
func (cs *CronService) GetJob(jobID string) (*CronJob, bool) {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
|
||||
for i := range cs.store.Jobs {
|
||||
if cs.store.Jobs[i].ID == jobID {
|
||||
jobCopy := cloneCronJob(cs.store.Jobs[i])
|
||||
return &jobCopy, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (cs *CronService) UpdateJob(job *CronJob) error {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
for i := range cs.store.Jobs {
|
||||
if cs.store.Jobs[i].ID == job.ID {
|
||||
cs.store.Jobs[i] = *job
|
||||
cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli()
|
||||
previous := cs.store.Jobs[i]
|
||||
updated := cloneCronJob(*job)
|
||||
now := time.Now().UnixMilli()
|
||||
updated.UpdatedAtMS = now
|
||||
if updated.Enabled {
|
||||
if previous.Enabled != updated.Enabled || !sameSchedule(previous.Schedule, updated.Schedule) {
|
||||
updated.State.NextRunAtMS = cs.computeNextRun(&updated.Schedule, now)
|
||||
}
|
||||
} else {
|
||||
updated.State.NextRunAtMS = nil
|
||||
}
|
||||
cs.store.Jobs[i] = updated
|
||||
|
||||
cs.notify()
|
||||
|
||||
@@ -464,6 +487,42 @@ func (cs *CronService) UpdateJob(job *CronJob) error {
|
||||
return fmt.Errorf("job not found")
|
||||
}
|
||||
|
||||
func cloneCronJob(job CronJob) CronJob {
|
||||
clone := job
|
||||
if job.Schedule.AtMS != nil {
|
||||
atMS := *job.Schedule.AtMS
|
||||
clone.Schedule.AtMS = &atMS
|
||||
}
|
||||
if job.Schedule.EveryMS != nil {
|
||||
everyMS := *job.Schedule.EveryMS
|
||||
clone.Schedule.EveryMS = &everyMS
|
||||
}
|
||||
if job.State.NextRunAtMS != nil {
|
||||
nextRunAtMS := *job.State.NextRunAtMS
|
||||
clone.State.NextRunAtMS = &nextRunAtMS
|
||||
}
|
||||
if job.State.LastRunAtMS != nil {
|
||||
lastRunAtMS := *job.State.LastRunAtMS
|
||||
clone.State.LastRunAtMS = &lastRunAtMS
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func sameSchedule(a, b CronSchedule) bool {
|
||||
return a.Kind == b.Kind &&
|
||||
sameInt64(a.AtMS, b.AtMS) &&
|
||||
sameInt64(a.EveryMS, b.EveryMS) &&
|
||||
a.Expr == b.Expr &&
|
||||
a.TZ == b.TZ
|
||||
}
|
||||
|
||||
func sameInt64(a, b *int64) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func (cs *CronService) RemoveJob(jobID string) bool {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
@@ -82,6 +82,136 @@ func TestCronService_CRUD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronService_GetJobReturnsCopy(t *testing.T) {
|
||||
cs, path := setupService(nil)
|
||||
defer os.Remove(path)
|
||||
|
||||
everyMS := int64(60_000)
|
||||
job, err := cs.AddJob("Task1", CronSchedule{Kind: "every", EveryMS: &everyMS}, "msg", "ch", "to")
|
||||
if err != nil {
|
||||
t.Fatalf("AddJob failed: %v", err)
|
||||
}
|
||||
if job.State.NextRunAtMS == nil {
|
||||
t.Fatal("expected initial next run")
|
||||
}
|
||||
nextRun := *job.State.NextRunAtMS
|
||||
|
||||
got, ok := cs.GetJob(job.ID)
|
||||
if !ok {
|
||||
t.Fatal("GetJob should find job")
|
||||
}
|
||||
got.Name = "mutated"
|
||||
got.Payload.Message = "changed"
|
||||
if got.Schedule.EveryMS != nil {
|
||||
*got.Schedule.EveryMS = 120_000
|
||||
}
|
||||
if got.State.NextRunAtMS != nil {
|
||||
*got.State.NextRunAtMS = time.Now().Add(3 * time.Hour).UnixMilli()
|
||||
}
|
||||
|
||||
again, ok := cs.GetJob(job.ID)
|
||||
if !ok {
|
||||
t.Fatal("GetJob should still find job")
|
||||
}
|
||||
if again.Name != "Task1" || again.Payload.Message != "msg" {
|
||||
t.Fatalf("GetJob should return a copy, got %+v", again)
|
||||
}
|
||||
if again.Schedule.EveryMS == nil || *again.Schedule.EveryMS != everyMS {
|
||||
t.Fatalf("GetJob should not alias schedule pointers, got %+v", again.Schedule)
|
||||
}
|
||||
if again.State.NextRunAtMS == nil || *again.State.NextRunAtMS != nextRun {
|
||||
t.Fatalf("GetJob should not alias state pointers, got %+v", again.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronService_UpdateJobRecomputesNextRunOnScheduleOrEnabledChange(t *testing.T) {
|
||||
cs, path := setupService(nil)
|
||||
defer os.Remove(path)
|
||||
|
||||
at := time.Now().Add(time.Hour).UnixMilli()
|
||||
job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", "ch", "to")
|
||||
if err != nil {
|
||||
t.Fatalf("AddJob failed: %v", err)
|
||||
}
|
||||
if job.State.NextRunAtMS == nil {
|
||||
t.Fatal("expected initial next run")
|
||||
}
|
||||
initialNextRun := *job.State.NextRunAtMS
|
||||
|
||||
everyMS := int64(30_000)
|
||||
job.Schedule = CronSchedule{Kind: "every", EveryMS: &everyMS}
|
||||
if err := cs.UpdateJob(job); err != nil {
|
||||
t.Fatalf("UpdateJob schedule failed: %v", err)
|
||||
}
|
||||
updated, ok := cs.GetJob(job.ID)
|
||||
if !ok {
|
||||
t.Fatal("updated job not found")
|
||||
}
|
||||
if updated.State.NextRunAtMS == nil {
|
||||
t.Fatal("expected recomputed next run after schedule change")
|
||||
}
|
||||
if *updated.State.NextRunAtMS == initialNextRun {
|
||||
t.Fatalf("next run should be recomputed, still %d", initialNextRun)
|
||||
}
|
||||
|
||||
if disabled := cs.EnableJob(job.ID, false); disabled == nil {
|
||||
t.Fatal("EnableJob(false) returned nil")
|
||||
}
|
||||
disabled, ok := cs.GetJob(job.ID)
|
||||
if !ok {
|
||||
t.Fatal("disabled job not found")
|
||||
}
|
||||
disabled.Enabled = true
|
||||
if err := cs.UpdateJob(disabled); err != nil {
|
||||
t.Fatalf("UpdateJob enabled failed: %v", err)
|
||||
}
|
||||
reenabled, ok := cs.GetJob(job.ID)
|
||||
if !ok {
|
||||
t.Fatal("reenabled job not found")
|
||||
}
|
||||
if !reenabled.Enabled || reenabled.State.NextRunAtMS == nil {
|
||||
t.Fatalf("expected enabled job with next run, got %+v", reenabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronService_UpdateJobPreservesRunStateOnPayloadOnlyChange(t *testing.T) {
|
||||
cs, path := setupService(nil)
|
||||
defer os.Remove(path)
|
||||
|
||||
everyMS := int64(60_000)
|
||||
job, err := cs.AddJob("Task1", CronSchedule{Kind: "every", EveryMS: &everyMS}, "msg", "ch", "to")
|
||||
if err != nil {
|
||||
t.Fatalf("AddJob failed: %v", err)
|
||||
}
|
||||
lastRun := time.Now().Add(-time.Minute).UnixMilli()
|
||||
job.State.LastRunAtMS = &lastRun
|
||||
job.State.LastStatus = "ok"
|
||||
job.State.LastError = "previous"
|
||||
if job.State.NextRunAtMS == nil {
|
||||
t.Fatal("expected next run before update")
|
||||
}
|
||||
nextRun := *job.State.NextRunAtMS
|
||||
|
||||
job.Payload.Message = "updated msg"
|
||||
if err := cs.UpdateJob(job); err != nil {
|
||||
t.Fatalf("UpdateJob failed: %v", err)
|
||||
}
|
||||
|
||||
updated, ok := cs.GetJob(job.ID)
|
||||
if !ok {
|
||||
t.Fatal("updated job not found")
|
||||
}
|
||||
if updated.State.LastRunAtMS == nil || *updated.State.LastRunAtMS != lastRun {
|
||||
t.Fatalf("last run changed: %+v", updated.State)
|
||||
}
|
||||
if updated.State.LastStatus != "ok" || updated.State.LastError != "previous" {
|
||||
t.Fatalf("last status changed: %+v", updated.State)
|
||||
}
|
||||
if updated.State.NextRunAtMS == nil || *updated.State.NextRunAtMS != nextRun {
|
||||
t.Fatalf("next run should be preserved: before=%d after=%+v", nextRun, updated.State.NextRunAtMS)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Test Cron Expression Calculation Logic
|
||||
func TestCronService_ComputeNextRun(t *testing.T) {
|
||||
cs, path := setupService(nil)
|
||||
|
||||
@@ -67,7 +67,10 @@ type DefaultDraftGenerator struct {
|
||||
func NewDefaultDraftGenerator(workspace string) *DefaultDraftGenerator {
|
||||
builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))
|
||||
if builtinSkillsDir == "" {
|
||||
wd, _ := os.Getwd()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
wd = config.GetHome()
|
||||
}
|
||||
builtinSkillsDir = filepath.Join(wd, "skills")
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,10 @@ type SkillsRecaller struct {
|
||||
func NewSkillsRecaller(workspace string) *SkillsRecaller {
|
||||
builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))
|
||||
if builtinSkillsDir == "" {
|
||||
wd, _ := os.Getwd()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
wd = config.GetHome()
|
||||
}
|
||||
builtinSkillsDir = filepath.Join(wd, "skills")
|
||||
}
|
||||
|
||||
|
||||
@@ -222,6 +222,11 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// All services (channels + shared HTTP server) are up; mark the health
|
||||
// server ready so GET /ready reports "ready". The health endpoints are
|
||||
// mounted on the shared gateway mux, so Health.Server.Start() (which would
|
||||
// otherwise set this) is never called — we flip the flag explicitly here.
|
||||
runningServices.HealthServer.SetReady(true)
|
||||
publishGatewayEvent(agentLoop, runtimeevents.KindGatewayReady, startedAt, nil)
|
||||
closeListeners = false
|
||||
|
||||
|
||||
@@ -62,7 +62,10 @@ func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig,
|
||||
if !isolation.Enabled || cmd == nil || cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
resourcesAny, _ := windowsPendingResources.LoadAndDelete(cmd)
|
||||
resourcesAny, loaded := windowsPendingResources.LoadAndDelete(cmd)
|
||||
if !loaded {
|
||||
return nil
|
||||
}
|
||||
resources, _ := resourcesAny.(windowsProcessResources)
|
||||
// Job objects can only be attached after the process exists, so the Windows
|
||||
// backend finishes isolation in this post-start hook.
|
||||
|
||||
@@ -62,7 +62,7 @@ func (s *isolatedPipeRWC) Write(p []byte) (n int, err error) {
|
||||
|
||||
func (s *isolatedPipeRWC) Close() error {
|
||||
if err := s.stdin.Close(); err != nil {
|
||||
return fmt.Errorf("closing stdin: %v", err)
|
||||
return fmt.Errorf("closing stdin: %w", err)
|
||||
}
|
||||
resChan := make(chan error, 1)
|
||||
go func() {
|
||||
@@ -205,7 +205,7 @@ func (c *isolatedIOConn) Write(ctx context.Context, msg jsonrpc.Message) error {
|
||||
defer c.writeMu.Unlock()
|
||||
data, err := jsonrpc.EncodeMessage(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling message: %v", err)
|
||||
return fmt.Errorf("marshaling message: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
_, err = c.rwc.Write(data)
|
||||
|
||||
+50
-1
@@ -4,6 +4,8 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
@@ -227,6 +229,11 @@ func (s *JSONLStore) UpsertSessionMeta(
|
||||
|
||||
// PromoteAliasHistory atomically promotes the first non-empty alias session
|
||||
// into the canonical session when the canonical session is still empty.
|
||||
//
|
||||
// Main-session aliases (e.g. "agent:main:main" or its opaque form) are
|
||||
// skipped during promotion. The main session is a shared global fallback
|
||||
// and promoting its history into individual sessions would attach stale
|
||||
// messages to every new Web UI session (issue #2972).
|
||||
func (s *JSONLStore) PromoteAliasHistory(
|
||||
_ context.Context,
|
||||
sessionKey string,
|
||||
@@ -240,6 +247,9 @@ func (s *JSONLStore) PromoteAliasHistory(
|
||||
|
||||
aliases = normalizeAliases(sessionKey, aliases)
|
||||
for _, alias := range aliases {
|
||||
if isMainSessionAlias(alias) {
|
||||
continue
|
||||
}
|
||||
unlock := s.lockSessionPair(sessionKey, alias)
|
||||
promoted, err := s.promoteAliasHistoryLocked(sessionKey, alias, scope, aliases)
|
||||
unlock()
|
||||
@@ -251,6 +261,34 @@ func (s *JSONLStore) PromoteAliasHistory(
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// isMainSessionAlias reports whether alias is the legacy or opaque main-session
|
||||
// key. The main session ("agent:<agent>:main") is a shared fallback and should
|
||||
// not have its history promoted into individual per-channel sessions.
|
||||
func isMainSessionAlias(alias string) bool {
|
||||
if alias == "" {
|
||||
return false
|
||||
}
|
||||
// Legacy form: "agent:main:main" (exactly 3 colon-separated parts)
|
||||
// Must not match "agent:sales:direct:main" etc.
|
||||
if strings.HasPrefix(alias, "agent:") && strings.HasSuffix(alias, ":main") {
|
||||
parts := strings.SplitN(alias, ":", 4)
|
||||
if len(parts) == 3 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Opaque form: "sk_v1_" + SHA256("agent:main:main")
|
||||
if strings.HasPrefix(alias, "sk_v1_") {
|
||||
for _, agentID := range []string{"main", "Main", "MAIN"} {
|
||||
legacy := "agent:" + agentID + ":main"
|
||||
hash := sha256.Sum256([]byte(legacy))
|
||||
if "sk_v1_"+hex.EncodeToString(hash[:]) == alias {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolveSessionKey returns the canonical session key for a candidate key.
|
||||
// It short-circuits direct canonical keys when possible, then scans metadata
|
||||
// once to resolve aliases or canonical metadata keys.
|
||||
@@ -561,6 +599,12 @@ func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error {
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if msg.CreatedAt == nil {
|
||||
msg.CreatedAt = &now
|
||||
}
|
||||
|
||||
// Append the message as a single JSON line.
|
||||
line, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
@@ -598,7 +642,6 @@ func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
if meta.Count == 0 && meta.CreatedAt.IsZero() {
|
||||
meta.CreatedAt = now
|
||||
}
|
||||
@@ -726,6 +769,12 @@ func (s *JSONLStore) SetHistory(
|
||||
meta.Count = len(history)
|
||||
meta.UpdatedAt = now
|
||||
|
||||
for i := range history {
|
||||
if history[i].CreatedAt == nil {
|
||||
history[i].CreatedAt = &now
|
||||
}
|
||||
}
|
||||
|
||||
// Write meta BEFORE rewriting the JSONL file. If we crash between
|
||||
// the two writes, meta has Skip=0 and the old file is still intact,
|
||||
// so GetHistory reads from line 1 — returning "too many" messages
|
||||
|
||||
@@ -1058,6 +1058,143 @@ func TestMultipleSessions_Isolation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_SetsCreatedAtWhenNil(t *testing.T) {
|
||||
type writeOp struct {
|
||||
name string
|
||||
fn func(store *JSONLStore, key string) (expectedCount int)
|
||||
}
|
||||
|
||||
ops := []writeOp{
|
||||
{
|
||||
name: "AddMessage",
|
||||
fn: func(store *JSONLStore, key string) int {
|
||||
if err := store.AddMessage(context.Background(), key, "user", "hello"); err != nil {
|
||||
t.Fatalf("AddMessage: %v", err)
|
||||
}
|
||||
return 1
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AddFullMessage",
|
||||
fn: func(store *JSONLStore, key string) int {
|
||||
if err := store.AddFullMessage(context.Background(), key, providers.Message{
|
||||
Role: "user",
|
||||
Content: "hello from full",
|
||||
}); err != nil {
|
||||
t.Fatalf("AddFullMessage: %v", err)
|
||||
}
|
||||
return 1
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SetHistory",
|
||||
fn: func(store *JSONLStore, key string) int {
|
||||
if err := store.SetHistory(context.Background(), key, []providers.Message{
|
||||
{Role: "user", Content: "msg1"},
|
||||
{Role: "assistant", Content: "msg2"},
|
||||
}); err != nil {
|
||||
t.Fatalf("SetHistory: %v", err)
|
||||
}
|
||||
return 2
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, op := range ops {
|
||||
t.Run(op.name, func(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
key := "s1"
|
||||
|
||||
before := time.Now().Add(-time.Second)
|
||||
expectedCount := op.fn(store, key)
|
||||
after := time.Now().Add(time.Second)
|
||||
|
||||
history, err := store.GetHistory(context.Background(), key)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHistory: %v", err)
|
||||
}
|
||||
if len(history) != expectedCount {
|
||||
t.Fatalf("expected %d messages, got %d", expectedCount, len(history))
|
||||
}
|
||||
for i := range history {
|
||||
if history[i].CreatedAt == nil || history[i].CreatedAt.IsZero() {
|
||||
t.Errorf("message %d CreatedAt is zero — not set by %s", i, op.name)
|
||||
}
|
||||
if history[i].CreatedAt.Before(before) || history[i].CreatedAt.After(after) {
|
||||
t.Errorf(
|
||||
"message %d CreatedAt %v outside expected window [%v, %v]",
|
||||
i, history[i].CreatedAt, before, after,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_PreservesExistingCreatedAt(t *testing.T) {
|
||||
t1 := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
t2 := time.Date(2026, 1, 1, 11, 0, 0, 0, time.UTC)
|
||||
|
||||
type writeOp struct {
|
||||
name string
|
||||
fn func(store *JSONLStore, key string)
|
||||
wantTimes []time.Time
|
||||
}
|
||||
|
||||
ops := []writeOp{
|
||||
{
|
||||
name: "AddFullMessage",
|
||||
fn: func(store *JSONLStore, key string) {
|
||||
if err := store.AddFullMessage(context.Background(), key, providers.Message{
|
||||
Role: "user",
|
||||
Content: "custom time",
|
||||
CreatedAt: &t1,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddFullMessage: %v", err)
|
||||
}
|
||||
},
|
||||
wantTimes: []time.Time{t1},
|
||||
},
|
||||
{
|
||||
name: "SetHistory",
|
||||
fn: func(store *JSONLStore, key string) {
|
||||
if err := store.SetHistory(context.Background(), key, []providers.Message{
|
||||
{Role: "user", Content: "msg1", CreatedAt: &t1},
|
||||
{Role: "assistant", Content: "msg2", CreatedAt: &t2},
|
||||
}); err != nil {
|
||||
t.Fatalf("SetHistory: %v", err)
|
||||
}
|
||||
},
|
||||
wantTimes: []time.Time{t1, t2},
|
||||
},
|
||||
}
|
||||
|
||||
for _, op := range ops {
|
||||
t.Run(op.name, func(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
key := "s1"
|
||||
|
||||
op.fn(store, key)
|
||||
|
||||
history, err := store.GetHistory(context.Background(), key)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHistory: %v", err)
|
||||
}
|
||||
if len(history) != len(op.wantTimes) {
|
||||
t.Fatalf("expected %d messages, got %d", len(op.wantTimes), len(history))
|
||||
}
|
||||
for i, want := range op.wantTimes {
|
||||
if history[i].CreatedAt == nil || !history[i].CreatedAt.Equal(want) {
|
||||
t.Errorf(
|
||||
"message %d CreatedAt = %v, want %v (should preserve caller-provided time)",
|
||||
i, history[i].CreatedAt, want,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAddMessage(b *testing.B) {
|
||||
dir := b.TempDir()
|
||||
store, err := NewJSONLStore(dir)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -149,8 +150,10 @@ func CopyFile(src, dst string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
_, copyErr := io.Copy(dstFile, srcFile)
|
||||
if closeErr := dstFile.Close(); closeErr != nil && copyErr == nil {
|
||||
return fmt.Errorf("close destination file %s: %w", dst, closeErr)
|
||||
}
|
||||
return copyErr
|
||||
}
|
||||
|
||||
+8
-1
@@ -64,7 +64,14 @@ func WritePidFile(homePath, host string, port int) (*PidFileData, error) {
|
||||
// pass the isProcessRunning check, blocking new gateway starts.
|
||||
// Treat recorded PID 1 as always stale.
|
||||
if data.PID != 1 && isProcessRunning(data.PID) {
|
||||
return nil, fmt.Errorf("gateway is already running (PID: %d, version: %s)", data.PID, data.Version)
|
||||
// Verify the process is actually a picoclaw instance.
|
||||
// If the PID was reused by an unrelated process
|
||||
// (e.g. systemd-resolved after a kill -9), treat
|
||||
// the PID file as stale and proceed with startup.
|
||||
if isPicoclawProcess(data.PID) {
|
||||
return nil, fmt.Errorf("gateway is already running (PID: %d, version: %s)", data.PID, data.Version)
|
||||
}
|
||||
logger.Warnf("found pid file (PID: %d) but process is not picoclaw", data.PID)
|
||||
}
|
||||
logger.Warnf("not running (PID: %d) so will remove the pid file: %s", data.PID, pidPath)
|
||||
}
|
||||
|
||||
+16
-1
@@ -4,7 +4,9 @@ package pid
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
@@ -18,7 +20,7 @@ func isProcessRunning(pid int) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// Signal(nil) does not kill the process but checks existence on Unix.
|
||||
// Signal(0) does not kill the process but checks existence on Unix.
|
||||
err = p.Signal(syscall.Signal(0))
|
||||
if err == nil {
|
||||
return true
|
||||
@@ -27,3 +29,16 @@ func isProcessRunning(pid int) bool {
|
||||
// EPERM means the process exists but we are not allowed to signal it.
|
||||
return errors.As(err, &errno) && errno == syscall.EPERM
|
||||
}
|
||||
|
||||
// isPicoclawProcess reads /proc/<pid>/comm to confirm the process name
|
||||
// contains "picoclaw". Returns false when the comm file can be read and
|
||||
// the name does not match (e.g., PID was reused by an unrelated process).
|
||||
// Returns true if /proc/<pid>/comm is unreadable so the call site falls
|
||||
// back to trusting the liveness check alone.
|
||||
func isPicoclawProcess(pid int) bool {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
|
||||
if err != nil {
|
||||
return true // cannot verify — trust liveness check
|
||||
}
|
||||
return strings.Contains(strings.TrimSpace(string(data)), "picoclaw")
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package pid
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
@@ -12,6 +13,7 @@ var (
|
||||
procOpenProcess = kernel32.NewProc("OpenProcess")
|
||||
procGetExitCodeProcess = kernel32.NewProc("GetExitCodeProcess")
|
||||
procCloseHandle = kernel32.NewProc("CloseHandle")
|
||||
procQueryFullProcessImageNameW = kernel32.NewProc("QueryFullProcessImageNameW")
|
||||
processQueryLimitedInformation = uint32(0x1000)
|
||||
stillActive = uint32(259)
|
||||
)
|
||||
@@ -40,3 +42,33 @@ func isProcessRunning(pid int) bool {
|
||||
}
|
||||
return exitCode == stillActive
|
||||
}
|
||||
|
||||
// isPicoclawProcess uses QueryFullProcessImageNameW to confirm the
|
||||
// process image name contains "picoclaw". Returns false when the name
|
||||
// clearly does not match. Returns true if the query fails, falling
|
||||
// back to trusting the liveness check alone.
|
||||
func isPicoclawProcess(pid int) bool {
|
||||
handle, _, _ := procOpenProcess.Call(
|
||||
uintptr(processQueryLimitedInformation),
|
||||
0,
|
||||
uintptr(pid),
|
||||
)
|
||||
if handle == 0 {
|
||||
return true // cannot open — trust liveness check
|
||||
}
|
||||
defer procCloseHandle.Call(handle)
|
||||
|
||||
var buf [260]uint16
|
||||
var size uint32 = 260
|
||||
ret, _, _ := procQueryFullProcessImageNameW.Call(
|
||||
uintptr(handle),
|
||||
0, // WIN32_NAME_FORMAT
|
||||
uintptr(unsafe.Pointer(&buf[0])),
|
||||
uintptr(unsafe.Pointer(&size)),
|
||||
)
|
||||
if ret == 0 {
|
||||
return true // cannot verify — trust liveness check
|
||||
}
|
||||
name := strings.ToLower(syscall.UTF16ToString(buf[:size]))
|
||||
return strings.Contains(name, "picoclaw")
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ func buildParams(
|
||||
apiModel := strings.ReplaceAll(model, ".", "-")
|
||||
|
||||
params := anthropic.MessageNewParams{
|
||||
Model: anthropic.Model(apiModel),
|
||||
Model: apiModel,
|
||||
Messages: anthropicMessages,
|
||||
MaxTokens: maxTokens,
|
||||
}
|
||||
@@ -262,7 +262,9 @@ func applyThinkingConfig(params *anthropic.MessageNewParams, level string) {
|
||||
params.Temperature = anthropic.MessageNewParams{}.Temperature
|
||||
|
||||
if level == "adaptive" {
|
||||
adaptive := anthropic.NewThinkingConfigAdaptiveParam()
|
||||
adaptive := anthropic.ThinkingConfigAdaptiveParam{
|
||||
Display: anthropic.ThinkingConfigAdaptiveDisplaySummarized,
|
||||
}
|
||||
params.Thinking = anthropic.ThinkingConfigParamUnion{OfAdaptive: &adaptive}
|
||||
params.OutputConfig = anthropic.OutputConfigParam{
|
||||
Effort: anthropic.OutputConfigEffortHigh,
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestBuildParams_BasicMessage(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("buildParams() error: %v", err)
|
||||
}
|
||||
if string(params.Model) != "claude-sonnet-4-6" {
|
||||
if params.Model != "claude-sonnet-4-6" {
|
||||
t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-6")
|
||||
}
|
||||
if params.MaxTokens != 1024 {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build azidentity
|
||||
|
||||
// Entra ID (DefaultAzureCredential) auth adapter.
|
||||
// Built only when -tags azidentity is supplied; otherwise identity_stub.go
|
||||
// satisfies the same exported API with a friendly error.
|
||||
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
||||
)
|
||||
|
||||
// azureOpenAIScope is the OAuth scope for Azure OpenAI (Cognitive Services).
|
||||
// Service-wide scope, so it covers all regions including sovereign clouds.
|
||||
const azureOpenAIScope = "https://cognitiveservices.azure.com/.default"
|
||||
|
||||
// NewProviderWithIdentity creates an Azure OpenAI provider authenticated via
|
||||
// the DefaultAzureCredential chain (env vars, workload identity, managed
|
||||
// identity, Azure CLI, ...). Construction itself only fails if the credential
|
||||
// chain cannot be built; misconfigured environments surface their error on
|
||||
// the first Chat call when GetToken is invoked.
|
||||
func NewProviderWithIdentity(apiBase, proxy, userAgent string, opts ...Option) (*Provider, error) {
|
||||
cred, err := azidentity.NewDefaultAzureCredential(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating azure default credential: %w", err)
|
||||
}
|
||||
|
||||
ts := func(ctx context.Context) (string, error) {
|
||||
tok, err := cred.GetToken(ctx, policy.TokenRequestOptions{
|
||||
Scopes: []string{azureOpenAIScope},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("acquiring azure access token: %w", err)
|
||||
}
|
||||
return tok.Token, nil
|
||||
}
|
||||
|
||||
return NewProviderWithTokenSource(apiBase, proxy, userAgent, ts, opts...), nil
|
||||
}
|
||||
|
||||
// NewProviderWithIdentityAndTimeout mirrors NewProviderWithTimeout for the
|
||||
// identity auth path.
|
||||
func NewProviderWithIdentityAndTimeout(
|
||||
apiBase, proxy, userAgent string,
|
||||
requestTimeoutSeconds int,
|
||||
) (*Provider, error) {
|
||||
return NewProviderWithIdentity(
|
||||
apiBase, proxy, userAgent,
|
||||
WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build !azidentity
|
||||
|
||||
// Stub for the Entra ID auth path when built without the azidentity tag.
|
||||
// Mirrors the exported surface of identity.go so callers compile cleanly
|
||||
// in the default build.
|
||||
|
||||
package azure
|
||||
|
||||
import "fmt"
|
||||
|
||||
const azidentityBuildHint = "azure identity auth not available: build with -tags azidentity to enable Entra ID auth, or set api_key"
|
||||
|
||||
// NewProviderWithIdentity returns an error in the default build.
|
||||
func NewProviderWithIdentity(apiBase, proxy, userAgent string, opts ...Option) (*Provider, error) {
|
||||
return nil, fmt.Errorf("%s", azidentityBuildHint)
|
||||
}
|
||||
|
||||
// NewProviderWithIdentityAndTimeout returns an error in the default build.
|
||||
func NewProviderWithIdentityAndTimeout(
|
||||
apiBase, proxy, userAgent string,
|
||||
requestTimeoutSeconds int,
|
||||
) (*Provider, error) {
|
||||
return nil, fmt.Errorf("%s", azidentityBuildHint)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//go:build azidentity
|
||||
|
||||
package azure
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewProviderWithIdentity_Construction(t *testing.T) {
|
||||
// DefaultAzureCredential construction itself does not require any env vars;
|
||||
// failures surface only on the first GetToken call. Verify we get a
|
||||
// non-nil provider back with a token source wired in.
|
||||
p, err := NewProviderWithIdentity("https://example.openai.azure.com", "", "ua-test")
|
||||
if err != nil {
|
||||
t.Fatalf("NewProviderWithIdentity() error = %v", err)
|
||||
}
|
||||
if p == nil {
|
||||
t.Fatal("NewProviderWithIdentity() returned nil provider")
|
||||
}
|
||||
if p.tokenSource == nil {
|
||||
t.Fatal("provider.tokenSource should be set")
|
||||
}
|
||||
if p.apiKey != "" {
|
||||
t.Errorf("provider.apiKey = %q, want empty", p.apiKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewProviderWithIdentityAndTimeout_Construction(t *testing.T) {
|
||||
p, err := NewProviderWithIdentityAndTimeout("https://example.openai.azure.com", "", "ua-test", 30)
|
||||
if err != nil {
|
||||
t.Fatalf("NewProviderWithIdentityAndTimeout() error = %v", err)
|
||||
}
|
||||
if p == nil {
|
||||
t.Fatal("returned nil provider")
|
||||
}
|
||||
if p.httpClient.Timeout.Seconds() != 30 {
|
||||
t.Errorf("timeout = %v, want 30s", p.httpClient.Timeout)
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,11 @@ const (
|
||||
// It handles Azure-specific authentication (Bearer token), URL construction
|
||||
// (Responses API), and request/response formatting.
|
||||
type Provider struct {
|
||||
apiKey string
|
||||
apiBase string
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
apiKey string
|
||||
apiBase string
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
tokenSource func(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// Option configures the Azure Provider.
|
||||
@@ -58,6 +59,14 @@ func WithUserAgent(userAgent string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithTokenSource sets a callback that returns a bearer token per request.
|
||||
// When set, it takes precedence over the static api key.
|
||||
func WithTokenSource(ts func(ctx context.Context) (string, error)) Option {
|
||||
return func(p *Provider) {
|
||||
p.tokenSource = ts
|
||||
}
|
||||
}
|
||||
|
||||
// NewProvider creates a new Azure OpenAI provider.
|
||||
func NewProvider(apiKey, apiBase, proxy, userAgent string, opts ...Option) *Provider {
|
||||
p := &Provider{
|
||||
@@ -84,6 +93,30 @@ func NewProviderWithTimeout(apiKey, apiBase, proxy, userAgent string, requestTim
|
||||
)
|
||||
}
|
||||
|
||||
// NewProviderWithTokenSource creates a new Azure OpenAI provider that obtains its
|
||||
// bearer token from the supplied callback on every request. Used for Entra ID auth
|
||||
// where tokens are short-lived and refreshed by the underlying credential.
|
||||
func NewProviderWithTokenSource(
|
||||
apiBase, proxy, userAgent string,
|
||||
tokenSource func(ctx context.Context) (string, error),
|
||||
opts ...Option,
|
||||
) *Provider {
|
||||
p := &Provider{
|
||||
apiBase: strings.TrimRight(apiBase, "/"),
|
||||
userAgent: userAgent,
|
||||
httpClient: common.NewHTTPClient(proxy),
|
||||
tokenSource: tokenSource,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(p)
|
||||
}
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// Chat sends a request to the Azure OpenAI Responses API endpoint.
|
||||
// The model parameter is passed in the request body.
|
||||
func (p *Provider) Chat(
|
||||
@@ -147,7 +180,14 @@ func (p *Provider) Chat(
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if p.apiKey != "" {
|
||||
switch {
|
||||
case p.tokenSource != nil:
|
||||
tok, tokErr := p.tokenSource(ctx)
|
||||
if tokErr != nil {
|
||||
return nil, fmt.Errorf("acquiring azure identity token: %w", tokErr)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
case p.apiKey != "":
|
||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
}
|
||||
if p.userAgent != "" {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -415,3 +417,68 @@ func TestProviderChat_AzureNoNativeWebSearch(t *testing.T) {
|
||||
t.Errorf("tool type = %v, want %q", tool["type"], "function")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_AzureTokenSourceHeader(t *testing.T) {
|
||||
var capturedAuth string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedAuth = r.Header.Get("Authorization")
|
||||
writeValidResponse(w)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ts := func(ctx context.Context) (string, error) {
|
||||
return "fake-entra-token", nil
|
||||
}
|
||||
p := NewProviderWithTokenSource(server.URL, "", "", ts)
|
||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
if capturedAuth != "Bearer fake-entra-token" {
|
||||
t.Errorf("Authorization header = %q, want %q", capturedAuth, "Bearer fake-entra-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_AzureTokenSourceError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeValidResponse(w)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
wantErr := errors.New("creds gone")
|
||||
ts := func(ctx context.Context) (string, error) {
|
||||
return "", wantErr
|
||||
}
|
||||
p := NewProviderWithTokenSource(server.URL, "", "", ts)
|
||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from token source")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "creds gone") {
|
||||
t.Errorf("error %q should wrap original token source error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_AzureTokenSourcePrecedence(t *testing.T) {
|
||||
var capturedAuth string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedAuth = r.Header.Get("Authorization")
|
||||
writeValidResponse(w)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ts := func(ctx context.Context) (string, error) {
|
||||
return "from-token-source", nil
|
||||
}
|
||||
// Provider with both an api_key AND a token source: token source must win.
|
||||
p := NewProvider("static-api-key", server.URL, "", "", WithTokenSource(ts))
|
||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
if capturedAuth != "Bearer from-token-source" {
|
||||
t.Errorf("Authorization header = %q, want token-source value", capturedAuth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,22 @@ type converseParams struct {
|
||||
toolConfig *types.ToolConfiguration
|
||||
}
|
||||
|
||||
func buildConverseParams(messages []Message, tools []ToolDefinition, options map[string]any) converseParams {
|
||||
// modelDeprecatesTemperature reports whether the given Bedrock model rejects the
|
||||
// temperature inference parameter. Newer Claude models (Opus 4.8 and later) treat
|
||||
// temperature as deprecated and return a ValidationException if it is supplied:
|
||||
//
|
||||
// ValidationException: The model returned the following errors:
|
||||
// temperature is deprecated for this model.
|
||||
//
|
||||
// The match is intentionally loose: Bedrock model IDs and inference-profile ARNs
|
||||
// embed the model name (e.g. "us.anthropic.claude-opus-4-8-20260514-v1:0"), so a
|
||||
// substring check covers both bare IDs and region-prefixed inference profiles.
|
||||
func modelDeprecatesTemperature(model string) bool {
|
||||
m := strings.ToLower(model)
|
||||
return strings.Contains(m, "claude-opus-4-8")
|
||||
}
|
||||
|
||||
func buildConverseParams(messages []Message, tools []ToolDefinition, model string, options map[string]any) converseParams {
|
||||
bedrockMessages, systemPrompts := convertMessages(messages)
|
||||
|
||||
var inferenceConfig *types.InferenceConfiguration
|
||||
@@ -159,10 +174,14 @@ func buildConverseParams(messages []Message, tools []ToolDefinition, options map
|
||||
}
|
||||
|
||||
if temp, ok := common.AsFloat(options["temperature"]); ok {
|
||||
if inferenceConfig == nil {
|
||||
inferenceConfig = &types.InferenceConfiguration{}
|
||||
if modelDeprecatesTemperature(model) {
|
||||
log.Printf("bedrock: temperature dropped because model %q no longer supports it", model)
|
||||
} else {
|
||||
if inferenceConfig == nil {
|
||||
inferenceConfig = &types.InferenceConfiguration{}
|
||||
}
|
||||
inferenceConfig.Temperature = aws.Float32(float32(temp))
|
||||
}
|
||||
inferenceConfig.Temperature = aws.Float32(float32(temp))
|
||||
}
|
||||
|
||||
var toolConfig *types.ToolConfiguration
|
||||
@@ -199,7 +218,7 @@ func (p *Provider) Chat(
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
params := buildConverseParams(messages, tools, options)
|
||||
params := buildConverseParams(messages, tools, model, options)
|
||||
input := &bedrockruntime.ConverseInput{
|
||||
ModelId: aws.String(model),
|
||||
Messages: params.messages,
|
||||
@@ -242,7 +261,7 @@ func (p *Provider) ChatStream(
|
||||
}
|
||||
}
|
||||
|
||||
params := buildConverseParams(messages, tools, options)
|
||||
params := buildConverseParams(messages, tools, model, options)
|
||||
input := &bedrockruntime.ConverseStreamInput{
|
||||
ModelId: aws.String(model),
|
||||
Messages: params.messages,
|
||||
|
||||
@@ -875,3 +875,49 @@ func TestParseStreamResponse_StopReasons(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelDeprecatesTemperature(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"opus 4.8 bare id", "claude-opus-4-8-20260514-v1:0", true},
|
||||
{"opus 4.8 inference profile", "us.anthropic.claude-opus-4-8-20260514-v1:0", true},
|
||||
{"opus 4.8 mixed case", "US.Anthropic.Claude-Opus-4-8", true},
|
||||
{"opus 4.7 unaffected", "us.anthropic.claude-opus-4-7-20250101-v1:0", false},
|
||||
{"sonnet unaffected", "us.anthropic.claude-sonnet-4-6", false},
|
||||
{"empty", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, modelDeprecatesTemperature(tt.model))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConverseParams_DropsTemperatureForDeprecatedModel(t *testing.T) {
|
||||
options := map[string]any{
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 1024,
|
||||
}
|
||||
|
||||
params := buildConverseParams(nil, nil, "us.anthropic.claude-opus-4-8-20260514-v1:0", options)
|
||||
|
||||
require.NotNil(t, params.inferenceConfig, "max_tokens should still populate inference config")
|
||||
assert.Nil(t, params.inferenceConfig.Temperature, "temperature must be omitted for opus 4.8")
|
||||
require.NotNil(t, params.inferenceConfig.MaxTokens)
|
||||
assert.Equal(t, int32(1024), *params.inferenceConfig.MaxTokens)
|
||||
}
|
||||
|
||||
func TestBuildConverseParams_KeepsTemperatureForSupportedModel(t *testing.T) {
|
||||
options := map[string]any{
|
||||
"temperature": 0.7,
|
||||
}
|
||||
|
||||
params := buildConverseParams(nil, nil, "us.anthropic.claude-opus-4-7-20250101-v1:0", options)
|
||||
|
||||
require.NotNil(t, params.inferenceConfig)
|
||||
require.NotNil(t, params.inferenceConfig.Temperature)
|
||||
assert.InDelta(t, 0.7, float64(*params.inferenceConfig.Temperature), 0.0001)
|
||||
}
|
||||
|
||||
@@ -111,6 +111,10 @@ var (
|
||||
substr("tool_use_id"),
|
||||
substr("messages.1.content.1.tool_use.id"),
|
||||
substr("invalid request format"),
|
||||
// Zhipu API error code 1210: parameter error (e.g., image format incompatible)
|
||||
substr("error code: 1210"),
|
||||
substr("error code 1210"),
|
||||
substr("zhipu api error code: 1210"),
|
||||
}
|
||||
contextOverflowPatterns = []errorPattern{
|
||||
rxp(`context[_ ]?length[_ ]?exceeded`),
|
||||
|
||||
@@ -137,23 +137,32 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||
return finalizeProviderFromConfig(provider, modelID, cfg)
|
||||
|
||||
case "azure":
|
||||
// Azure OpenAI uses deployment-based URLs, api-key header auth,
|
||||
// and always sends max_completion_tokens.
|
||||
if cfg.APIKey() == "" {
|
||||
return nil, "", fmt.Errorf("api_key is required for azure protocol")
|
||||
}
|
||||
// Azure OpenAI uses deployment-based URLs. Auth is Bearer token via api_key
|
||||
// when set; otherwise falls back to Entra ID (DefaultAzureCredential).
|
||||
if cfg.APIBase == "" {
|
||||
return nil, "", fmt.Errorf(
|
||||
"api_base is required for azure protocol (e.g., https://your-resource.openai.azure.com)",
|
||||
)
|
||||
}
|
||||
return finalizeProviderFromConfig(azure.NewProviderWithTimeout(
|
||||
cfg.APIKey(),
|
||||
if cfg.APIKey() != "" {
|
||||
return finalizeProviderFromConfig(azure.NewProviderWithTimeout(
|
||||
cfg.APIKey(),
|
||||
cfg.APIBase,
|
||||
cfg.Proxy,
|
||||
userAgent,
|
||||
cfg.RequestTimeout,
|
||||
), modelID, cfg)
|
||||
}
|
||||
provider, err := azure.NewProviderWithIdentityAndTimeout(
|
||||
cfg.APIBase,
|
||||
cfg.Proxy,
|
||||
userAgent,
|
||||
cfg.RequestTimeout,
|
||||
), modelID, cfg)
|
||||
)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return finalizeProviderFromConfig(provider, modelID, cfg)
|
||||
|
||||
case "bedrock":
|
||||
// AWS Bedrock uses AWS SDK credentials (env vars, profiles, IAM roles, etc.)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build azidentity
|
||||
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package providers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// With the azidentity build tag, an azure config with no api_key must succeed
|
||||
// (falls back to DefaultAzureCredential). Construction does not require any
|
||||
// real Azure environment — token acquisition happens on first Chat.
|
||||
func TestCreateProviderFromConfig_AzureIdentityFallback(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "azure-gpt5",
|
||||
Model: "azure/my-gpt5-deployment",
|
||||
APIBase: "https://my-resource.openai.azure.com",
|
||||
}
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||
}
|
||||
if provider == nil {
|
||||
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||
}
|
||||
if modelID != "my-gpt5-deployment" {
|
||||
t.Errorf("modelID = %q, want %q", modelID, "my-gpt5-deployment")
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,30 @@ func TestCreateProviderFromConfig_UsesExplicitProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_DeepSeekSupportsThinking(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "deepseek-v4-flash",
|
||||
Provider: "deepseek",
|
||||
Model: "deepseek-v4-flash",
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||
}
|
||||
if modelID != "deepseek-v4-flash" {
|
||||
t.Fatalf("modelID = %q, want %q", modelID, "deepseek-v4-flash")
|
||||
}
|
||||
tc, ok := provider.(ThinkingCapable)
|
||||
if !ok {
|
||||
t.Fatalf("provider %T should implement ThinkingCapable for DeepSeek", provider)
|
||||
}
|
||||
if !tc.SupportsThinking() {
|
||||
t.Fatalf("DeepSeek provider SupportsThinking() = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_PreservesExplicitProviderPrefixedModel(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-openai",
|
||||
@@ -846,8 +870,11 @@ func TestCreateProviderFromConfig_AzureMissingAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
_, _, err := CreateProviderFromConfig(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("CreateProviderFromConfig() expected error for missing API key")
|
||||
// Without api_key the factory falls back to identity auth, which in the
|
||||
// default build is stubbed out and surfaces a build-tag error. With the
|
||||
// azidentity tag, the call succeeds and is covered by a separate test.
|
||||
if err != nil && !strings.Contains(err.Error(), "azidentity") {
|
||||
t.Fatalf("CreateProviderFromConfig() unexpected error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1054,6 +1081,21 @@ func TestModelProviderOptions(t *testing.T) {
|
||||
} else if option.DefaultAPIBase != "https://api.anthropic.com/v1" {
|
||||
t.Fatalf("anthropic default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.anthropic.com/v1")
|
||||
}
|
||||
// First-party Claude API model IDs use hyphenated formats such as
|
||||
// claude-{name}-{major}-{minor} or claude-{name}-{major}-{minor}-{YYYYMMDD};
|
||||
// dotted provider prefixes are for platform-specific IDs such as Bedrock.
|
||||
// https://platform.claude.com/docs/en/about-claude/models/model-ids-and-versions
|
||||
for _, provider := range []string{"anthropic", "anthropic-messages"} {
|
||||
option, ok := seen[provider]
|
||||
if !ok {
|
||||
t.Fatalf("%s option missing", provider)
|
||||
}
|
||||
for _, model := range option.CommonModels {
|
||||
if strings.Contains(model, ".") {
|
||||
t.Fatalf("%s common_model %q uses dotted ID", provider, model)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, ok := seen["azure"]; !ok {
|
||||
t.Fatal("azure option missing")
|
||||
}
|
||||
|
||||
@@ -89,6 +89,13 @@ func (p *HTTPProvider) SupportsNativeSearch() bool {
|
||||
return p.delegate.SupportsNativeSearch()
|
||||
}
|
||||
|
||||
func (p *HTTPProvider) SupportsThinking() bool {
|
||||
if p == nil || p.delegate == nil {
|
||||
return false
|
||||
}
|
||||
return p.delegate.SupportsThinking()
|
||||
}
|
||||
|
||||
func (p *HTTPProvider) SetProviderName(providerName string) {
|
||||
if p == nil || p.delegate == nil {
|
||||
return
|
||||
|
||||
@@ -104,8 +104,19 @@ func (p *CodexProvider) Chat(
|
||||
defer stream.Close()
|
||||
|
||||
var resp *responses.Response
|
||||
var streamedText strings.Builder
|
||||
streamedOutputItems := make([]responses.ResponseOutputItemUnion, 0)
|
||||
for stream.Next() {
|
||||
evt := stream.Current()
|
||||
if evt.Type == "response.output_text.delta" {
|
||||
streamedText.WriteString(evt.Delta)
|
||||
}
|
||||
if evt.Type == "response.output_item.done" {
|
||||
itemEvt := evt.AsResponseOutputItemDone()
|
||||
if itemEvt.Item.Type != "" {
|
||||
streamedOutputItems = append(streamedOutputItems, itemEvt.Item)
|
||||
}
|
||||
}
|
||||
if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" {
|
||||
evtResp := evt.Response
|
||||
if evtResp.ID != "" {
|
||||
@@ -152,8 +163,15 @@ func (p *CodexProvider) Chat(
|
||||
logger.ErrorCF("provider.codex", "Codex stream ended without completed response event", fields)
|
||||
return nil, fmt.Errorf("codex API call: stream ended without completed response")
|
||||
}
|
||||
if len(resp.Output) == 0 && len(streamedOutputItems) > 0 {
|
||||
resp.Output = streamedOutputItems
|
||||
}
|
||||
|
||||
return orc.ParseResponseFromStruct(resp), nil
|
||||
parsed := orc.ParseResponseFromStruct(resp)
|
||||
if parsed.Content == "" && streamedText.Len() > 0 {
|
||||
parsed.Content = streamedText.String()
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func (p *CodexProvider) GetDefaultModel() string {
|
||||
|
||||
@@ -374,6 +374,129 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProvider_ChatRoundTrip_OutputTextDeltaFallback(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/responses" {
|
||||
http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var reqBody map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if reqBody["stream"] != true {
|
||||
http.Error(w, "stream must be true", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": nil,
|
||||
}
|
||||
writeOutputTextDeltaSSE(w, "OK", resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewCodexProvider("test-token", "acc-123")
|
||||
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
|
||||
|
||||
resp, err := provider.Chat(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "Hello"}},
|
||||
nil,
|
||||
"gpt-4o",
|
||||
map[string]any{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
if resp.Content != "OK" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "OK")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProvider_ChatRoundTrip_OutputItemDoneFallback(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/responses" {
|
||||
http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var reqBody map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if reqBody["stream"] != true {
|
||||
http.Error(w, "stream must be true", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
item := map[string]any{
|
||||
"id": "fc_1",
|
||||
"type": "function_call",
|
||||
"call_id": "call_abc",
|
||||
"name": "write_file",
|
||||
"arguments": `{"path":"x.txt","content":"ok"}`,
|
||||
"status": "completed",
|
||||
}
|
||||
resp := map[string]any{
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": []map[string]any{},
|
||||
}
|
||||
writeOutputItemDoneSSE(w, item, resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewCodexProvider("test-token", "acc-123")
|
||||
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
|
||||
|
||||
resp, err := provider.Chat(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "Create x.txt"}},
|
||||
[]ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: ToolFunctionDefinition{
|
||||
Name: "write_file",
|
||||
Description: "write file",
|
||||
Parameters: map[string]any{"type": "object"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"gpt-5.5",
|
||||
map[string]any{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 {
|
||||
t.Fatalf("len(ToolCalls) = %d, want 1", len(resp.ToolCalls))
|
||||
}
|
||||
tc := resp.ToolCalls[0]
|
||||
if tc.ID != "call_abc" {
|
||||
t.Errorf("ToolCall.ID = %q, want %q", tc.ID, "call_abc")
|
||||
}
|
||||
if tc.Name != "write_file" {
|
||||
t.Errorf("ToolCall.Name = %q, want %q", tc.Name, "write_file")
|
||||
}
|
||||
if tc.Arguments["path"] != "x.txt" {
|
||||
t.Errorf("ToolCall.Arguments[path] = %v, want x.txt", tc.Arguments["path"])
|
||||
}
|
||||
if tc.Arguments["content"] != "ok" {
|
||||
t.Errorf("ToolCall.Arguments[content] = %v, want ok", tc.Arguments["content"])
|
||||
}
|
||||
if resp.FinishReason != "tool_calls" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/responses" {
|
||||
@@ -647,3 +770,46 @@ func writeCompletedSSE(w http.ResponseWriter, response map[string]any) {
|
||||
fmt.Fprintf(w, "data: %s\n\n", string(b))
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
}
|
||||
|
||||
func writeOutputTextDeltaSSE(w http.ResponseWriter, delta string, response map[string]any) {
|
||||
deltaEvent := map[string]any{
|
||||
"type": "response.output_text.delta",
|
||||
"sequence_number": 1,
|
||||
"delta": delta,
|
||||
}
|
||||
completedEvent := map[string]any{
|
||||
"type": "response.completed",
|
||||
"sequence_number": 2,
|
||||
"response": response,
|
||||
}
|
||||
deltaBytes, _ := json.Marshal(deltaEvent)
|
||||
completedBytes, _ := json.Marshal(completedEvent)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprintf(w, "event: response.output_text.delta\n")
|
||||
fmt.Fprintf(w, "data: %s\n\n", string(deltaBytes))
|
||||
fmt.Fprintf(w, "event: response.completed\n")
|
||||
fmt.Fprintf(w, "data: %s\n\n", string(completedBytes))
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
}
|
||||
|
||||
func writeOutputItemDoneSSE(w http.ResponseWriter, item map[string]any, response map[string]any) {
|
||||
itemEvent := map[string]any{
|
||||
"type": "response.output_item.done",
|
||||
"sequence_number": 1,
|
||||
"output_index": 0,
|
||||
"item": item,
|
||||
}
|
||||
completedEvent := map[string]any{
|
||||
"type": "response.completed",
|
||||
"sequence_number": 2,
|
||||
"response": response,
|
||||
}
|
||||
itemBytes, _ := json.Marshal(itemEvent)
|
||||
completedBytes, _ := json.Marshal(completedEvent)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprintf(w, "event: response.output_item.done\n")
|
||||
fmt.Fprintf(w, "data: %s\n\n", string(itemBytes))
|
||||
fmt.Fprintf(w, "event: response.completed\n")
|
||||
fmt.Fprintf(w, "data: %s\n\n", string(completedBytes))
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
@@ -204,7 +205,16 @@ func (p *Provider) buildRequestBody(
|
||||
|
||||
func (p *Provider) applyThinkingControl(requestBody map[string]any, model string, options map[string]any) {
|
||||
level, ok := normalizedThinkingLevel(options)
|
||||
if !ok || level != "off" {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if p.SupportsThinking() {
|
||||
p.applyDeepSeekThinkingControl(requestBody, level)
|
||||
return
|
||||
}
|
||||
|
||||
if level != "off" {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -216,6 +226,28 @@ func (p *Provider) applyThinkingControl(requestBody map[string]any, model string
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) applyDeepSeekThinkingControl(requestBody map[string]any, level string) {
|
||||
switch level {
|
||||
case "off":
|
||||
requestBody["thinking"] = map[string]any{"type": "disabled"}
|
||||
case "low", "medium", "high":
|
||||
requestBody["thinking"] = map[string]any{"type": "enabled"}
|
||||
requestBody["reasoning_effort"] = "high"
|
||||
case "xhigh":
|
||||
requestBody["thinking"] = map[string]any{"type": "enabled"}
|
||||
requestBody["reasoning_effort"] = "max"
|
||||
case "adaptive":
|
||||
logger.WarnCF("provider.openai_compat",
|
||||
`DeepSeek does not support thinking_level="adaptive"; using provider default thinking behavior`,
|
||||
map[string]any{
|
||||
"provider": p.providerName,
|
||||
"api_base": p.apiBase,
|
||||
"thinking_level": level,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedThinkingLevel(options map[string]any) (string, bool) {
|
||||
raw, ok := options["thinking_level"].(string)
|
||||
if !ok {
|
||||
@@ -290,6 +322,10 @@ func (p *Provider) SetProviderName(providerName string) {
|
||||
p.providerName = strings.ToLower(strings.TrimSpace(providerName))
|
||||
}
|
||||
|
||||
func (p *Provider) SupportsThinking() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(p.providerName), "deepseek") || isDeepSeekHost(p.apiBase)
|
||||
}
|
||||
|
||||
func (p *Provider) prepareMessagesForRequest(messages []Message) []Message {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -8,11 +8,13 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
@@ -125,6 +127,146 @@ func TestBuildRequestBody_PreservesDoubaoRequestWhenThinkingLevelIsNotOff(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequestBody_MapsDeepSeekThinkingLevels(t *testing.T) {
|
||||
p := NewProvider("key", "https://api.deepseek.com/v1", "")
|
||||
p.SetProviderName("deepseek")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
level string
|
||||
wantThinkingType string
|
||||
wantEffort any
|
||||
}{
|
||||
{name: "off", level: "off", wantThinkingType: "disabled"},
|
||||
{name: "low", level: "low", wantThinkingType: "enabled", wantEffort: "high"},
|
||||
{name: "medium", level: "medium", wantThinkingType: "enabled", wantEffort: "high"},
|
||||
{name: "high", level: "high", wantThinkingType: "enabled", wantEffort: "high"},
|
||||
{name: "xhigh", level: "xhigh", wantThinkingType: "enabled", wantEffort: "max"},
|
||||
{name: "adaptive", level: "adaptive"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := p.buildRequestBody(
|
||||
[]Message{{Role: "user", Content: "hi"}},
|
||||
nil,
|
||||
"deepseek-v4-pro",
|
||||
map[string]any{"thinking_level": tt.level},
|
||||
)
|
||||
|
||||
if tt.wantThinkingType == "" {
|
||||
if _, ok := body["thinking"]; ok {
|
||||
t.Fatalf("thinking should be omitted for %q, got %#v", tt.level, body["thinking"])
|
||||
}
|
||||
} else {
|
||||
thinking, ok := body["thinking"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("thinking = %#v, want map", body["thinking"])
|
||||
}
|
||||
if got := thinking["type"]; got != tt.wantThinkingType {
|
||||
t.Fatalf("thinking.type = %#v, want %q", got, tt.wantThinkingType)
|
||||
}
|
||||
}
|
||||
|
||||
if tt.wantEffort == nil {
|
||||
if _, ok := body["reasoning_effort"]; ok {
|
||||
t.Fatalf("reasoning_effort should be omitted for %q, got %#v", tt.level, body["reasoning_effort"])
|
||||
}
|
||||
} else if got := body["reasoning_effort"]; got != tt.wantEffort {
|
||||
t.Fatalf("reasoning_effort = %#v, want %#v", got, tt.wantEffort)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequestBody_MapsDeepSeekThinkingLevelsByHost(t *testing.T) {
|
||||
p := NewProvider("key", "https://api.deepseek.com/v1", "")
|
||||
|
||||
body := p.buildRequestBody(
|
||||
[]Message{{Role: "user", Content: "hi"}},
|
||||
nil,
|
||||
"deepseek-v4-flash",
|
||||
map[string]any{"thinking_level": "xhigh"},
|
||||
)
|
||||
|
||||
thinking, ok := body["thinking"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("thinking = %#v, want map", body["thinking"])
|
||||
}
|
||||
if got := thinking["type"]; got != "enabled" {
|
||||
t.Fatalf("thinking.type = %#v, want enabled", got)
|
||||
}
|
||||
if got := body["reasoning_effort"]; got != "max" {
|
||||
t.Fatalf("reasoning_effort = %#v, want max", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequestBody_DeepSeekExtraBodyStillOverridesThinkingFields(t *testing.T) {
|
||||
extraBody := map[string]any{
|
||||
"thinking": map[string]any{"type": "disabled"},
|
||||
"reasoning_effort": "max",
|
||||
}
|
||||
p := NewProvider("key", "https://api.deepseek.com/v1", "", WithExtraBody(extraBody))
|
||||
p.SetProviderName("deepseek")
|
||||
|
||||
body := p.buildRequestBody(
|
||||
[]Message{{Role: "user", Content: "hi"}},
|
||||
nil,
|
||||
"deepseek-v4-pro",
|
||||
map[string]any{"thinking_level": "high"},
|
||||
)
|
||||
|
||||
thinking, ok := body["thinking"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("thinking = %#v, want map", body["thinking"])
|
||||
}
|
||||
if got := thinking["type"]; got != "disabled" {
|
||||
t.Fatalf("thinking.type = %#v, want disabled from extra_body override", got)
|
||||
}
|
||||
if got := body["reasoning_effort"]; got != "max" {
|
||||
t.Fatalf("reasoning_effort = %#v, want max from extra_body override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequestBody_WarnsForUnsupportedDeepSeekAdaptiveThinkingLevel(t *testing.T) {
|
||||
logFile := t.TempDir() + "/deepseek-adaptive-warning.log"
|
||||
prevLevel := logger.GetLevel()
|
||||
logger.SetLevel(logger.WARN)
|
||||
if err := logger.EnableFileLogging(logFile); err != nil {
|
||||
t.Fatalf("EnableFileLogging() error = %v", err)
|
||||
}
|
||||
defer func() {
|
||||
logger.DisableFileLogging()
|
||||
logger.SetLevel(prevLevel)
|
||||
}()
|
||||
|
||||
p := NewProvider("key", "https://api.deepseek.com/v1", "")
|
||||
p.SetProviderName("deepseek")
|
||||
|
||||
body := p.buildRequestBody(
|
||||
[]Message{{Role: "user", Content: "hi"}},
|
||||
nil,
|
||||
"deepseek-v4-pro",
|
||||
map[string]any{"thinking_level": "adaptive"},
|
||||
)
|
||||
|
||||
if _, ok := body["thinking"]; ok {
|
||||
t.Fatalf("thinking should be omitted for adaptive, got %#v", body["thinking"])
|
||||
}
|
||||
if _, ok := body["reasoning_effort"]; ok {
|
||||
t.Fatalf("reasoning_effort should be omitted for adaptive, got %#v", body["reasoning_effort"])
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(logFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v", logFile, err)
|
||||
}
|
||||
logs := string(data)
|
||||
if !strings.Contains(logs, `thinking_level=\"adaptive\"`) {
|
||||
t.Fatalf("warning log = %q, want adaptive warning message", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_ParsesToolCalls(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]any{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package protocoltypes
|
||||
|
||||
import "time"
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type,omitempty"`
|
||||
@@ -87,6 +89,7 @@ type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ModelName string `json:"model_name,omitempty"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
Media []string `json:"media,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
|
||||
@@ -474,6 +474,7 @@ var modelProviderOptionsByName = map[string]ModelProviderOption{
|
||||
DefaultModelAllowed: true,
|
||||
SupportsFetch: true,
|
||||
Priority: 39,
|
||||
CommonModels: []string{"mimo-v2.5", "mimo-v2.5-pro"},
|
||||
httpAPI: true,
|
||||
},
|
||||
"avian": {
|
||||
|
||||
@@ -68,17 +68,31 @@ func (a *Assembler) Assemble(ctx context.Context, convID int64, input AssembleIn
|
||||
freshTailTokens += r.tokenCount
|
||||
}
|
||||
|
||||
// Budget-aware selection of evictable items
|
||||
// If the protected tail alone exceeds budget, trim from the oldest end at
|
||||
// provider-safe boundaries. The rebuild path later sanitizes leading
|
||||
// assistant(tool_calls)/tool messages, so splitting the active turn here can
|
||||
// silently discard the very context we are trying to protect.
|
||||
if freshTailTokens > input.Budget {
|
||||
originalTailCount := len(freshTail)
|
||||
originalFreshTailTokens := freshTailTokens
|
||||
var preservedActiveTurn bool
|
||||
freshTail, freshTailTokens, preservedActiveTurn = trimFreshTailToSafeBudget(freshTail, input.Budget)
|
||||
logFields := map[string]any{
|
||||
"budget": input.Budget,
|
||||
"fresh_tail_tokens": freshTailTokens,
|
||||
"fresh_tail_count": len(freshTail),
|
||||
"trimmed_fresh_items": originalTailCount - len(freshTail),
|
||||
"original_fresh_tokens": originalFreshTailTokens,
|
||||
"preserved_active_turn": preservedActiveTurn,
|
||||
}
|
||||
if preservedActiveTurn {
|
||||
logger.WarnCF("seahorse", "assemble: preserving active turn over budget", logFields)
|
||||
} else {
|
||||
logger.InfoCF("seahorse", "assemble: trimmed fresh tail to safe boundary", logFields)
|
||||
}
|
||||
}
|
||||
remainingBudget := input.Budget - freshTailTokens
|
||||
if remainingBudget < 0 {
|
||||
// Fresh tail alone exceeds budget - we keep it anyway (design decision)
|
||||
// Log for debugging retry/overflow issues
|
||||
logger.InfoCF("seahorse", "assemble: fresh tail exceeds budget", map[string]any{
|
||||
"budget": input.Budget,
|
||||
"fresh_tail_tokens": freshTailTokens,
|
||||
"fresh_tail_count": len(freshTail),
|
||||
"over_budget_by": freshTailTokens - input.Budget,
|
||||
})
|
||||
remainingBudget = 0
|
||||
}
|
||||
|
||||
@@ -184,6 +198,81 @@ func (a *Assembler) Assemble(ctx context.Context, convID int64, input AssembleIn
|
||||
}, nil
|
||||
}
|
||||
|
||||
func trimFreshTailToSafeBudget(tail []resolvedItem, budget int) ([]resolvedItem, int, bool) {
|
||||
tailTokens := resolvedItemsTokenCount(tail)
|
||||
if tailTokens <= budget {
|
||||
return tail, tailTokens, false
|
||||
}
|
||||
|
||||
latestTurnStart := lastUserMessageIndex(tail)
|
||||
if latestTurnStart >= 0 {
|
||||
latestTurnTokens := resolvedItemsTokenCount(tail[latestTurnStart:])
|
||||
if latestTurnTokens > budget {
|
||||
return tail[latestTurnStart:], latestTurnTokens, true
|
||||
}
|
||||
}
|
||||
|
||||
start := 0
|
||||
for tailTokens > budget && start < len(tail) {
|
||||
tailTokens -= tail[start].tokenCount
|
||||
start++
|
||||
}
|
||||
for start < len(tail) && !isProviderSafeHistoryStart(tail[start:]) {
|
||||
tailTokens -= tail[start].tokenCount
|
||||
start++
|
||||
}
|
||||
|
||||
return tail[start:], tailTokens, false
|
||||
}
|
||||
|
||||
func resolvedItemsTokenCount(items []resolvedItem) int {
|
||||
total := 0
|
||||
for _, item := range items {
|
||||
total += item.tokenCount
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func lastUserMessageIndex(items []resolvedItem) int {
|
||||
for i := len(items) - 1; i >= 0; i-- {
|
||||
if items[i].itemType != "message" || items[i].message == nil {
|
||||
continue
|
||||
}
|
||||
if items[i].message.Role == "user" {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func isProviderSafeHistoryStart(items []resolvedItem) bool {
|
||||
for _, item := range items {
|
||||
if item.itemType != "message" || item.message == nil {
|
||||
continue
|
||||
}
|
||||
if item.message.Role == "tool" {
|
||||
return false
|
||||
}
|
||||
if item.message.Role == "assistant" && messageHasToolUse(item.message) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func messageHasToolUse(msg *Message) bool {
|
||||
if msg == nil {
|
||||
return false
|
||||
}
|
||||
for _, part := range msg.Parts {
|
||||
if part.Type == "tool_use" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveItem loads the full message or summary for a context item.
|
||||
func (a *Assembler) resolveItem(ctx context.Context, item ContextItem) (resolvedItem, error) {
|
||||
if item.ItemType == "message" {
|
||||
|
||||
@@ -145,22 +145,91 @@ func TestAssemblerBudgetEvictsOldest(t *testing.T) {
|
||||
s.UpsertContextItems(ctx, convID, items)
|
||||
|
||||
// Budget of 200 tokens with FreshTailCount=32
|
||||
// Fresh tail = last 32 messages (320 tokens, over budget, but always included)
|
||||
// Fresh tail = last 32 messages (320 tokens, over budget)
|
||||
// Evictable = first 8 messages (80 tokens)
|
||||
// Budget after tail: max(0, 200-320) = 0 → no evictable items included
|
||||
// The oldest messages from the fresh tail should be dropped so only the
|
||||
// newest 20 messages remain within the 200-token budget.
|
||||
a := &Assembler{store: s, config: Config{}}
|
||||
result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 200})
|
||||
if err != nil {
|
||||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
|
||||
// Should only include the 32-item fresh tail
|
||||
if len(result.Messages) != 32 {
|
||||
t.Errorf("Messages = %d, want 32 (fresh tail)", len(result.Messages))
|
||||
if len(result.Messages) != 20 {
|
||||
t.Errorf("Messages = %d, want 20", len(result.Messages))
|
||||
}
|
||||
// Should be the LAST 32 messages
|
||||
if result.Messages[0].ID != msgs[8].ID {
|
||||
t.Errorf("first message ID = %d, want %d (msgs[8])", result.Messages[0].ID, msgs[8].ID)
|
||||
if result.Messages[0].ID != msgs[20].ID {
|
||||
t.Errorf("first message ID = %d, want %d (msgs[20])", result.Messages[0].ID, msgs[20].ID)
|
||||
}
|
||||
|
||||
totalTokens := 0
|
||||
for _, msg := range result.Messages {
|
||||
totalTokens += msg.TokenCount
|
||||
}
|
||||
if totalTokens > 200 {
|
||||
t.Errorf("assembled tokens = %d, want <= 200", totalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssemblerBudgetPreservesLatestToolTurnWhenItExceedsBudget(t *testing.T) {
|
||||
s, convID := setupAssemblerStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
oldMsg, _ := s.AddMessage(ctx, convID, "assistant", "older context", 20)
|
||||
userMsg, _ := s.AddMessage(ctx, convID, "user", "inspect the file", 5)
|
||||
assistantToolMsg, _ := s.AddMessageWithParts(ctx, convID, "assistant", []MessagePart{
|
||||
{
|
||||
Type: "tool_use",
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"/tmp/test.txt"}`,
|
||||
ToolCallID: "tc_1",
|
||||
},
|
||||
}, 5)
|
||||
toolResultMsg, _ := s.AddMessageWithParts(ctx, convID, "tool", []MessagePart{
|
||||
{
|
||||
Type: "tool_result",
|
||||
ToolCallID: "tc_1",
|
||||
Text: "very large tool output",
|
||||
},
|
||||
}, 200)
|
||||
finalAssistantMsg, _ := s.AddMessage(ctx, convID, "assistant", "done", 5)
|
||||
|
||||
s.UpsertContextItems(ctx, convID, []ContextItem{
|
||||
{Ordinal: 100, ItemType: "message", MessageID: oldMsg.ID, TokenCount: 20},
|
||||
{Ordinal: 200, ItemType: "message", MessageID: userMsg.ID, TokenCount: 5},
|
||||
{Ordinal: 300, ItemType: "message", MessageID: assistantToolMsg.ID, TokenCount: 5},
|
||||
{Ordinal: 400, ItemType: "message", MessageID: toolResultMsg.ID, TokenCount: 200},
|
||||
{Ordinal: 500, ItemType: "message", MessageID: finalAssistantMsg.ID, TokenCount: 5},
|
||||
})
|
||||
|
||||
a := &Assembler{store: s, config: Config{}}
|
||||
result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 210})
|
||||
if err != nil {
|
||||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Messages) != 4 {
|
||||
t.Fatalf("Messages = %d, want 4 protected-turn messages", len(result.Messages))
|
||||
}
|
||||
if result.Messages[0].ID != userMsg.ID {
|
||||
t.Fatalf("first message ID = %d, want current user message %d", result.Messages[0].ID, userMsg.ID)
|
||||
}
|
||||
if result.Messages[1].ID != assistantToolMsg.ID {
|
||||
t.Fatalf("second message ID = %d, want assistant tool-call %d", result.Messages[1].ID, assistantToolMsg.ID)
|
||||
}
|
||||
if result.Messages[2].ID != toolResultMsg.ID {
|
||||
t.Fatalf("third message ID = %d, want tool result %d", result.Messages[2].ID, toolResultMsg.ID)
|
||||
}
|
||||
if result.Messages[3].ID != finalAssistantMsg.ID {
|
||||
t.Fatalf("fourth message ID = %d, want final assistant %d", result.Messages[3].ID, finalAssistantMsg.ID)
|
||||
}
|
||||
|
||||
totalTokens := 0
|
||||
for _, msg := range result.Messages {
|
||||
totalTokens += msg.TokenCount
|
||||
}
|
||||
if totalTokens <= 210 {
|
||||
t.Fatalf("assembled tokens = %d, want protected turn to remain over budget", totalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+108
-24
@@ -9,6 +9,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
@@ -261,6 +262,7 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa
|
||||
msg.ModelName,
|
||||
msg.ReasoningContent,
|
||||
msg.TokenCount,
|
||||
msg.CreatedAt,
|
||||
)
|
||||
} else {
|
||||
added, err = e.store.AddMessageWithReasoning(
|
||||
@@ -271,6 +273,7 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa
|
||||
msg.ModelName,
|
||||
msg.ReasoningContent,
|
||||
msg.TokenCount,
|
||||
msg.CreatedAt,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -445,10 +448,14 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
|
||||
if err != nil {
|
||||
return fmt.Errorf("bootstrap: repair model_name: %w", err)
|
||||
}
|
||||
if (repairedReasoning || repairedModelName) && len(dbMsgs) == len(messages) {
|
||||
repairedCreatedAt, err := e.repairBootstrapCreatedAt(ctx, dbMsgs, messages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bootstrap: repair created_at: %w", err)
|
||||
}
|
||||
if (repairedReasoning || repairedModelName || repairedCreatedAt) && len(dbMsgs) == len(messages) {
|
||||
matched := true
|
||||
for i := range messages {
|
||||
if !messageMatches(dbMsgs[i], messages[i]) {
|
||||
if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
@@ -462,7 +469,7 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
|
||||
if len(dbMsgs) == len(messages) {
|
||||
matched := true
|
||||
for i := range messages {
|
||||
if !messageMatches(dbMsgs[i], messages[i]) {
|
||||
if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
@@ -477,7 +484,7 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
|
||||
compareLen := min(len(dbMsgs), len(messages))
|
||||
|
||||
for i := range compareLen {
|
||||
if messageMatches(dbMsgs[i], messages[i]) {
|
||||
if messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) {
|
||||
anchor = i
|
||||
} else {
|
||||
// Mismatch detected - log details and rebuild
|
||||
@@ -578,7 +585,11 @@ func (e *Engine) repairBootstrapReasoningContent(ctx context.Context, dbMsgs, me
|
||||
}
|
||||
|
||||
for i := range overlap {
|
||||
if !messageMatchesIgnoringReasoningAndModelName(dbMsgs[i], messages[i]) {
|
||||
if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{
|
||||
IgnoreReasoningContent: true,
|
||||
IgnoreModelName: true,
|
||||
IgnoreCreatedAt: true,
|
||||
}) {
|
||||
return false, nil
|
||||
}
|
||||
if dbMsgs[i].ReasoningContent == messages[i].ReasoningContent {
|
||||
@@ -629,7 +640,11 @@ func (e *Engine) repairBootstrapModelName(ctx context.Context, dbMsgs, messages
|
||||
}
|
||||
|
||||
for i := range overlap {
|
||||
if !messageMatchesIgnoringReasoningAndModelName(dbMsgs[i], messages[i]) {
|
||||
if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{
|
||||
IgnoreReasoningContent: true,
|
||||
IgnoreModelName: true,
|
||||
IgnoreCreatedAt: true,
|
||||
}) {
|
||||
return false, nil
|
||||
}
|
||||
if dbMsgs[i].ModelName == messages[i].ModelName {
|
||||
@@ -666,6 +681,64 @@ func (e *Engine) repairBootstrapModelName(ctx context.Context, dbMsgs, messages
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (e *Engine) repairBootstrapCreatedAt(ctx context.Context, dbMsgs, messages []Message) (bool, error) {
|
||||
if len(dbMsgs) == 0 || len(messages) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
overlap := min(len(messages), len(dbMsgs))
|
||||
|
||||
var updates []struct {
|
||||
index int
|
||||
messageID int64
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
for i := range overlap {
|
||||
if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{
|
||||
IgnoreReasoningContent: true,
|
||||
IgnoreModelName: true,
|
||||
IgnoreCreatedAt: true,
|
||||
}) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
wantCreatedAt := normalizeMessageCreatedAt(messages[i].CreatedAt)
|
||||
if wantCreatedAt.IsZero() {
|
||||
return false, nil
|
||||
}
|
||||
if dbMsgs[i].CreatedAt.Equal(wantCreatedAt) {
|
||||
continue
|
||||
}
|
||||
|
||||
updates = append(updates, struct {
|
||||
index int
|
||||
messageID int64
|
||||
createdAt time.Time
|
||||
}{
|
||||
index: i,
|
||||
messageID: dbMsgs[i].ID,
|
||||
createdAt: wantCreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for _, update := range updates {
|
||||
if err := e.store.UpdateMessageCreatedAt(ctx, update.messageID, update.createdAt); err != nil {
|
||||
return false, err
|
||||
}
|
||||
dbMsgs[update.index].CreatedAt = update.createdAt
|
||||
}
|
||||
|
||||
logger.InfoCF("seahorse", "bootstrap: repaired message created_at", map[string]any{
|
||||
"messages": len(updates),
|
||||
})
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// truncate shortens a string for logging.
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
@@ -674,29 +747,28 @@ func truncate(s string, maxLen int) string {
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// messageMatches compares two messages using role + reasoning_content and then
|
||||
// either content or parts. TokenCount is NOT compared because it may be
|
||||
// re-estimated differently during bootstrap (e.g., via tokenizer.EstimateMessageTokens).
|
||||
// For messages with Parts (tool_use, tool_result), compare Parts instead of Content
|
||||
// because structured messages are matched by their parts payload.
|
||||
func messageMatches(a, b Message) bool {
|
||||
if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent || a.ModelName != b.ModelName {
|
||||
return false
|
||||
}
|
||||
return messageMatchesIgnoringReasoning(a, b)
|
||||
type messageMatchOptions struct {
|
||||
IgnoreReasoningContent bool
|
||||
IgnoreModelName bool
|
||||
IgnoreCreatedAt bool
|
||||
}
|
||||
|
||||
func messageMatchesIgnoringReasoning(a, b Message) bool {
|
||||
if a.ModelName != b.ModelName {
|
||||
return false
|
||||
}
|
||||
return messageMatchesIgnoringReasoningAndModelName(a, b)
|
||||
}
|
||||
|
||||
func messageMatchesIgnoringReasoningAndModelName(a, b Message) bool {
|
||||
// messagesMatch compares two messages by role and payload, plus the optional
|
||||
// metadata fields used by bootstrap repair. TokenCount is intentionally ignored
|
||||
// because bootstrap may re-estimate it differently.
|
||||
func messagesMatch(a, b Message, opts messageMatchOptions) bool {
|
||||
if a.Role != b.Role {
|
||||
return false
|
||||
}
|
||||
if !opts.IgnoreReasoningContent && a.ReasoningContent != b.ReasoningContent {
|
||||
return false
|
||||
}
|
||||
if !opts.IgnoreModelName && a.ModelName != b.ModelName {
|
||||
return false
|
||||
}
|
||||
if !opts.IgnoreCreatedAt && !messageCreatedAtMatches(a.CreatedAt, b.CreatedAt) {
|
||||
return false
|
||||
}
|
||||
// If either message has Parts, compare Parts
|
||||
if len(a.Parts) > 0 || len(b.Parts) > 0 {
|
||||
return partsMatch(a.Parts, b.Parts)
|
||||
@@ -705,6 +777,18 @@ func messageMatchesIgnoringReasoningAndModelName(a, b Message) bool {
|
||||
return a.Content == b.Content
|
||||
}
|
||||
|
||||
// messageCreatedAtMatches treats missing timestamps as compatible so bootstrap
|
||||
// can preserve legacy histories while still enforcing exact equality once both
|
||||
// sides carry canonical created_at values.
|
||||
func messageCreatedAtMatches(a, b time.Time) bool {
|
||||
na := normalizeMessageCreatedAt(a)
|
||||
nb := normalizeMessageCreatedAt(b)
|
||||
if na.IsZero() || nb.IsZero() {
|
||||
return true
|
||||
}
|
||||
return na.Equal(nb)
|
||||
}
|
||||
|
||||
// partsMatch compares two slices of MessagePart for equality.
|
||||
func partsMatch(a, b []MessagePart) bool {
|
||||
if len(a) != len(b) {
|
||||
|
||||
@@ -57,8 +57,8 @@ func prepareBootstrapRepairConversation(
|
||||
}
|
||||
|
||||
return conv, []Message{
|
||||
{Role: "user", Content: "hello", TokenCount: 3},
|
||||
{Role: "assistant", Content: "world", TokenCount: 3},
|
||||
{Role: "user", Content: "hello", TokenCount: 3, CreatedAt: userMsg.CreatedAt},
|
||||
{Role: "assistant", Content: "world", TokenCount: 3, CreatedAt: assistantMsg.CreatedAt},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,13 +464,19 @@ func TestBootstrapRepairsReasoningContentAndModelNameTogether(t *testing.T) {
|
||||
}
|
||||
|
||||
err = eng.Bootstrap(ctx, sessionKey, []Message{
|
||||
{Role: "user", Content: "hello", TokenCount: 3},
|
||||
{
|
||||
Role: "user",
|
||||
Content: "hello",
|
||||
TokenCount: 3,
|
||||
CreatedAt: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "world",
|
||||
ModelName: "gpt-5.4",
|
||||
ReasoningContent: "let me think this through",
|
||||
TokenCount: 3,
|
||||
CreatedAt: time.Date(2026, 3, 4, 5, 6, 8, 0, time.UTC),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -515,6 +521,7 @@ func TestBootstrapRepairsIncorrectNonEmptyModelName(t *testing.T) {
|
||||
"wrong-model",
|
||||
"",
|
||||
3,
|
||||
time.Time{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessageWithReasoning assistant: %v", err)
|
||||
@@ -545,6 +552,64 @@ func TestBootstrapRepairsIncorrectNonEmptyModelName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapRepairsCreatedAt(t *testing.T) {
|
||||
eng := newTestEngine(t)
|
||||
ctx := context.Background()
|
||||
sessionKey := "agent:repair-created-at"
|
||||
conv, msgs := prepareBootstrapRepairConversation(t, eng, ctx, sessionKey)
|
||||
|
||||
wantCreatedAt := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC)
|
||||
msgs[1].CreatedAt = wantCreatedAt
|
||||
|
||||
err := eng.Bootstrap(ctx, sessionKey, msgs)
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap: %v", err)
|
||||
}
|
||||
|
||||
stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessages: %v", err)
|
||||
}
|
||||
if len(stored) != 2 {
|
||||
t.Fatalf("stored messages = %d, want 2", len(stored))
|
||||
}
|
||||
if !stored[1].CreatedAt.Equal(wantCreatedAt) {
|
||||
t.Fatalf("stored[1].CreatedAt = %v, want %v", stored[1].CreatedAt, wantCreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineIngestPreservesCreatedAt(t *testing.T) {
|
||||
eng := newTestEngine(t)
|
||||
ctx := context.Background()
|
||||
wantCreatedAt := time.Date(2026, 4, 5, 6, 7, 8, 0, time.UTC)
|
||||
|
||||
msgs := []Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "world",
|
||||
TokenCount: 4,
|
||||
CreatedAt: wantCreatedAt,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := eng.Ingest(ctx, "agent:created-at", msgs)
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
|
||||
conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:created-at")
|
||||
stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessages: %v", err)
|
||||
}
|
||||
if len(stored) != 1 {
|
||||
t.Fatalf("stored messages = %d, want 1", len(stored))
|
||||
}
|
||||
if !stored[0].CreatedAt.Equal(wantCreatedAt) {
|
||||
t.Fatalf("stored[0].CreatedAt = %v, want %v", stored[0].CreatedAt, wantCreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineIngestWithPartsPreservesReasoningContent(t *testing.T) {
|
||||
eng := newTestEngine(t)
|
||||
ctx := context.Background()
|
||||
@@ -864,8 +929,19 @@ func TestBootstrapRepairsMissingReasoningContentWithoutDroppingSummaries(t *test
|
||||
}
|
||||
|
||||
err = eng.Bootstrap(ctx, sessionKey, []Message{
|
||||
{Role: "user", Content: "hello", TokenCount: 3},
|
||||
{Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3},
|
||||
{
|
||||
Role: "user",
|
||||
Content: "hello",
|
||||
TokenCount: 3,
|
||||
CreatedAt: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "world",
|
||||
ReasoningContent: "let me think this through",
|
||||
TokenCount: 3,
|
||||
CreatedAt: time.Date(2026, 3, 4, 5, 6, 8, 0, time.UTC),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap: %v", err)
|
||||
|
||||
@@ -22,7 +22,10 @@ func ParseLastDuration(s string) (time.Duration, error) {
|
||||
return 0, fmt.Errorf("invalid duration format: %q (use format like 6h, 7d, 2w, 1m)", s)
|
||||
}
|
||||
|
||||
value, _ := strconv.Atoi(matches[1])
|
||||
value, err := strconv.Atoi(matches[1])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration value: %q", matches[1])
|
||||
}
|
||||
unit := matches[2]
|
||||
|
||||
switch unit {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user