mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Compare commits
264 Commits
eb0653074b
...
nightly
| Author | SHA1 | Date | |
|---|---|---|---|
| 413d37494b | |||
| d153952c15 | |||
| 091c374348 | |||
| 0994d48e77 | |||
| 729935ef1a | |||
| 293fb69439 | |||
| f30c611327 | |||
| 23b52ebef5 | |||
| 0d4b06f088 | |||
| 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 | |||
| 89e7a61a69 | |||
| e9e653fb13 | |||
| 5755b5b323 | |||
| 65c09d4270 | |||
| 28ec5793a8 | |||
| c5a016ccc6 | |||
| 9825b4782f | |||
| f5f6fdc1f9 | |||
| cfbddcd117 | |||
| 7be20bf70a | |||
| ab6d3946a5 | |||
| 7af40d49eb | |||
| 239a98e18b | |||
| d499cbece4 | |||
| d48fa2e2fd | |||
| e95bcaf3e3 | |||
| fbea699936 | |||
| 23e1485a98 | |||
| edcae17b41 | |||
| d609e83313 | |||
| 96b4c543f4 | |||
| 477028f8f2 | |||
| 9bb44b0a80 | |||
| 6a97b1b087 | |||
| 020bef2759 | |||
| 848bf77381 | |||
| 3a454593ca | |||
| ceebda35ee | |||
| 1bf0d898de | |||
| c05e5e29c6 | |||
| 987f117f31 | |||
| 5a4e42d1b6 | |||
| f09a7d67f7 | |||
| 2cce7b8abe | |||
| 044a9d1df6 | |||
| d3ac0a74c4 | |||
| 24e8285e73 | |||
| 33e5503e26 | |||
| fd08ebd3db | |||
| 34e73f6b1a | |||
| 3e30e8abc6 | |||
| 81bbef62b1 | |||
| 2992eccbf0 | |||
| 76175b4bcf | |||
| 0dfdb54198 | |||
| 1bc7abfb50 | |||
| 5bbebb5fc8 | |||
| f55d7a0598 | |||
| 30938df40b | |||
| e7e21df354 | |||
| 33f9d63862 | |||
| 17cf91771c | |||
| f0dcba8c5a | |||
| b7db059544 | |||
| 548dc15acd | |||
| 639b32703a | |||
| fe7ded5c13 | |||
| 1502636bf0 | |||
| 941bac2332 | |||
| 3f653161e3 | |||
| f53222f6a4 | |||
| d61902d42a | |||
| cb5d33124c | |||
| 68e572f969 | |||
| 57876248e2 | |||
| 789f907f6d | |||
| feacd84b84 | |||
| 604187e312 | |||
| 0df050ff2e | |||
| 6817aa5311 | |||
| 412705783d | |||
| bfb2b35f74 | |||
| b225629af8 | |||
| c62a9bf55b | |||
| f7d25c6546 | |||
| 215d98aa78 | |||
| dab8391344 | |||
| a4abbf62e2 | |||
| 8ab455171c | |||
| eec4436e64 | |||
| dc41c9c566 | |||
| 2f8429f57c | |||
| d8385ce0a7 | |||
| 89631b8671 | |||
| 4db1168962 | |||
| f6190b54de | |||
| 6ae7dc38b9 | |||
| 10f4466a7e | |||
| 794eb04f32 | |||
| ffb8243721 | |||
| ec21ddc222 | |||
| ffe091d8b2 | |||
| 4edbc73b64 | |||
| ffc8bdba36 | |||
| 0ac8703e0f | |||
| 131f33f084 | |||
| 6dd30a0c77 | |||
| 7a8d7fb218 | |||
| 703f630f33 | |||
| 6e8590900b | |||
| e304dce40e | |||
| b00ff5bc5d |
@@ -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]
|
||||
@@ -5,7 +5,18 @@ on:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
name: Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run Docker-backed integration suites
|
||||
run: bash ./scripts/run-integration-tests.sh
|
||||
|
||||
build:
|
||||
needs: integration
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -64,3 +64,13 @@ jobs:
|
||||
|
||||
- name: Run go test
|
||||
run: go test -tags goolm,stdjson ./...
|
||||
|
||||
integration:
|
||||
name: Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run Docker-backed integration suites
|
||||
run: bash ./scripts/run-integration-tests.sh
|
||||
|
||||
@@ -73,3 +73,4 @@ web/backend/dist/*
|
||||
docker/data
|
||||
|
||||
.omc/
|
||||
.worktrees/
|
||||
|
||||
@@ -73,10 +73,13 @@ make check # Full pre-commit check: deps + fmt + vet + test + docs consist
|
||||
|
||||
```bash
|
||||
make test # Run all tests
|
||||
make integration-test # Run Docker-backed integration suites
|
||||
go test -run TestName -v ./pkg/session/ # Run a single test
|
||||
go test -bench=. -benchmem -run='^$' ./... # Run benchmarks
|
||||
```
|
||||
|
||||
Docker-backed integration suites are auto-discovered from [`integration/suites/`](integration/suites/). See [`integration/README.md`](integration/README.md) for the suite layout and the conventions used by CI.
|
||||
|
||||
### Code Style
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all build install uninstall clean help test build-all lint-docs
|
||||
.PHONY: all build install uninstall clean help test integration-test build-all lint-docs
|
||||
|
||||
# Build variables
|
||||
BINARY_NAME=picoclaw
|
||||
@@ -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
|
||||
|
||||
@@ -379,6 +379,10 @@ test: generate
|
||||
@$(GO) test $(GOFLAGS) $$($(GO) list $(GOFLAGS) ./... | grep -v github.com/sipeed/picoclaw/web/)
|
||||
@cd web && make test
|
||||
|
||||
## integration-test: Run Docker-backed integration test suites
|
||||
integration-test:
|
||||
@bash ./scripts/run-integration-tests.sh
|
||||
|
||||
## fmt: Format Go code
|
||||
fmt:
|
||||
@$(GOLANGCI_LINT) fmt
|
||||
|
||||
@@ -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>
|
||||
@@ -493,9 +507,11 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too
|
||||
| Search Engine | API Key | Free Tier | Link |
|
||||
|--------------|---------|-----------|------|
|
||||
| DuckDuckGo | Not needed | Unlimited | Built-in fallback |
|
||||
| [Gemini Google Search](https://aistudio.google.com/apikey) | Required | Varies | Gemini with Google Search grounding |
|
||||
| [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: 432 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()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func NewConfigCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Manage configuration",
|
||||
}
|
||||
|
||||
cmd.AddCommand(newResetCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newResetCommand() *cobra.Command {
|
||||
var force bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "reset",
|
||||
Short: "Reset configuration to factory defaults",
|
||||
Args: cobra.NoArgs,
|
||||
Example: ` picoclaw config reset
|
||||
picoclaw config reset --force`,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
if !force {
|
||||
fmt.Print("Reset config to factory defaults? API keys will be preserved. (y/n): ")
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
if strings.ToLower(strings.TrimSpace(response)) != "y" {
|
||||
fmt.Println("Aborted.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
configPath := internal.GetConfigPath()
|
||||
if err := config.ResetToDefaults(configPath); err != nil {
|
||||
return fmt.Errorf("reset failed: %w", err)
|
||||
}
|
||||
fmt.Println("Configuration has been reset to factory defaults.")
|
||||
fmt.Println("A backup of the previous config was created in the same directory.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&force, "force", "f", false,
|
||||
"Skip confirmation prompt")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func newAddCommand() *cobra.Command {
|
||||
flags.StringArrayP("env", "e", nil, "Environment variable in KEY=value format (repeatable, saved to config)")
|
||||
flags.String("env-file", "", "Path to an env file for stdio servers (recommended for secrets)")
|
||||
flags.StringArrayP("header", "H", nil, "HTTP header in 'Name: Value' or 'Name=Value' format (repeatable)")
|
||||
flags.StringP("transport", "t", "stdio", "Transport type: stdio, http, or sse")
|
||||
flags.StringP("transport", "t", "stdio", "Transport type: stdio, http / streamable-http, or sse")
|
||||
flags.BoolP("force", "f", false, "Overwrite an existing server without prompting")
|
||||
flags.Bool("deferred", false, "Mark server as deferred (tools hidden until explicitly activated)")
|
||||
flags.Bool("no-deferred", false, "Mark server as non-deferred (tools always active)")
|
||||
@@ -173,7 +173,7 @@ func parseAddArgs(args []string) (addOptions, string, string, []string, bool, er
|
||||
}
|
||||
|
||||
func buildServerConfig(target string, args []string, opts addOptions) (config.MCPServerConfig, error) {
|
||||
transport := strings.ToLower(strings.TrimSpace(opts.Transport))
|
||||
transport := config.NormalizeMCPTransportType(opts.Transport)
|
||||
if transport == "" {
|
||||
transport = "stdio"
|
||||
}
|
||||
|
||||
@@ -296,6 +296,47 @@ func TestMCPAddHTTPServer(t *testing.T) {
|
||||
assert.Empty(t, server.Command)
|
||||
}
|
||||
|
||||
func TestMCPAddSupportsStreamableHTTPAlias(t *testing.T) {
|
||||
configPath := setupMCPConfigEnv(t)
|
||||
|
||||
cmd := NewMCPCommand()
|
||||
_, err := executeCommand(cmd, []string{
|
||||
"add",
|
||||
"context7",
|
||||
"--transport",
|
||||
"streamable-http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
}, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := readMCPConfig(t, configPath)
|
||||
server := cfg.Tools.MCP.Servers["context7"]
|
||||
assert.Equal(t, "http", server.Type)
|
||||
assert.Equal(t, "https://mcp.context7.com/mcp", server.URL)
|
||||
}
|
||||
|
||||
func TestSaveValidatedConfigNormalizesStreamableHTTPAlias(t *testing.T) {
|
||||
configPath := setupMCPConfigEnv(t)
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Tools.MCP.Enabled = true
|
||||
cfg.Tools.MCP.Servers = map[string]config.MCPServerConfig{
|
||||
"context7": {
|
||||
Enabled: true,
|
||||
Type: "streamable-http",
|
||||
URL: "https://mcp.context7.com/mcp",
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, saveValidatedConfig(cfg))
|
||||
|
||||
saved := readMCPConfig(t, configPath)
|
||||
server := saved.Tools.MCP.Servers["context7"]
|
||||
assert.Equal(t, "http", server.Type)
|
||||
assert.Equal(t, "https://mcp.context7.com/mcp", server.URL)
|
||||
assert.Equal(t, "streamable-http", cfg.Tools.MCP.Servers["context7"].Type)
|
||||
}
|
||||
|
||||
func TestMCPRemoveRemovesLastServerAndDisablesMCP(t *testing.T) {
|
||||
configPath := setupMCPConfigEnv(t)
|
||||
writeMCPConfig(t, configPath, &config.Config{
|
||||
|
||||
@@ -108,7 +108,9 @@ func saveValidatedConfig(cfg *config.Config) error {
|
||||
return fmt.Errorf("config is nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
normalizedCfg := normalizedConfigForSave(cfg)
|
||||
|
||||
data, err := json.Marshal(normalizedCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize config: %w", err)
|
||||
}
|
||||
@@ -117,13 +119,32 @@ func saveValidatedConfig(cfg *config.Config) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(internal.GetConfigPath(), cfg); err != nil {
|
||||
if err := config.SaveConfig(internal.GetConfigPath(), normalizedCfg); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizedConfigForSave(cfg *config.Config) *config.Config {
|
||||
clone := *cfg
|
||||
if cfg.Tools.MCP.Servers == nil {
|
||||
return &clone
|
||||
}
|
||||
|
||||
clone.Tools = cfg.Tools
|
||||
clone.Tools.MCP = cfg.Tools.MCP
|
||||
clone.Tools.MCP.Servers = make(map[string]config.MCPServerConfig, len(cfg.Tools.MCP.Servers))
|
||||
for name, server := range cfg.Tools.MCP.Servers {
|
||||
if server.Type != "" {
|
||||
server.Type = config.NormalizeMCPTransportType(server.Type)
|
||||
}
|
||||
clone.Tools.MCP.Servers[name] = server
|
||||
}
|
||||
|
||||
return &clone
|
||||
}
|
||||
|
||||
func validateConfigDocument(data []byte) error {
|
||||
var instance map[string]any
|
||||
if err := json.Unmarshal(data, &instance); err != nil {
|
||||
@@ -156,18 +177,12 @@ func loadMCPConfigSchema() (*jsonschema.Resolved, error) {
|
||||
}
|
||||
|
||||
func inferTransportType(server config.MCPServerConfig) string {
|
||||
switch server.Type {
|
||||
case "stdio", "http", "sse":
|
||||
return server.Type
|
||||
}
|
||||
if server.URL != "" {
|
||||
return "sse"
|
||||
}
|
||||
if server.Command != "" {
|
||||
return "stdio"
|
||||
}
|
||||
transport := config.EffectiveMCPTransportType(server)
|
||||
if transport == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return transport
|
||||
}
|
||||
|
||||
func renderServerTarget(server config.MCPServerConfig) string {
|
||||
transport := inferTransportType(server)
|
||||
|
||||
@@ -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"
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
||||
configcmd "github.com/sipeed/picoclaw/cmd/picoclaw/internal/config"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/mcp"
|
||||
@@ -32,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")
|
||||
@@ -82,6 +128,7 @@ picoclaw --no-color status`,
|
||||
})
|
||||
|
||||
cmd.AddCommand(
|
||||
configcmd.NewConfigCommand(),
|
||||
onboard.NewOnboardCommand(),
|
||||
agent.NewAgentCommand(),
|
||||
auth.NewAuthCommand(),
|
||||
@@ -121,6 +168,9 @@ const (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Initialize Termux SSL certificate detection before anything else
|
||||
initTermuxSSL()
|
||||
|
||||
cliui.Init(earlyColorDisabled())
|
||||
|
||||
if earlyColorDisabled() {
|
||||
|
||||
@@ -39,6 +39,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||
allowedCommands := []string{
|
||||
"agent",
|
||||
"auth",
|
||||
"config",
|
||||
"cron",
|
||||
"gateway",
|
||||
"mcp",
|
||||
|
||||
+125
-65
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"version": 3,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
@@ -33,13 +34,13 @@
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-openai-key",
|
||||
"api_keys": ["sk-your-openai-key"],
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "sk-ant-your-key",
|
||||
"api_keys": ["sk-ant-your-key"],
|
||||
"api_base": "https://api.anthropic.com/v1",
|
||||
"thinking_level": "high"
|
||||
},
|
||||
@@ -47,7 +48,7 @@
|
||||
"_comment": "Anthropic Messages API - use native format for direct Anthropic API access",
|
||||
"model_name": "claude-opus-4-6",
|
||||
"model": "anthropic-messages/claude-opus-4-6",
|
||||
"api_key": "sk-ant-your-key",
|
||||
"api_keys": ["sk-ant-your-key"],
|
||||
"api_base": "https://api.anthropic.com"
|
||||
},
|
||||
{
|
||||
@@ -59,12 +60,12 @@
|
||||
{
|
||||
"model_name": "deepseek",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"api_key": "sk-your-deepseek-key"
|
||||
"api_keys": ["sk-your-deepseek-key"]
|
||||
},
|
||||
{
|
||||
"model_name": "venice-uncensored",
|
||||
"model": "venice/venice-uncensored",
|
||||
"api_key": "your-venice-api-key"
|
||||
"api_keys": ["your-venice-api-key"]
|
||||
},
|
||||
{
|
||||
"model_name": "lmstudio-local",
|
||||
@@ -73,114 +74,134 @@
|
||||
{
|
||||
"model_name": "longcat",
|
||||
"model": "longcat/LongCat-Flash-Thinking",
|
||||
"api_key": "your-longcat-api-key"
|
||||
"api_keys": ["your-longcat-api-key"]
|
||||
},
|
||||
{
|
||||
"model_name": "modelscope-qwen",
|
||||
"model": "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
"api_key": "your-modelscope-access-token",
|
||||
"api_keys": ["your-modelscope-access-token"],
|
||||
"api_base": "https://api-inference.modelscope.cn/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "azure-gpt5",
|
||||
"model": "azure/my-gpt5-deployment",
|
||||
"api_key": "your-azure-api-key",
|
||||
"api_keys": ["your-azure-api-key"],
|
||||
"api_base": "https://your-resource.openai.azure.com"
|
||||
},
|
||||
{
|
||||
"model_name": "loadbalanced-gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-key1",
|
||||
"api_keys": ["sk-key1"],
|
||||
"api_base": "https://api1.example.com/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "loadbalanced-gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-key2",
|
||||
"api_keys": ["sk-key2"],
|
||||
"api_base": "https://api2.example.com/v1"
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
"channel_list": {
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"type": "telegram",
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"reasoning_channel_id": "",
|
||||
"settings": {
|
||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||
"base_url": "",
|
||||
"proxy": "",
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"use_markdown_v2": false,
|
||||
"reasoning_channel_id": "",
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"enabled": false,
|
||||
"token": "YOUR_DISCORD_BOT_TOKEN",
|
||||
"proxy": "",
|
||||
"type": "discord",
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": false
|
||||
},
|
||||
"reasoning_channel_id": ""
|
||||
"reasoning_channel_id": "",
|
||||
"settings": {
|
||||
"token": "YOUR_DISCORD_BOT_TOKEN",
|
||||
"proxy": ""
|
||||
}
|
||||
},
|
||||
"qq": {
|
||||
"enabled": false,
|
||||
"app_id": "YOUR_QQ_APP_ID",
|
||||
"app_secret": "YOUR_QQ_APP_SECRET",
|
||||
"type": "qq",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
"reasoning_channel_id": "",
|
||||
"settings": {
|
||||
"app_id": "YOUR_QQ_APP_ID",
|
||||
"app_secret": "YOUR_QQ_APP_SECRET"
|
||||
}
|
||||
},
|
||||
"maixcam": {
|
||||
"enabled": false,
|
||||
"host": "0.0.0.0",
|
||||
"port": 18790,
|
||||
"type": "maixcam",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
"reasoning_channel_id": "",
|
||||
"settings": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 18790
|
||||
}
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": false,
|
||||
"type": "whatsapp",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": "",
|
||||
"settings": {
|
||||
"bridge_url": "ws://localhost:3001",
|
||||
"use_native": false,
|
||||
"session_store_path": "",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
"session_store_path": ""
|
||||
}
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"app_id": "",
|
||||
"app_secret": "",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"type": "feishu",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": "",
|
||||
"placeholder": {
|
||||
"enabled": true,
|
||||
"text": ["Thinking...", "Processing...", "Typing..."]
|
||||
},
|
||||
"reasoning_channel_id": "",
|
||||
"settings": {
|
||||
"app_id": "",
|
||||
"app_secret": "",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"random_reaction_emoji": [],
|
||||
"is_lark": false
|
||||
}
|
||||
},
|
||||
"dingtalk": {
|
||||
"enabled": false,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"type": "dingtalk",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
"reasoning_channel_id": "",
|
||||
"settings": {
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET"
|
||||
}
|
||||
},
|
||||
"slack": {
|
||||
"enabled": false,
|
||||
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
||||
"app_token": "xapp-YOUR-APP-TOKEN",
|
||||
"type": "slack",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
"reasoning_channel_id": "",
|
||||
"settings": {
|
||||
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
||||
"app_token": "xapp-YOUR-APP-TOKEN"
|
||||
}
|
||||
},
|
||||
"matrix": {
|
||||
"enabled": false,
|
||||
"homeserver": "https://matrix.org",
|
||||
"user_id": "@your-bot:matrix.org",
|
||||
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||
"device_id": "",
|
||||
"join_on_invite": true,
|
||||
"type": "matrix",
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": true
|
||||
@@ -190,57 +211,91 @@
|
||||
"text": ["Thinking...", "Processing...", "Typing..."]
|
||||
},
|
||||
"reasoning_channel_id": "",
|
||||
"settings": {
|
||||
"homeserver": "https://matrix.org",
|
||||
"user_id": "@your-bot:matrix.org",
|
||||
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||
"device_id": "",
|
||||
"join_on_invite": true,
|
||||
"crypto_database_path": "",
|
||||
"crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY"
|
||||
}
|
||||
},
|
||||
"line": {
|
||||
"enabled": false,
|
||||
"type": "line",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": "",
|
||||
"settings": {
|
||||
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
||||
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_path": "/webhook/line",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
"webhook_path": "/webhook/line"
|
||||
}
|
||||
},
|
||||
"onebot": {
|
||||
"enabled": false,
|
||||
"type": "onebot",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": "",
|
||||
"group_trigger": {
|
||||
"prefixes": []
|
||||
},
|
||||
"settings": {
|
||||
"ws_url": "ws://127.0.0.1:3001",
|
||||
"access_token": "",
|
||||
"reconnect_interval": 5,
|
||||
"group_trigger_prefix": [],
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
"reconnect_interval": 5
|
||||
}
|
||||
},
|
||||
"wecom": {
|
||||
"_comment": "WeCom AI Bot over WebSocket.",
|
||||
"enabled": false,
|
||||
"type": "wecom",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": "",
|
||||
"settings": {
|
||||
"bot_id": "YOUR_BOT_ID",
|
||||
"secret": "YOUR_SECRET",
|
||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||
"send_thinking_message": true,
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
"send_thinking_message": true
|
||||
}
|
||||
},
|
||||
"pico": {
|
||||
"enabled": false,
|
||||
"type": "pico",
|
||||
"allow_from": [],
|
||||
"settings": {
|
||||
"token": "YOUR_PICO_TOKEN",
|
||||
"allow_token_query": false,
|
||||
"allow_origins": [],
|
||||
"ping_interval": 30,
|
||||
"read_timeout": 60,
|
||||
"max_connections": 100,
|
||||
"allow_from": []
|
||||
"max_connections": 100
|
||||
}
|
||||
},
|
||||
"pico_client": {
|
||||
"enabled": false,
|
||||
"type": "pico_client",
|
||||
"allow_from": [],
|
||||
"settings": {
|
||||
"url": "wss://remote-pico-server/pico/ws",
|
||||
"token": "YOUR_PICO_TOKEN",
|
||||
"session_id": "",
|
||||
"ping_interval": 30,
|
||||
"read_timeout": 60,
|
||||
"allow_from": []
|
||||
"read_timeout": 60
|
||||
}
|
||||
},
|
||||
"irc": {
|
||||
"enabled": false,
|
||||
"type": "irc",
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": true
|
||||
},
|
||||
"typing": {
|
||||
"enabled": false
|
||||
},
|
||||
"reasoning_channel_id": "",
|
||||
"settings": {
|
||||
"server": "irc.libera.chat:6697",
|
||||
"tls": true,
|
||||
"nick": "mybot",
|
||||
@@ -251,15 +306,8 @@
|
||||
"sasl_user": "",
|
||||
"sasl_password": "",
|
||||
"channels": ["#mychannel"],
|
||||
"request_caps": ["server-time", "message-tags"],
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": true
|
||||
},
|
||||
"typing": {
|
||||
"enabled": false
|
||||
},
|
||||
"reasoning_channel_id": ""
|
||||
"request_caps": ["server-time", "message-tags"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -268,7 +316,6 @@
|
||||
"web": {
|
||||
"enabled": true,
|
||||
"prefer_native": true,
|
||||
"fetch_limit_bytes": 10485760,
|
||||
"format": "plaintext",
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
@@ -282,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,
|
||||
@@ -291,6 +345,12 @@
|
||||
"enabled": false,
|
||||
"max_results": 5
|
||||
},
|
||||
"gemini": {
|
||||
"enabled": false,
|
||||
"api_key": "",
|
||||
"model": "gemini-2.5-flash",
|
||||
"max_results": 5
|
||||
},
|
||||
"perplexity": {
|
||||
"enabled": false,
|
||||
"api_key": "pplx-xxx",
|
||||
|
||||
@@ -78,17 +78,17 @@ Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
|
||||
"model_name": "deepseek-chat",
|
||||
"model": "openai/deepseek-chat",
|
||||
"api_base": "https://api.deepseek.com/v1",
|
||||
"api_key": "sk-xxx"
|
||||
"api_keys": ["sk-xxx"]
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-xxx"
|
||||
"api_keys": ["sk-xxx"]
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "sk-xxx"
|
||||
"api_keys": ["sk-xxx"]
|
||||
},
|
||||
{
|
||||
"model_name": "gemini-3-flash",
|
||||
@@ -99,7 +99,7 @@ Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
|
||||
"model_name": "my-company-llm",
|
||||
"model": "openai/company-model-v1",
|
||||
"api_base": "https://llm.company.com/v1",
|
||||
"api_key": "xxx"
|
||||
"api_keys": ["xxx"]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -252,7 +252,7 @@ func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
|
||||
{
|
||||
"providers": {
|
||||
"deepseek": {
|
||||
"api_key": "sk-xxx",
|
||||
"api_keys": ["sk-xxx"],
|
||||
"api_base": "https://api.deepseek.com/v1"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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. |
|
||||
@@ -66,6 +66,50 @@ PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.
|
||||
|
||||
> **Remarque :** Les modifications apportées à `AGENT.md`, `SOUL.md`, `USER.md` et `memory/MEMORY.md` sont détectées automatiquement au moment de l'exécution via le suivi de la date de modification (mtime). Il n'est **pas nécessaire de redémarrer le gateway** après avoir modifié ces fichiers — l'agent charge le nouveau contenu à la prochaine requête.
|
||||
|
||||
### Politique de contexte de requête
|
||||
|
||||
`turn_profile` est une politique facultative sous `agents.defaults.turn_profile` pour contrôler le contexte chargé par chaque nouveau tour : historique, prompt système, prompts de skills et outils autorisés. Sans cette configuration, ou avec `"enabled": false`, PicoClaw garde son comportement normal. Avec `"enabled": true`, la politique ci-dessous s'applique à chaque nouveau tour.
|
||||
|
||||
Tous les blocs utilisent les mêmes valeurs de `mode` :
|
||||
|
||||
| Mode | Signification |
|
||||
| --- | --- |
|
||||
| `default` | Garde le comportement normal de PicoClaw. Un bloc absent ou sans `mode` vaut `default`. |
|
||||
| `off` | Désactive ce bloc pour le tour. |
|
||||
| `custom` | Utilise une liste d'autorisation. Dans cette version, `custom` est pris en charge seulement pour `skills` et `tools`; l'utiliser pour `history` ou `system_prompt` produit une erreur de validation. |
|
||||
|
||||
Blocs disponibles :
|
||||
|
||||
| Bloc | Ce qu'il contrôle |
|
||||
| --- | --- |
|
||||
| `history` | Lecture de l'historique et du résumé, écriture des messages utilisateur/assistant/outil, ingestion de contexte, compression et résumé. |
|
||||
| `system_prompt` | Injection de l'identité PicoClaw, des instructions de l'espace de travail, de la mémoire, du contexte d'exécution et du résumé. Les prompts système externes restent autorisés quand ce bloc est `off`. |
|
||||
| `skills` | Chargement du catalogue de skills et du contenu des skills actifs. `custom.allow` ne garde que les noms listés. |
|
||||
| `tools` | Outils exposés au modèle et autorisés à l'exécution. `custom.allow` ne garde que les outils enregistrés et listés. |
|
||||
|
||||
Quand `system_prompt.mode` vaut `off`, que des outils restent visibles et qu'aucun prompt système externe n'est fourni, PicoClaw réutilise sa règle existante d'utilisation des outils comme prompt minimal de secours. Si `tools.mode` vaut `off`, ce prompt de secours n'est pas ajouté.
|
||||
|
||||
Exemple de contexte propre avec outils web :
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"turn_profile": {
|
||||
"enabled": true,
|
||||
"history": { "mode": "off" },
|
||||
"system_prompt": { "mode": "off" },
|
||||
"skills": { "mode": "off" },
|
||||
"tools": {
|
||||
"mode": "custom",
|
||||
"allow": ["web_search", "web_fetch"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sources de Compétences
|
||||
|
||||
Par défaut, les compétences sont chargées depuis :
|
||||
@@ -364,6 +408,55 @@ Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectu
|
||||
|
||||
L'ancienne configuration `providers` est **dépréciée** et a été supprimée dans V2. Les configs V0/V1 existantes sont auto-migrées. Voir [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
|
||||
|
||||
#### Configuration du Streaming
|
||||
|
||||
Le streaming provider utilise un double opt-in et est désactivé par défaut. L'agent ne tente le streaming que lorsque le channel courant a `settings.streaming.enabled: true`, que l'entrée de modèle active a `streaming.enabled: true`, et que le provider comme le channel prennent en charge le streaming. Si une condition manque, PicoClaw utilise le chemin de requête non-streaming normal.
|
||||
|
||||
Pico WebUI est le premier channel entièrement câblé. Pico crée le premier message assistant avec le message wire existant `message.create`, puis met à jour ce même message avec `message.update`; aucun nouveau type de message Pico n'est introduit.
|
||||
|
||||
Laissez `streaming` absent si vous ne voulez pas de streaming. Un bloc `streaming` omis signifie désactivé; il n'est pas nécessaire d'écrire `"streaming": {"enabled": false}`.
|
||||
|
||||
Exemple d'activation :
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-your-openai-key"],
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"channel_list": {
|
||||
"pico": {
|
||||
"enabled": true,
|
||||
"type": "pico",
|
||||
"settings": {
|
||||
"token": "YOUR_PICO_TOKEN",
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Champ | Type | Défaut | Description |
|
||||
| ----- | ---- | ------ | ----------- |
|
||||
| `channel_list.<name>.settings.streaming.enabled` | bool | `false` | Autorise ce channel à afficher la sortie streaming du provider |
|
||||
| `channel_list.<name>.settings.streaming.throttle_seconds` | int | Défaut Pico après activation : `0` | Intervalle minimal entre les mises à jour intermédiaires; le contenu final est toujours envoyé |
|
||||
| `channel_list.<name>.settings.streaming.min_growth_chars` | int | Défaut Pico après activation : `1` | Croissance minimale du texte avant une mise à jour intermédiaire; le contenu final est toujours envoyé |
|
||||
| `model_list[].streaming.enabled` | bool | `false` | Autorise cette entrée de modèle à tenter des requêtes provider en streaming |
|
||||
|
||||
Les anciennes variables d'environnement Telegram restent compatibles : `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED`, `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS` et `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS`. Elles s'appliquent uniquement aux settings Telegram et n'activent ni ne modifient `settings.streaming` de Pico.
|
||||
|
||||
Le comportement d'échec est volontairement conservateur : si le streaming échoue avant l'envoi d'un chunk visible, PicoClaw réessaie une fois via le chemin `Chat()` normal. Si un chunk a déjà été affiché à l'utilisateur, PicoClaw n'envoie pas une deuxième réponse non-streaming, afin d'éviter une sortie dupliquée.
|
||||
|
||||
### Architecture des Providers
|
||||
|
||||
PicoClaw route les providers par famille de protocole :
|
||||
|
||||
@@ -31,6 +31,55 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||
```
|
||||
|
||||
### Configurazione Streaming
|
||||
|
||||
Lo streaming del provider usa un double opt-in ed è disattivato per impostazione predefinita. L'agent prova lo streaming solo quando il canale corrente ha `settings.streaming.enabled: true`, l'entry del modello attivo ha `streaming.enabled: true`, e sia il provider sia il canale supportano lo streaming. Se manca una qualsiasi condizione, PicoClaw usa il normale percorso di richiesta non streaming.
|
||||
|
||||
Pico WebUI è il primo canale completamente collegato. Pico crea il primo messaggio assistant con il wire message esistente `message.create`, poi aggiorna lo stesso messaggio con `message.update`; non viene introdotto alcun nuovo tipo di wire message Pico.
|
||||
|
||||
Lascia `streaming` assente quando non vuoi usare lo streaming. Un blocco `streaming` omesso significa disattivato; non è necessario scrivere `"streaming": {"enabled": false}`.
|
||||
|
||||
Esempio di attivazione:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-your-openai-key"],
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"channel_list": {
|
||||
"pico": {
|
||||
"enabled": true,
|
||||
"type": "pico",
|
||||
"settings": {
|
||||
"token": "YOUR_PICO_TOKEN",
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Campo | Tipo | Predefinito | Descrizione |
|
||||
| ----- | ---- | ----------- | ----------- |
|
||||
| `channel_list.<name>.settings.streaming.enabled` | bool | `false` | Permette a questo canale di mostrare l'output streaming del provider |
|
||||
| `channel_list.<name>.settings.streaming.throttle_seconds` | int | Predefinito Pico dopo l'attivazione: `0` | Intervallo minimo tra aggiornamenti intermedi; il contenuto finale viene sempre inviato |
|
||||
| `channel_list.<name>.settings.streaming.min_growth_chars` | int | Predefinito Pico dopo l'attivazione: `1` | Crescita minima del testo prima di inviare un aggiornamento intermedio; il contenuto finale viene sempre inviato |
|
||||
| `model_list[].streaming.enabled` | bool | `false` | Permette a questa entry di modello di provare richieste provider streaming |
|
||||
|
||||
Le variabili d'ambiente legacy di Telegram restano compatibili: `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED`, `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS` e `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS`. Si applicano solo alle settings Telegram e non attivano né modificano `settings.streaming` di Pico.
|
||||
|
||||
Il comportamento in caso di errore è intenzionalmente conservativo: se lo streaming fallisce prima che venga inviato un chunk visibile, PicoClaw riprova una volta tramite il normale percorso `Chat()`. Se un chunk è già stato mostrato all'utente, PicoClaw non invia una seconda risposta non streaming, evitando output duplicato.
|
||||
|
||||
### Struttura del Workspace
|
||||
|
||||
PicoClaw salva i dati nel workspace configurato (predefinito: `~/.picoclaw/workspace`):
|
||||
|
||||
@@ -67,6 +67,50 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw
|
||||
|
||||
> **注意:** `AGENT.md`、`SOUL.md`、`USER.md` および `memory/MEMORY.md` への変更は、ファイル更新時刻(mtime)の追跡により実行時に自動検出されます。これらのファイルを編集した後に **gateway を再起動する必要はありません** — Agent は次のリクエスト時に最新の内容を自動的に読み込みます。
|
||||
|
||||
### リクエストコンテキストポリシー
|
||||
|
||||
`turn_profile` は `agents.defaults.turn_profile` に置く任意のリクエストコンテキストポリシーです。各ターンに履歴、system prompt、skill prompt、許可ツールを含めるかどうかを制御します。未設定、または `"enabled": false` の場合、PicoClaw は通常動作のままです。`"enabled": true` にすると、このポリシーが各新規ターンに適用されます。
|
||||
|
||||
各ブロックは同じ `mode` を使います。
|
||||
|
||||
| Mode | 意味 |
|
||||
| --- | --- |
|
||||
| `default` | PicoClaw の通常動作を維持します。ブロックまたは `mode` が省略された場合も `default` です。 |
|
||||
| `off` | そのブロックを無効にします。 |
|
||||
| `custom` | 許可リストを使います。このバージョンでは `skills` と `tools` のみ対応し、`history` や `system_prompt` で使うと検証エラーになります。 |
|
||||
|
||||
ブロックの意味:
|
||||
|
||||
| ブロック | 制御する内容 |
|
||||
| --- | --- |
|
||||
| `history` | 履歴と要約の読み込み、ユーザー/アシスタント/ツールメッセージの保存、コンテキスト取り込み、圧縮と要約。 |
|
||||
| `system_prompt` | PicoClaw の既定の identity、ワークスペース指示、メモリ、実行時コンテキスト、要約の注入。`off` でも外部 system prompt は利用できます。 |
|
||||
| `skills` | Skill カタログと active skill のプロンプト内容。`custom.allow` は列挙した skill 名だけを残します。 |
|
||||
| `tools` | モデルに見せ、実行を許可するツール。`custom.allow` は登録済みで列挙されたツール名だけを残します。 |
|
||||
|
||||
`system_prompt.mode` が `off` で、ツールが表示され、外部 system prompt がない場合、PicoClaw は既存のツール使用ルールを最小のフォールバックプロンプトとして再利用します。`tools.mode` が `off` の場合、このフォールバックは追加されません。
|
||||
|
||||
Web ツールだけを残すクリーンなコンテキスト例:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"turn_profile": {
|
||||
"enabled": true,
|
||||
"history": { "mode": "off" },
|
||||
"system_prompt": { "mode": "off" },
|
||||
"skills": { "mode": "off" },
|
||||
"tools": {
|
||||
"mode": "custom",
|
||||
"allow": ["web_search", "web_fetch"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### スキルソース
|
||||
|
||||
デフォルトでは、スキルは以下の順序で読み込まれます:
|
||||
@@ -365,6 +409,55 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信
|
||||
|
||||
旧 `providers` 設定は**非推奨**となり、V2 で削除されました。既存の V0/V1 設定は自動的に移行されます。[docs/migration/model-list-migration.md](../migration/model-list-migration.md) を参照してください。
|
||||
|
||||
#### ストリーミング設定
|
||||
|
||||
Provider ストリーミングは二重の opt-in 方式で、デフォルトでは無効です。現在の channel に `settings.streaming.enabled: true` があり、アクティブなモデルエントリに `streaming.enabled: true` があり、さらに provider と channel の両方がストリーミングをサポートしている場合にのみ、agent はストリーミングリクエストを試行します。いずれかの条件が欠ける場合、PicoClaw は通常の非ストリーミングリクエスト経路を使います。
|
||||
|
||||
Pico WebUI が最初に完全対応した channel です。Pico は既存の `message.create` wire message で最初の assistant メッセージを作成し、その後 `message.update` で同じメッセージを更新します。新しい Pico wire message type は追加されません。
|
||||
|
||||
ストリーミングを使わない場合は `streaming` を省略してください。`streaming` ブロックの省略は無効を意味するため、`"streaming": {"enabled": false}` を書く必要はありません。
|
||||
|
||||
有効化例:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-your-openai-key"],
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"channel_list": {
|
||||
"pico": {
|
||||
"enabled": true,
|
||||
"type": "pico",
|
||||
"settings": {
|
||||
"token": "YOUR_PICO_TOKEN",
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| フィールド | 型 | デフォルト | 説明 |
|
||||
| ---------- | -- | ---------- | ---- |
|
||||
| `channel_list.<name>.settings.streaming.enabled` | bool | `false` | この channel で provider のストリーミング出力を表示できるようにします |
|
||||
| `channel_list.<name>.settings.streaming.throttle_seconds` | int | Pico で有効化後のデフォルト:`0` | 中間更新の最小間隔。最終内容は常に flush されます |
|
||||
| `channel_list.<name>.settings.streaming.min_growth_chars` | int | Pico で有効化後のデフォルト:`1` | 次の中間更新を送るために必要な最小文字増加数。最終内容は常に flush されます |
|
||||
| `model_list[].streaming.enabled` | bool | `false` | このモデルエントリで provider ストリーミングリクエストを試行できるようにします |
|
||||
|
||||
既存の Telegram 環境変数 `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED`、`PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS`、`PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS` は互換性のため引き続き使えます。これらは Telegram settings にのみ適用され、Pico の `settings.streaming` を有効化または変更しません。
|
||||
|
||||
失敗時の動作は保守的です。可視 chunk が送信される前にストリーミングが失敗した場合、PicoClaw は通常の `Chat()` 経路で一度だけ再試行します。すでに chunk がユーザーに表示されている場合は、表示済み出力の重複を避けるため、二つ目の非ストリーミング回答は送信しません。
|
||||
|
||||
### Provider アーキテクチャ
|
||||
|
||||
PicoClaw はプロトコルファミリーで Provider をルーティングします:
|
||||
|
||||
@@ -99,6 +99,50 @@ The `evolution` block controls PicoClaw's self-evolution runtime. When enabled,
|
||||
|
||||
Use `observe` first if you want to inspect learning records without generating skill changes. Use `draft` when you want PicoClaw to prepare reviewable improvements. Use `apply` only when you are comfortable letting accepted drafts update workspace skills.
|
||||
|
||||
### Request Context Policy
|
||||
|
||||
`turn_profile` is an optional request context policy under `agents.defaults.turn_profile`. Leave it unset or set `"enabled": false` to keep PicoClaw's normal behavior. When `"enabled": true`, the same policy applies to every new turn.
|
||||
|
||||
Each block uses the same `mode` values:
|
||||
|
||||
| Mode | Meaning |
|
||||
| --- | --- |
|
||||
| `default` | Keep PicoClaw's normal behavior for that block. Missing blocks and missing `mode` fields are treated as `default`. |
|
||||
| `off` | Disable that block for the turn. |
|
||||
| `custom` | Use an allow list. In this version, `custom` is supported only for `skills` and `tools`; using it for `history` or `system_prompt` is a validation error. |
|
||||
|
||||
Profile blocks:
|
||||
|
||||
| Block | What it controls |
|
||||
| --- | --- |
|
||||
| `history` | Whether the turn reads prior session history and summary, writes user/assistant/tool messages, ingests context, and runs compaction or summarization. |
|
||||
| `system_prompt` | Whether PicoClaw injects its default identity, workspace instructions, memory, runtime context, and summary. External request system prompts are still allowed when this is `off`. |
|
||||
| `skills` | Whether the skill catalog and active skill prompt content are loaded. `custom.allow` keeps only the listed skill names in prompt context. |
|
||||
| `tools` | Which callable tools are exposed to the model and allowed at execution time. `custom.allow` keeps only listed registered tool names. |
|
||||
|
||||
When `system_prompt.mode` is `off`, tools are still visible, and no external system prompt is supplied, PicoClaw uses its existing tool-use rule as the minimal fallback prompt. If `tools.mode` is `off`, no fallback prompt is added.
|
||||
|
||||
Example clean web policy:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"turn_profile": {
|
||||
"enabled": true,
|
||||
"history": { "mode": "off" },
|
||||
"system_prompt": { "mode": "off" },
|
||||
"skills": { "mode": "off" },
|
||||
"tools": {
|
||||
"mode": "custom",
|
||||
"allow": ["web_search", "web_fetch"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Web launcher dashboard
|
||||
|
||||
**picoclaw-launcher** serves a browser UI that requires password sign-in first. On first run, open `/launcher-setup` to create the dashboard password. Later manual sign-ins use `/launcher-login`.
|
||||
@@ -356,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
|
||||
|
||||
@@ -744,6 +789,55 @@ Resolution rules:
|
||||
- If `provider` is omitted, PicoClaw treats the first `/` segment in `model` as the provider and everything after that first `/` as the runtime model ID.
|
||||
- This means `"model": "openrouter/openai/gpt-5.4"` still works as a compatibility form and sends `openai/gpt-5.4` to OpenRouter.
|
||||
|
||||
#### Streaming Configuration
|
||||
|
||||
Provider streaming uses a double opt-in and is disabled by default. The agent only tries streaming when the current channel has `settings.streaming.enabled: true`, the active model entry has `streaming.enabled: true`, and both the provider and channel support streaming. If any condition is missing, PicoClaw uses the normal non-streaming request path.
|
||||
|
||||
Pico WebUI is the first fully wired channel. Pico creates the first assistant message with the existing `message.create` wire message, then updates that same message with `message.update`; no new Pico wire message type is introduced.
|
||||
|
||||
Leave `streaming` unset when you do not want streaming. An omitted `streaming` block means disabled; you do not need to write `"streaming": {"enabled": false}`.
|
||||
|
||||
Opt-in example:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-your-openai-key"],
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"channel_list": {
|
||||
"pico": {
|
||||
"enabled": true,
|
||||
"type": "pico",
|
||||
"settings": {
|
||||
"token": "YOUR_PICO_TOKEN",
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
| ----- | ---- | ------- | ----------- |
|
||||
| `channel_list.<name>.settings.streaming.enabled` | bool | `false` | Allows this channel to display provider streaming output |
|
||||
| `channel_list.<name>.settings.streaming.throttle_seconds` | int | Pico default after enabling: `0` | Minimum interval for intermediate updates; final content is always flushed |
|
||||
| `channel_list.<name>.settings.streaming.min_growth_chars` | int | Pico default after enabling: `1` | Minimum character growth before sending an intermediate update; final content is always flushed |
|
||||
| `model_list[].streaming.enabled` | bool | `false` | Allows this model entry to try provider streaming requests |
|
||||
|
||||
Legacy Telegram environment variables remain compatible: `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED`, `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS`, and `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS`. They only apply to Telegram settings and do not enable or modify Pico `settings.streaming`.
|
||||
|
||||
Failure behavior is intentionally conservative: if streaming fails before any visible chunk is sent, PicoClaw retries once through the normal `Chat()` path. If a chunk has already been shown to the user, PicoClaw does not send a second non-streaming answer, because that would duplicate visible output.
|
||||
|
||||
#### Vendor-Specific Examples
|
||||
|
||||
> **Tip**: You can omit `api_key` fields and store them in `.security.yml` for better security. See [Security Configuration](#-security-configuration-recommended).
|
||||
|
||||
@@ -31,6 +31,55 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||
```
|
||||
|
||||
### Konfigurasi Streaming
|
||||
|
||||
Provider streaming menggunakan double opt-in dan dimatikan secara lalai. Agent hanya mencuba streaming apabila saluran semasa mempunyai `settings.streaming.enabled: true`, entry model aktif mempunyai `streaming.enabled: true`, dan kedua-dua provider serta saluran menyokong streaming. Jika mana-mana syarat tiada, PicoClaw menggunakan laluan permintaan bukan streaming biasa.
|
||||
|
||||
Pico WebUI ialah saluran pertama yang disambungkan sepenuhnya. Pico mencipta mesej assistant pertama dengan wire message sedia ada `message.create`, kemudian mengemas kini mesej yang sama dengan `message.update`; tiada jenis wire message Pico baharu ditambah.
|
||||
|
||||
Biarkan `streaming` tidak ditetapkan jika anda tidak mahu streaming. Blok `streaming` yang tiada bermaksud dimatikan; anda tidak perlu menulis `"streaming": {"enabled": false}`.
|
||||
|
||||
Contoh mengaktifkan streaming:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-your-openai-key"],
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"channel_list": {
|
||||
"pico": {
|
||||
"enabled": true,
|
||||
"type": "pico",
|
||||
"settings": {
|
||||
"token": "YOUR_PICO_TOKEN",
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Kunci | Jenis | Lalai | Penerangan |
|
||||
| ----- | ----- | ----- | ---------- |
|
||||
| `channel_list.<name>.settings.streaming.enabled` | bool | `false` | Membenarkan saluran ini memaparkan output streaming provider |
|
||||
| `channel_list.<name>.settings.streaming.throttle_seconds` | int | Lalai Pico selepas diaktifkan: `0` | Jarak masa minimum antara kemas kini pertengahan; kandungan akhir sentiasa dihantar |
|
||||
| `channel_list.<name>.settings.streaming.min_growth_chars` | int | Lalai Pico selepas diaktifkan: `1` | Pertambahan aksara minimum sebelum menghantar kemas kini pertengahan; kandungan akhir sentiasa dihantar |
|
||||
| `model_list[].streaming.enabled` | bool | `false` | Membenarkan entry model ini mencuba permintaan provider streaming |
|
||||
|
||||
Pemboleh ubah persekitaran Telegram lama masih serasi: `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED`, `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS`, dan `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS`. Ia hanya digunakan untuk settings Telegram dan tidak mengaktifkan atau mengubah `settings.streaming` Pico.
|
||||
|
||||
Tingkah laku kegagalan adalah konservatif: jika streaming gagal sebelum mana-mana chunk kelihatan dihantar, PicoClaw mencuba semula sekali melalui laluan `Chat()` biasa. Jika chunk sudah dipaparkan kepada pengguna, PicoClaw tidak menghantar jawapan bukan streaming kedua untuk mengelakkan output berganda.
|
||||
|
||||
### Susun Atur Workspace
|
||||
|
||||
PicoClaw menyimpan data dalam workspace yang dikonfigurasikan (lalai: `~/.picoclaw/workspace`):
|
||||
|
||||
@@ -67,6 +67,50 @@ O PicoClaw armazena dados no seu workspace configurado (padrão: `~/.picoclaw/wo
|
||||
|
||||
> **Nota:** Alterações em `AGENT.md`, `SOUL.md`, `USER.md` e `memory/MEMORY.md` são detectadas automaticamente em tempo de execução via rastreamento de data de modificação (mtime). **Não é necessário reiniciar o gateway** após editar esses arquivos — o agente carrega o novo conteúdo na próxima requisição.
|
||||
|
||||
### Política de contexto da requisição
|
||||
|
||||
`turn_profile` é uma política opcional em `agents.defaults.turn_profile` para controlar qual contexto cada novo turno carrega: histórico, prompt de sistema, prompts de skills e ferramentas permitidas. Sem essa configuração, ou com `"enabled": false`, o PicoClaw mantém o comportamento normal. Com `"enabled": true`, a política abaixo se aplica a cada novo turno.
|
||||
|
||||
Todos os blocos usam os mesmos valores de `mode`:
|
||||
|
||||
| Mode | Significado |
|
||||
| --- | --- |
|
||||
| `default` | Mantém o comportamento normal do PicoClaw. Blocos ausentes ou sem `mode` são tratados como `default`. |
|
||||
| `off` | Desativa esse bloco para o turno. |
|
||||
| `custom` | Usa uma lista de permissão. Nesta versão, `custom` só é suportado para `skills` e `tools`; usá-lo em `history` ou `system_prompt` gera erro de validação. |
|
||||
|
||||
Blocos disponíveis:
|
||||
|
||||
| Bloco | O que controla |
|
||||
| --- | --- |
|
||||
| `history` | Leitura de histórico e resumo, gravação de mensagens de usuário/assistente/ferramenta, ingestão de contexto, compactação e resumo. |
|
||||
| `system_prompt` | Injeção da identidade padrão do PicoClaw, instruções do workspace, memória, contexto de execução e resumo. Prompts de sistema externos ainda são permitidos quando este bloco está `off`. |
|
||||
| `skills` | Catálogo de skills e conteúdo de skills ativas no prompt. `custom.allow` mantém apenas os nomes listados. |
|
||||
| `tools` | Ferramentas visíveis ao modelo e permitidas na execução. `custom.allow` mantém apenas ferramentas registradas e listadas. |
|
||||
|
||||
Quando `system_prompt.mode` é `off`, ferramentas continuam visíveis e nenhum prompt de sistema externo é fornecido, o PicoClaw reutiliza sua regra existente de uso de ferramentas como prompt mínimo de fallback. Se `tools.mode` é `off`, esse fallback não é adicionado.
|
||||
|
||||
Exemplo de contexto limpo com ferramentas web:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"turn_profile": {
|
||||
"enabled": true,
|
||||
"history": { "mode": "off" },
|
||||
"system_prompt": { "mode": "off" },
|
||||
"skills": { "mode": "off" },
|
||||
"tools": {
|
||||
"mode": "custom",
|
||||
"allow": ["web_search", "web_fetch"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fontes de Skills
|
||||
|
||||
Por padrão, as skills são carregadas de:
|
||||
@@ -365,6 +409,55 @@ Configure múltiplos endpoints para o mesmo nome de modelo — PicoClaw fará ro
|
||||
|
||||
A configuração antiga `providers` está **depreciada** e foi removida no V2. Configs V0/V1 existentes são auto-migradas. Veja [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
|
||||
|
||||
#### Configuração de Streaming
|
||||
|
||||
O streaming do provider usa double opt-in e fica desativado por padrão. O agent só tenta streaming quando o canal atual tem `settings.streaming.enabled: true`, a entrada de modelo ativa tem `streaming.enabled: true`, e tanto o provider quanto o canal suportam streaming. Se qualquer condição estiver ausente, o PicoClaw usa o caminho normal de requisição sem streaming.
|
||||
|
||||
O Pico WebUI é o primeiro canal totalmente integrado. O Pico cria a primeira mensagem assistant com o wire message existente `message.create` e depois atualiza a mesma mensagem com `message.update`; nenhum novo tipo de wire message do Pico é introduzido.
|
||||
|
||||
Deixe `streaming` ausente quando não quiser streaming. Um bloco `streaming` omitido significa desativado; você não precisa escrever `"streaming": {"enabled": false}`.
|
||||
|
||||
Exemplo de ativação:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-your-openai-key"],
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"channel_list": {
|
||||
"pico": {
|
||||
"enabled": true,
|
||||
"type": "pico",
|
||||
"settings": {
|
||||
"token": "YOUR_PICO_TOKEN",
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Campo | Tipo | Padrão | Descrição |
|
||||
| ----- | ---- | ------ | --------- |
|
||||
| `channel_list.<name>.settings.streaming.enabled` | bool | `false` | Permite que este canal exiba output streaming do provider |
|
||||
| `channel_list.<name>.settings.streaming.throttle_seconds` | int | Padrão do Pico após ativar: `0` | Intervalo mínimo entre atualizações intermediárias; o conteúdo final sempre é enviado |
|
||||
| `channel_list.<name>.settings.streaming.min_growth_chars` | int | Padrão do Pico após ativar: `1` | Crescimento mínimo de texto antes de enviar outra atualização intermediária; o conteúdo final sempre é enviado |
|
||||
| `model_list[].streaming.enabled` | bool | `false` | Permite que esta entrada de modelo tente requisições de provider streaming |
|
||||
|
||||
As variáveis de ambiente legadas do Telegram continuam compatíveis: `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED`, `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS` e `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS`. Elas se aplicam apenas às settings do Telegram e não ativam nem modificam `settings.streaming` do Pico.
|
||||
|
||||
O comportamento de falha é intencionalmente conservador: se o streaming falhar antes de qualquer chunk visível ser enviado, o PicoClaw tenta novamente uma vez pelo caminho normal `Chat()`. Se um chunk já foi mostrado ao usuário, o PicoClaw não envia uma segunda resposta sem streaming, evitando output duplicado.
|
||||
|
||||
### Arquitetura de Providers
|
||||
|
||||
PicoClaw roteia providers por família de protocolo:
|
||||
|
||||
@@ -67,6 +67,50 @@ PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định:
|
||||
|
||||
> **Lưu ý:** Các thay đổi đối với `AGENT.md`, `SOUL.md`, `USER.md` và `memory/MEMORY.md` được tự động phát hiện trong thời gian chạy thông qua theo dõi thời gian sửa đổi file (mtime). **Không cần khởi động lại gateway** sau khi chỉnh sửa các file này — agent sẽ tải nội dung mới vào yêu cầu tiếp theo.
|
||||
|
||||
### Chính sách ngữ cảnh request
|
||||
|
||||
`turn_profile` là chính sách tùy chọn trong `agents.defaults.turn_profile` để kiểm soát ngữ cảnh mỗi turn mới mang theo: lịch sử, system prompt, prompt skills và các tool được phép gọi. Nếu không cấu hình, hoặc đặt `"enabled": false`, PicoClaw giữ nguyên hành vi mặc định. Khi đặt `"enabled": true`, chính sách bên dưới áp dụng cho mỗi turn mới.
|
||||
|
||||
Mỗi block dùng chung các giá trị `mode`:
|
||||
|
||||
| Mode | Ý nghĩa |
|
||||
| --- | --- |
|
||||
| `default` | Giữ hành vi bình thường của PicoClaw. Block bị thiếu hoặc thiếu `mode` đều được xem là `default`. |
|
||||
| `off` | Tắt block đó cho turn. |
|
||||
| `custom` | Dùng danh sách cho phép. Phiên bản này chỉ hỗ trợ `custom` cho `skills` và `tools`; dùng cho `history` hoặc `system_prompt` sẽ lỗi validate. |
|
||||
|
||||
Các block:
|
||||
|
||||
| Block | Nội dung kiểm soát |
|
||||
| --- | --- |
|
||||
| `history` | Đọc lịch sử và tóm tắt, ghi tin nhắn user/assistant/tool, nạp context, compact và summarize. |
|
||||
| `system_prompt` | Chèn identity mặc định của PicoClaw, chỉ dẫn workspace, memory, runtime context và summary. System prompt từ request bên ngoài vẫn được dùng khi block này `off`. |
|
||||
| `skills` | Catalog skills và nội dung active skill trong prompt. `custom.allow` chỉ giữ các tên skill được liệt kê. |
|
||||
| `tools` | Công cụ hiển thị cho model và được phép thực thi. `custom.allow` chỉ giữ các tool đã đăng ký và được liệt kê. |
|
||||
|
||||
Khi `system_prompt.mode` là `off`, tools vẫn hiển thị và không có system prompt bên ngoài, PicoClaw dùng lại quy tắc dùng tool hiện có làm prompt fallback tối thiểu. Nếu `tools.mode` là `off`, fallback này không được thêm.
|
||||
|
||||
Ví dụ ngữ cảnh sạch chỉ giữ tool web:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"turn_profile": {
|
||||
"enabled": true,
|
||||
"history": { "mode": "off" },
|
||||
"system_prompt": { "mode": "off" },
|
||||
"skills": { "mode": "off" },
|
||||
"tools": {
|
||||
"mode": "custom",
|
||||
"allow": ["web_search", "web_fetch"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Nguồn Skill
|
||||
|
||||
Mặc định, skill được tải từ:
|
||||
@@ -365,6 +409,55 @@ Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự đ
|
||||
|
||||
Cấu hình `providers` cũ đã **bị deprecated** và đã được loại bỏ trong V2. Các cấu hình V0/V1 hiện có sẽ được tự động migrate. Xem [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
|
||||
|
||||
#### Cấu Hình Streaming
|
||||
|
||||
Provider streaming dùng cơ chế double opt-in và bị tắt theo mặc định. Agent chỉ thử streaming khi channel hiện tại có `settings.streaming.enabled: true`, entry model đang dùng có `streaming.enabled: true`, và cả provider lẫn channel đều hỗ trợ streaming. Nếu thiếu bất kỳ điều kiện nào, PicoClaw dùng đường dẫn yêu cầu không streaming thông thường.
|
||||
|
||||
Pico WebUI là channel đầu tiên được nối đầy đủ. Pico tạo message assistant đầu tiên bằng wire message hiện có `message.create`, sau đó cập nhật chính message đó bằng `message.update`; không thêm loại wire message Pico mới.
|
||||
|
||||
Hãy để trống `streaming` khi bạn không muốn dùng streaming. Bỏ qua block `streaming` nghĩa là đã tắt; bạn không cần viết `"streaming": {"enabled": false}`.
|
||||
|
||||
Ví dụ bật streaming:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-your-openai-key"],
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"channel_list": {
|
||||
"pico": {
|
||||
"enabled": true,
|
||||
"type": "pico",
|
||||
"settings": {
|
||||
"token": "YOUR_PICO_TOKEN",
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Trường | Kiểu | Mặc định | Mô tả |
|
||||
| ------ | ---- | -------- | ----- |
|
||||
| `channel_list.<name>.settings.streaming.enabled` | bool | `false` | Cho phép channel này hiển thị output streaming từ provider |
|
||||
| `channel_list.<name>.settings.streaming.throttle_seconds` | int | Mặc định Pico sau khi bật: `0` | Khoảng cách tối thiểu giữa các cập nhật trung gian; nội dung cuối luôn được flush |
|
||||
| `channel_list.<name>.settings.streaming.min_growth_chars` | int | Mặc định Pico sau khi bật: `1` | Số ký tự tăng tối thiểu trước khi gửi cập nhật trung gian; nội dung cuối luôn được flush |
|
||||
| `model_list[].streaming.enabled` | bool | `false` | Cho phép entry model này thử yêu cầu provider streaming |
|
||||
|
||||
Các biến môi trường Telegram cũ vẫn tương thích: `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED`, `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS`, và `PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS`. Chúng chỉ áp dụng cho Telegram settings và không bật hoặc thay đổi `settings.streaming` của Pico.
|
||||
|
||||
Hành vi lỗi được giữ thận trọng: nếu streaming lỗi trước khi gửi bất kỳ chunk hiển thị nào, PicoClaw thử lại một lần qua đường dẫn `Chat()` thông thường. Nếu đã có chunk hiển thị cho người dùng, PicoClaw không gửi thêm một câu trả lời non-streaming thứ hai để tránh lặp output.
|
||||
|
||||
### Kiến Trúc Provider
|
||||
|
||||
PicoClaw định tuyến provider theo họ giao thức:
|
||||
|
||||
@@ -97,6 +97,50 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
||||
|
||||
如果你只想先检查学习记录,建议从 `observe` 开始。需要生成可审查改进时使用 `draft`。只有在你接受让已通过的草稿更新工作区技能时,才使用 `apply`。
|
||||
|
||||
### 请求上下文策略
|
||||
|
||||
`turn_profile` 是 `agents.defaults.turn_profile` 下的可选请求上下文策略,用来控制每个新回合是否带入历史、系统提示、技能提示,以及允许调用哪些工具。不写该配置或设置 `"enabled": false` 时,PicoClaw 完全保持原逻辑;设置 `"enabled": true` 后,下面的策略会应用到每个新回合。
|
||||
|
||||
所有块都使用同一组 `mode`:
|
||||
|
||||
| Mode | 含义 |
|
||||
| --- | --- |
|
||||
| `default` | 保持 PicoClaw 原逻辑。块缺失或 `mode` 缺失都按 `default` 处理。 |
|
||||
| `off` | 关闭该块。 |
|
||||
| `custom` | 使用允许列表。本版本仅支持 `skills` 和 `tools`,在 `history` 或 `system_prompt` 中使用会触发配置校验错误。 |
|
||||
|
||||
各块含义:
|
||||
|
||||
| 块 | 控制内容 |
|
||||
| --- | --- |
|
||||
| `history` | 是否读取历史和摘要、写入用户/助手/工具消息、写入 context manager,以及是否压缩或总结本轮会话。 |
|
||||
| `system_prompt` | 是否注入 PicoClaw 默认身份、工作区指令、记忆、运行时上下文和摘要。关闭后仍可使用外部传入的 system prompt。 |
|
||||
| `skills` | 是否加载技能目录和 active skill 提示。`custom.allow` 只保留列出的技能名。 |
|
||||
| `tools` | 暴露给模型并允许执行的工具。`custom.allow` 只保留已注册且列出的工具名。 |
|
||||
|
||||
当 `system_prompt.mode` 为 `off`、工具仍可见且没有外部 system prompt 时,PicoClaw 会复用现有的工具使用规则作为最小兜底提示。如果 `tools.mode` 为 `off`,则不会添加兜底提示。
|
||||
|
||||
只保留 Web 工具的干净上下文示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"turn_profile": {
|
||||
"enabled": true,
|
||||
"history": { "mode": "off" },
|
||||
"system_prompt": { "mode": "off" },
|
||||
"skills": { "mode": "off" },
|
||||
"tools": {
|
||||
"mode": "custom",
|
||||
"allow": ["web_search", "web_fetch"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Web 启动器控制台
|
||||
|
||||
用 **picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`。
|
||||
@@ -293,7 +337,7 @@ PicoClaw 默认在沙箱环境中运行。Agent 只能访问配置的工作区
|
||||
| `tools.exec.custom_deny_patterns` | string[] | `[]` | 自定义阻止的正则表达式模式 |
|
||||
| `tools.exec.custom_allow_patterns` | string[] | `[]` | 自定义允许的正则表达式模式 |
|
||||
|
||||
> **安全提示:** Symlink 保护默认启用——所有文件路径在白名单匹配前都会通过 `filepath.EvalSymlinks` 解析,防止符号链接逃逸攻击。
|
||||
> **安全提示:** Symlink 保护默认启用——所有文件路径在允许列表匹配前都会通过 `filepath.EvalSymlinks` 解析,防止符号链接逃逸攻击。
|
||||
|
||||
#### 已知限制:构建工具的子进程
|
||||
|
||||
@@ -537,6 +581,55 @@ Agent 读取 HEARTBEAT.md
|
||||
- 如果未设置 `provider`,PicoClaw 会把 `model` 第一个 `/` 之前的字段当作 provider,并把第一个 `/` 之后的全部内容当作最终模型 ID。
|
||||
- 这意味着 `"model": "openrouter/openai/gpt-5.4"` 这样的兼容写法仍然可用,并会把 `openai/gpt-5.4` 发送给 OpenRouter。
|
||||
|
||||
#### 流式输出配置
|
||||
|
||||
Provider 流式输出采用双开关,默认关闭。只有当前 channel 的 `settings.streaming.enabled` 和当前模型条目的 `streaming.enabled` 都为 `true`,并且 provider 与 channel 都支持流式能力时,Agent 才会尝试流式请求;任一条件不满足时仍使用普通非流式请求。
|
||||
|
||||
当前完整落地的是 Pico WebUI。Pico 使用已有的 `message.create` 创建第一条 assistant 消息,随后用 `message.update` 更新同一条消息,不新增协议消息类型。
|
||||
|
||||
不需要流式时请省略 `streaming` 配置块。省略表示关闭,不需要写 `"streaming": {"enabled": false}`。
|
||||
|
||||
开启示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-your-openai-key"],
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"channel_list": {
|
||||
"pico": {
|
||||
"enabled": true,
|
||||
"type": "pico",
|
||||
"settings": {
|
||||
"token": "YOUR_PICO_TOKEN",
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `channel_list.<name>.settings.streaming.enabled` | bool | `false` | 是否允许该 channel 尝试展示 provider 流式输出 |
|
||||
| `channel_list.<name>.settings.streaming.throttle_seconds` | int | Pico 开启后默认 `0` | 中间更新的最小时间间隔,最终内容不受此限制 |
|
||||
| `channel_list.<name>.settings.streaming.min_growth_chars` | int | Pico 开启后默认 `1` | 中间更新相比上次发送至少增长的字符数,最终内容不受此限制 |
|
||||
| `model_list[].streaming.enabled` | bool | `false` | 是否允许该模型条目尝试 provider 流式请求 |
|
||||
|
||||
Telegram 旧环境变量仍兼容:`PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED`、`PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS`、`PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS`。这些环境变量只作用于 Telegram settings,不会开启或修改 Pico 的 `settings.streaming`。
|
||||
|
||||
失败处理保持保守:如果还没有任何可见 chunk 就失败,PicoClaw 会回退到普通 `Chat()` 路径重试一次;如果已经有 chunk 展示给用户,则不会再发送一条非流式最终答案,避免界面重复输出。
|
||||
|
||||
#### 各厂商配置示例
|
||||
|
||||
<details>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -113,10 +113,13 @@ Cette conception permet également le **support multi-agents** avec une sélecti
|
||||
| `max_tokens_field` | string | Non | Remplace le nom du champ max tokens dans le corps de la requête (ex : `max_completion_tokens` pour les modèles o1) |
|
||||
| `thinking_level` | string | Non | Niveau de pensée étendue : `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` |
|
||||
| `extra_body` | object | Non | Champs supplémentaires à injecter dans chaque corps de requête |
|
||||
| `streaming.enabled` | bool | Non | Opt-in pour le streaming provider sur cette entrée de modèle. Par défaut `false`, et le channel actif doit aussi avoir `settings.streaming.enabled` à `true` |
|
||||
| `rpm` | int | Non | Limite de requêtes par minute |
|
||||
| `fallbacks` | string[] | Non | Noms des modèles de secours pour le basculement automatique |
|
||||
| `enabled` | bool | Non | Activer ou désactiver cette entrée de modèle (par défaut : `true`) |
|
||||
|
||||
Lorsque le streaming est désactivé, omettez le bloc `streaming`. Écrire `"streaming": {"enabled": false}` est optionnel et n'est pas nécessaire.
|
||||
|
||||
#### Exemples par Vendor
|
||||
|
||||
**OpenAI**
|
||||
|
||||
@@ -114,10 +114,13 @@
|
||||
| `max_tokens_field` | string | いいえ | リクエストボディの max tokens フィールド名を上書き(例:o1 モデルでは `max_completion_tokens`) |
|
||||
| `thinking_level` | string | いいえ | 拡張思考レベル:`off`、`low`、`medium`、`high`、`xhigh`、`adaptive` |
|
||||
| `extra_body` | object | いいえ | 各リクエストボディに注入する追加フィールド |
|
||||
| `streaming.enabled` | bool | いいえ | このモデルエントリで provider ストリーミングを試行するための opt-in。デフォルトは `false` で、アクティブな channel の `settings.streaming.enabled` も `true` である必要があります |
|
||||
| `rpm` | int | いいえ | 1 分あたりのリクエストレート制限 |
|
||||
| `fallbacks` | string[] | いいえ | 自動フェイルオーバーのフォールバックモデル名 |
|
||||
| `enabled` | bool | いいえ | このモデルエントリを有効にするかどうか(デフォルト:`true`) |
|
||||
|
||||
ストリーミングを無効にする場合は `streaming` ブロックを省略してください。`"streaming": {"enabled": false}` を書くことは任意であり、必須ではありません。
|
||||
|
||||
#### ベンダー別設定例
|
||||
|
||||
**OpenAI**
|
||||
|
||||
@@ -131,10 +131,13 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||
| `tool_schema_transform` | string | No | Optional compatibility transform for tool parameter schemas. Default: disabled. Supported values: `simple`. |
|
||||
| `extra_body` | object | No | Additional fields to inject into every request body |
|
||||
| `custom_headers` | object | No | Additional HTTP headers to inject into every request (e.g., `{"X-Source":"coding-plan"}`). If a key matches a built-in header, the custom value overrides the built-in one (e.g., `Authorization`, `User-Agent`, `Content-Type`, `Accept`). |
|
||||
| `streaming.enabled` | bool | No | Opt-in for provider streaming on this model entry. Defaults to `false` and also requires the active channel's `settings.streaming.enabled` to be `true`. |
|
||||
| `rpm` | int | No | Per-minute request rate limit |
|
||||
| `fallbacks` | string[] | No | Fallback model names for automatic failover |
|
||||
| `enabled` | bool | No | Whether this model entry is active (default: `true`) |
|
||||
|
||||
When streaming is disabled, omit the `streaming` block. Writing `"streaming": {"enabled": false}` is optional and not needed in generated or hand-written config.
|
||||
|
||||
#### Tool Schema Compatibility
|
||||
|
||||
By default, PicoClaw now forwards tool JSON Schemas unchanged.
|
||||
|
||||
@@ -113,10 +113,13 @@ Este design também permite **suporte multi-agente** com seleção flexível de
|
||||
| `max_tokens_field` | string | Não | Substitui o nome do campo max tokens no corpo da requisição (ex: `max_completion_tokens` para modelos o1) |
|
||||
| `thinking_level` | string | Não | Nível de pensamento estendido: `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` |
|
||||
| `extra_body` | object | Não | Campos adicionais para injetar em cada corpo de requisição |
|
||||
| `streaming.enabled` | bool | Não | Opt-in para provider streaming nesta entrada de modelo. O padrão é `false` e o canal ativo também precisa de `settings.streaming.enabled` como `true` |
|
||||
| `rpm` | int | Não | Limite de requisições por minuto |
|
||||
| `fallbacks` | string[] | Não | Nomes dos modelos de fallback para failover automático |
|
||||
| `enabled` | bool | Não | Ativar ou desativar esta entrada de modelo (padrão: `true`) |
|
||||
|
||||
Quando streaming estiver desativado, omita o bloco `streaming`. Escrever `"streaming": {"enabled": false}` é opcional e não é necessário.
|
||||
|
||||
#### Exemplos por Vendor
|
||||
|
||||
**OpenAI**
|
||||
|
||||
@@ -113,10 +113,13 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
||||
| `max_tokens_field` | string | Không | Ghi đè tên trường max tokens trong request body (ví dụ: `max_completion_tokens` cho model o1) |
|
||||
| `thinking_level` | string | Không | Mức độ tư duy mở rộng: `off`, `low`, `medium`, `high`, `xhigh` hoặc `adaptive` |
|
||||
| `extra_body` | object | Không | Các trường bổ sung để chèn vào mỗi request body |
|
||||
| `streaming.enabled` | bool | Không | Opt-in cho provider streaming trên entry model này. Mặc định là `false` và channel đang hoạt động cũng cần `settings.streaming.enabled` là `true` |
|
||||
| `rpm` | int | Không | Giới hạn tốc độ yêu cầu mỗi phút |
|
||||
| `fallbacks` | string[] | Không | Tên model dự phòng cho failover tự động |
|
||||
| `enabled` | bool | Không | Kích hoạt hay vô hiệu hóa entry model này (mặc định: `true`) |
|
||||
|
||||
Khi không dùng streaming, hãy bỏ qua block `streaming`. Viết `"streaming": {"enabled": false}` là tùy chọn và không cần thiết.
|
||||
|
||||
#### Ví Dụ Theo Vendor
|
||||
|
||||
**OpenAI**
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
| `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL |
|
||||
| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Gemini、Anthropic 和 Azure provider) |
|
||||
| `request_timeout` | int | 否 | 请求超时时间(秒),默认值因 provider 而异 |
|
||||
| `streaming.enabled` | bool | 否 | 是否允许此模型条目尝试 provider 流式请求,默认 `false`。它只表达模型/端点能力 opt-in,实际还需要当前 channel 的 `settings.streaming.enabled` 同时开启 |
|
||||
| `max_tokens_field` | string | 否 | 覆盖请求体中 max tokens 的字段名(如 o1 模型使用 `max_completion_tokens`) |
|
||||
| `thinking_level` | string | 否 | 扩展思考级别:`off`、`low`、`medium`、`high`、`xhigh` 或 `adaptive` |
|
||||
| `extra_body` | object | 否 | 注入到每个请求体中的额外字段 |
|
||||
@@ -131,6 +132,8 @@
|
||||
| `fallbacks` | string[] | 否 | 自动故障转移的备用模型名称 |
|
||||
| `enabled` | bool | 否 | 是否启用此模型条目(默认:`true`) |
|
||||
|
||||
不需要流式时请省略 `streaming` 配置块。写 `"streaming": {"enabled": false}` 是可选的,手写或生成配置时都不需要。
|
||||
|
||||
#### `provider` / `model` 解析规则
|
||||
|
||||
PicoClaw 按下面的规则解析 `provider` 和最终发给上游的模型 ID:
|
||||
|
||||
@@ -354,6 +354,7 @@ Cela crée `~/.picoclaw/config.json` et le répertoire workspace.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 3,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
@@ -363,7 +364,7 @@ Cela crée `~/.picoclaw/config.json` et le répertoire workspace.
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
"api_keys": ["sk-your-api-key"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -350,6 +350,7 @@ Ini membuat `~/.picoclaw/config.json` dan direktori workspace.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 3,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
@@ -359,7 +360,7 @@ Ini membuat `~/.picoclaw/config.json` dan direktori workspace.
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
"api_keys": ["sk-your-api-key"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -350,6 +350,7 @@ Questo crea `~/.picoclaw/config.json` e la directory workspace.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 3,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
@@ -359,7 +360,7 @@ Questo crea `~/.picoclaw/config.json` e la directory workspace.
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
"api_keys": ["sk-your-api-key"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -351,6 +351,7 @@ picoclaw onboard
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 3,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
@@ -360,7 +361,7 @@ picoclaw onboard
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
"api_keys": ["sk-your-api-key"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -351,6 +351,7 @@ Isso cria `~/.picoclaw/config.json` e o diretório workspace.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 3,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
@@ -360,7 +361,7 @@ Isso cria `~/.picoclaw/config.json` e o diretório workspace.
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
"api_keys": ["sk-your-api-key"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -351,6 +351,7 @@ Lệnh này tạo `~/.picoclaw/config.json` và thư mục workspace.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 3,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
@@ -360,7 +361,7 @@ Lệnh này tạo `~/.picoclaw/config.json` và thư mục workspace.
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
"api_keys": ["sk-your-api-key"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -351,6 +351,7 @@ picoclaw onboard
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 3,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
@@ -360,7 +361,7 @@ picoclaw onboard
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
"api_keys": ["sk-your-api-key"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ When you load a config file:
|
||||
The `version` field in `config.json` indicates the schema version:
|
||||
- `0` or missing: Legacy config (no version field)
|
||||
- `1`: Previous version (will be auto-migrated to V2 on load)
|
||||
- `2`: Current version
|
||||
- `2`: Previous version (will be auto-migrated to V3 on load)
|
||||
- `3`: Current version
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -64,8 +65,8 @@ When making breaking changes to the config schema:
|
||||
Create a new struct for the new version if the structure changes significantly:
|
||||
|
||||
```go
|
||||
// ConfigV2 represents version 2 config structure
|
||||
type ConfigV2 struct {
|
||||
// ConfigV3 represents version 3 config structure
|
||||
type ConfigV3 struct {
|
||||
Version int `json:"version"`
|
||||
Agents AgentsConfig `json:"agents"`
|
||||
// ... other fields with new structure
|
||||
@@ -75,7 +76,7 @@ type ConfigV2 struct {
|
||||
### Step 2: Update Current Config Version
|
||||
|
||||
```go
|
||||
const CurrentVersion = 2 // Increment this
|
||||
const CurrentVersion = 3 // Increment this
|
||||
```
|
||||
|
||||
### Step 3: Add a Loader Function
|
||||
@@ -141,9 +142,9 @@ Create a test in `config_migration_test.go`:
|
||||
|
||||
```go
|
||||
func TestMigrateV2ToV3(t *testing.T) {
|
||||
// Create a version 2 config
|
||||
v2Config := Config{
|
||||
Version: 2,
|
||||
// Create a version 3 config
|
||||
v3Config := Config{
|
||||
Version: 3,
|
||||
// ... set up test data
|
||||
}
|
||||
|
||||
@@ -224,7 +225,7 @@ Backups are created in the same directory as your config file:
|
||||
|
||||
### Scenario: Adding a new field with default value
|
||||
|
||||
Old config (version 2):
|
||||
Old config (version 3):
|
||||
```json
|
||||
{
|
||||
"version": 3,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -117,7 +117,7 @@ Supported flags:
|
||||
| `--env`, `-e` | Add a stdio environment variable in `KEY=value` format. Repeatable. Values are saved to config. |
|
||||
| `--env-file` | Attach an env file path to a stdio server. Recommended for secrets you do not want stored inline in `config.json`. |
|
||||
| `--header`, `-H` | Add an HTTP header in `Name: Value` or `Name=Value` format. Repeatable. |
|
||||
| `--transport`, `-t` | Transport type: `stdio` (default), `http`, or `sse`. |
|
||||
| `--transport`, `-t` | Transport type: `stdio` (default), `http` / `streamable-http`, or `sse`. |
|
||||
| `--force`, `-f` | Overwrite an existing server entry without confirmation. |
|
||||
| `--deferred` | Mark the server as deferred: tools are hidden and discoverable on demand. |
|
||||
| `--no-deferred` | Mark the server as non-deferred: tools are always loaded into context. |
|
||||
@@ -198,13 +198,15 @@ For `stdio`:
|
||||
- `--header` is rejected
|
||||
- `-- <command> [args...]` is supported and recommended for unambiguous parsing
|
||||
|
||||
For `http` / `sse`:
|
||||
For `http` / `streamable-http` / `sse`:
|
||||
|
||||
- `<command-or-url>` must be a valid URL
|
||||
- extra command args are rejected
|
||||
- `--env` is rejected
|
||||
- `--env-file` is rejected
|
||||
- `--header` is supported and stored in `headers`
|
||||
- `http` and `streamable-http` use streamable HTTP request-response mode
|
||||
- `sse` uses the same streamable HTTP transport, but also enables the optional standalone SSE listener for server-initiated notifications
|
||||
|
||||
Overwrite behavior:
|
||||
|
||||
|
||||
@@ -66,6 +66,32 @@ General settings for fetching and processing webpage content.
|
||||
| `enabled` | bool | true | Enable DuckDuckGo search |
|
||||
| `max_results` | int | 5 | Maximum number of results |
|
||||
|
||||
### Gemini Google Search
|
||||
|
||||
Gemini search uses Gemini with Google Search grounding. It returns an AI-synthesized answer with citations from Google Search.
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|---------------|--------|----------------------|-----------------------------------|
|
||||
| `enabled` | bool | false | Enable Gemini Google Search |
|
||||
| `api_key` | string | - | Google Gemini API key |
|
||||
| `model` | string | `gemini-2.5-flash` | Gemini model used for search |
|
||||
| `max_results` | int | 5 | Maximum number of citations |
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"gemini": {
|
||||
"enabled": true,
|
||||
"api_key": "YOUR_GEMINI_API_KEY",
|
||||
"model": "gemini-2.5-flash",
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Baidu Search
|
||||
|
||||
Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5), which is AI-powered and optimized for Chinese-language queries.
|
||||
@@ -100,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 |
|
||||
@@ -145,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
|
||||
|
||||
|
||||
@@ -691,7 +691,7 @@ case "your-provider":
|
||||
{
|
||||
"model_name": "your-model",
|
||||
"model": "your-provider/model-name",
|
||||
"api_key": "your-api-key",
|
||||
"api_keys": ["your-api-key"],
|
||||
"api_base": "https://api.your-provider.com/v1"
|
||||
}
|
||||
]
|
||||
@@ -725,7 +725,7 @@ picoclaw agent -m "Hello" --model your-model
|
||||
export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
|
||||
|
||||
# Remplacer les paramètres du fournisseur
|
||||
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
|
||||
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_keys":["..."]}]'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -691,7 +691,7 @@ case "your-provider":
|
||||
{
|
||||
"model_name": "your-model",
|
||||
"model": "your-provider/model-name",
|
||||
"api_key": "your-api-key",
|
||||
"api_keys": ["your-api-key"],
|
||||
"api_base": "https://api.your-provider.com/v1"
|
||||
}
|
||||
]
|
||||
@@ -725,7 +725,7 @@ picoclaw agent -m "Hello" --model your-model
|
||||
export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
|
||||
|
||||
# プロバイダー設定の上書き
|
||||
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
|
||||
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_keys":["..."]}]'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -689,7 +689,7 @@ case "your-provider":
|
||||
{
|
||||
"model_name": "your-model",
|
||||
"model": "your-provider/model-name",
|
||||
"api_key": "your-api-key",
|
||||
"api_keys": ["your-api-key"],
|
||||
"api_base": "https://api.your-provider.com/v1"
|
||||
}
|
||||
]
|
||||
@@ -723,7 +723,7 @@ picoclaw agent -m "Hello" --model your-model
|
||||
export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
|
||||
|
||||
# Override provider settings
|
||||
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
|
||||
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_keys":["..."]}]'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -691,7 +691,7 @@ case "your-provider":
|
||||
{
|
||||
"model_name": "your-model",
|
||||
"model": "your-provider/model-name",
|
||||
"api_key": "your-api-key",
|
||||
"api_keys": ["your-api-key"],
|
||||
"api_base": "https://api.your-provider.com/v1"
|
||||
}
|
||||
]
|
||||
@@ -725,7 +725,7 @@ picoclaw agent -m "Hello" --model your-model
|
||||
export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
|
||||
|
||||
# Substituir configurações do provedor
|
||||
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
|
||||
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_keys":["..."]}]'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -691,7 +691,7 @@ case "your-provider":
|
||||
{
|
||||
"model_name": "your-model",
|
||||
"model": "your-provider/model-name",
|
||||
"api_key": "your-api-key",
|
||||
"api_keys": ["your-api-key"],
|
||||
"api_base": "https://api.your-provider.com/v1"
|
||||
}
|
||||
]
|
||||
@@ -725,7 +725,7 @@ picoclaw agent -m "Hello" --model your-model
|
||||
export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
|
||||
|
||||
# Ghi đè cài đặt nhà cung cấp
|
||||
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
|
||||
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_keys":["..."]}]'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -691,7 +691,7 @@ case "your-provider":
|
||||
{
|
||||
"model_name": "your-model",
|
||||
"model": "your-provider/model-name",
|
||||
"api_key": "your-api-key",
|
||||
"api_keys": ["your-api-key"],
|
||||
"api_base": "https://api.your-provider.com/v1"
|
||||
}
|
||||
]
|
||||
@@ -725,7 +725,7 @@ picoclaw agent -m "Hello" --model your-model
|
||||
export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
|
||||
|
||||
# 覆盖提供商设置
|
||||
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
|
||||
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_keys":["..."]}]'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -33,7 +33,7 @@ enc://AAAA...base64...
|
||||
{
|
||||
"model_name": "gpt-4o",
|
||||
"model": "openai/gpt-4o",
|
||||
"api_key": "enc://AAAA...base64...",
|
||||
"api_keys": ["enc://AAAA...base64..."],
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -32,7 +32,7 @@ enc://AAAA...base64...
|
||||
{
|
||||
"model_name": "gpt-4o",
|
||||
"model": "openai/gpt-4o",
|
||||
"api_key": "enc://AAAA...base64...",
|
||||
"api_keys": ["enc://AAAA...base64..."],
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -31,7 +31,7 @@ enc://AAAA...base64...
|
||||
{
|
||||
"model_name": "gpt-4o",
|
||||
"model": "openai/gpt-4o",
|
||||
// "api_key": "enc://AAAA...base64..." move to .security.yml
|
||||
// "api_keys": ["enc://AAAA...base64..."] move to .security.yml
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -33,7 +33,7 @@ enc://AAAA...base64...
|
||||
{
|
||||
"model_name": "gpt-4o",
|
||||
"model": "openai/gpt-4o",
|
||||
"api_key": "enc://AAAA...base64...",
|
||||
"api_keys": ["enc://AAAA...base64..."],
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -33,7 +33,7 @@ enc://AAAA...base64...
|
||||
{
|
||||
"model_name": "gpt-4o",
|
||||
"model": "openai/gpt-4o",
|
||||
"api_key": "enc://AAAA...base64...",
|
||||
"api_keys": ["enc://AAAA...base64..."],
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -32,7 +32,7 @@ enc://AAAA...base64...
|
||||
{
|
||||
"model_name": "gpt-4o",
|
||||
"model": "openai/gpt-4o",
|
||||
"api_key": "enc://AAAA...base64...",
|
||||
"api_keys": ["enc://AAAA...base64..."],
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -152,7 +152,7 @@ You can now remove sensitive fields from `config.json` since they're loaded from
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api.openai.com/v1",
|
||||
"api_key": "sk-your-actual-api-key-here"
|
||||
"api_keys": ["sk-your-actual-api-key-here"]
|
||||
}
|
||||
],
|
||||
"channel_list": {
|
||||
@@ -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
|
||||
|
||||
@@ -33,7 +33,7 @@ go run ./examples/pico-echo-server -addr :9090 -token secret
|
||||
2. Configure `pico_client` in your `config.json`:
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"channel_list": {
|
||||
"pico_client": {
|
||||
"enabled": true,
|
||||
"url": "ws://localhost:9090/ws",
|
||||
|
||||
@@ -1,39 +1,44 @@
|
||||
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.6
|
||||
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/config v1.32.17
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.0
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.25
|
||||
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
|
||||
github.com/ergochat/irc-go v0.6.0
|
||||
github.com/ergochat/readline v0.1.3
|
||||
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207
|
||||
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.6.1
|
||||
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
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/mymmrac/telego v1.8.0
|
||||
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
|
||||
github.com/slack-go/slack v0.17.3
|
||||
github.com/slack-go/slack v0.23.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
@@ -41,33 +46,37 @@ require (
|
||||
go.mau.fi/util v0.9.8
|
||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/term v0.42.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/time v0.15.0
|
||||
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.48.2
|
||||
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/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/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
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
|
||||
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/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.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
|
||||
github.com/aws/smithy-go v1.27.1 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/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
|
||||
@@ -76,13 +85,16 @@ require (
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||
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
|
||||
@@ -90,14 +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/pmezard/go-difflib v1.0.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
|
||||
@@ -105,24 +119,24 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/bytedance/sonic v1.15.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/github/copilot-sdk/go v0.2.0
|
||||
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3
|
||||
github.com/grbit/go-json v0.11.0 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
@@ -130,14 +144,14 @@ require (
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||
github.com/valyala/fasthttp v1.71.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/sys v0.45.0
|
||||
)
|
||||
|
||||
replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532
|
||||
|
||||
@@ -5,66 +5,82 @@ 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.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
||||
github.com/adhocore/gronx v1.19.6/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.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/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/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.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/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/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/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=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
|
||||
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/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.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.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.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.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
|
||||
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
|
||||
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/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.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/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/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.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,10 +180,17 @@ 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.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -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.6.1 h1:vAdu+sX9yXNkKnKnYQeIv6yBkjP37Q1JEJHmMa2eCjQ=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.6.1/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=
|
||||
@@ -197,12 +224,12 @@ github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFe
|
||||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
||||
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
||||
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
|
||||
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
|
||||
github.com/mymmrac/telego v1.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
|
||||
github.com/mymmrac/telego v1.9.0 h1:ZUJxZaPx/1IgRvVb5lXnUB8FgW5rNYfRe6Q2EJ4OJ+Y=
|
||||
github.com/mymmrac/telego v1.9.0/go.mod h1:tVEB7OqiOPx8elRk9+ETkwiDQrUhWSB2XmAKIY9KmWY=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
@@ -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=
|
||||
@@ -246,13 +275,15 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
||||
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
||||
github.com/slack-go/slack v0.23.1 h1:ZS5B96wxxYQRwvJ3/vJFtqtUZi3tXhsZCyT44Nv7M80=
|
||||
github.com/slack-go/slack v0.23.1/go.mod h1:H0yR/YBuRJ39RkE+JpV/d/oEsbanzTRowR82bCN0cEs=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
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=
|
||||
@@ -283,8 +314,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
|
||||
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
||||
@@ -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=
|
||||
@@ -330,8 +363,8 @@ golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWP
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
@@ -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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
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=
|
||||
@@ -365,8 +398,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -384,20 +417,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.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=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -405,8 +439,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -449,10 +483,10 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/mautrix v0.27.0 h1:yfEYwoIluVWkofUgbZl9gP4i5nQTF+QNsxtb+r5bKlM=
|
||||
maunium.net/go/mautrix v0.27.0/go.mod h1:7QpEQiTy6p4LHkXXaZI+N46tGYy8HMhD0JjzZAFoFWs=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
@@ -461,18 +495,18 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
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.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
|
||||
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
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=
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
# Integration Test Suites
|
||||
|
||||
This directory contains the integration test suites that CI runs before merging PRs and before building `main`.
|
||||
|
||||
These tests exist to catch regressions that are easy to miss with unit tests alone, especially when different PRs touch adjacent code paths and the breakage only appears once the pieces are wired together. Typical examples are:
|
||||
|
||||
- protocol compatibility across package boundaries
|
||||
- request and response behavior over real transports
|
||||
- subprocess, CLI, or container wiring
|
||||
- configuration passed through environment variables
|
||||
- startup, discovery, and teardown flows involving more than one component
|
||||
|
||||
## Two Layers of Integration Testing
|
||||
|
||||
PicoClaw currently uses two related mechanisms:
|
||||
|
||||
1. Go integration tests, usually in `*_integration_test.go` files and guarded by `//go:build integration`
|
||||
2. Docker-backed suites in `integration/suites/` that start real dependencies and run one or more of those Go tests in CI
|
||||
|
||||
That distinction matters:
|
||||
|
||||
- a tagged Go integration test is the test implementation
|
||||
- a Docker-backed suite is how we make that test reproducible and CI-safe
|
||||
|
||||
If a test should actively protect merges between PRs, it should be reachable from [`scripts/run-integration-tests.sh`](../scripts/run-integration-tests.sh), either by extending an existing suite or by adding a new one.
|
||||
|
||||
Some integration-tagged tests are intentionally opt-in and not part of the Docker suites. For example, real CLI smoke tests under `pkg/providers/cli/` depend on external binaries being installed locally. Those are useful for manual verification, but they do not gate PR merges.
|
||||
|
||||
## What CI Runs
|
||||
|
||||
The integration jobs in [`.github/workflows/pr.yml`](../.github/workflows/pr.yml) and [`.github/workflows/build.yml`](../.github/workflows/build.yml) both execute:
|
||||
|
||||
```bash
|
||||
bash ./scripts/run-integration-tests.sh
|
||||
```
|
||||
|
||||
The runner auto-discovers every suite under `integration/suites/`, so adding a suite does not require editing the GitHub Actions workflow.
|
||||
|
||||
## How the Runner Works
|
||||
|
||||
- The shared runner is defined in [`integration/docker-compose.runner.yml`](docker-compose.runner.yml).
|
||||
- Each suite lives in `integration/suites/<suite-name>/`.
|
||||
- The runner script loads `suite.env`, merges the shared compose file with the suite-specific compose files, starts dependency services, runs the suite command, and then tears everything down.
|
||||
- The shared runner container sets `GOFLAGS=-tags=goolm,stdjson,integration`, so tests run with the same build tags used by CI.
|
||||
|
||||
In practice, each suite gives us:
|
||||
|
||||
- a deterministic dependency graph
|
||||
- a stable execution environment
|
||||
- automatic cleanup after the run
|
||||
- a clean place to encode the exact regression we want to prevent
|
||||
|
||||
## Current Reference Suite
|
||||
|
||||
[`integration/suites/mcp-streamable/`](suites/mcp-streamable/) is the reference example today.
|
||||
|
||||
It does three things:
|
||||
|
||||
- builds and starts a fixture MCP server from [`integration/fixtures/mcp-streamable-server/`](fixtures/mcp-streamable-server/)
|
||||
- injects connection details into the runner container through environment variables
|
||||
- runs [`TestIntegration_RealConfiguredServer`](../pkg/mcp/manager_real_server_integration_test.go) to verify that PicoClaw can connect to a real server, discover tools, invoke one, and validate the response payload
|
||||
|
||||
That suite complements [`TestIntegration_StreamableHTTPCompatibility`](../pkg/mcp/manager_integration_test.go), which exercises the same area in-process. Together they cover both protocol behavior and real service wiring.
|
||||
|
||||
## Suite Layout
|
||||
|
||||
Each suite directory must contain:
|
||||
|
||||
- `suite.env`
|
||||
- at least one `docker-compose.yml` or `docker-compose.*.yml`
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
integration/suites/my-suite/
|
||||
├── docker-compose.yml
|
||||
└── suite.env
|
||||
```
|
||||
|
||||
## Required Manifest Fields
|
||||
|
||||
`suite.env` is sourced by the runner script and must define:
|
||||
|
||||
- `TEST_COMMAND`: shell command executed inside the integration runner container
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `RUNNER_SERVICE`: override the default runner service name (`integration-runner`)
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
TEST_COMMAND='go test ./pkg/mcp -run TestIntegration_RealConfiguredServer -v'
|
||||
```
|
||||
|
||||
## Running Integration Tests Locally
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker with the `docker compose` plugin for Docker-backed suites
|
||||
- Go 1.25+ only if you want to run tagged integration tests directly on your host instead of through Docker
|
||||
|
||||
### Run Everything That CI Runs
|
||||
|
||||
```bash
|
||||
make integration-test
|
||||
```
|
||||
|
||||
Equivalent direct command:
|
||||
|
||||
```bash
|
||||
bash ./scripts/run-integration-tests.sh
|
||||
```
|
||||
|
||||
### Run a Single Suite
|
||||
|
||||
```bash
|
||||
bash ./scripts/run-integration-tests.sh mcp-streamable
|
||||
```
|
||||
|
||||
This is the fastest way to reproduce exactly what the CI integration job does for one suite.
|
||||
|
||||
### Run a Tagged Integration Test Directly
|
||||
|
||||
For faster iteration while writing the test, you can run the Go test itself without Docker:
|
||||
|
||||
```bash
|
||||
go test -tags=goolm,stdjson,integration ./pkg/mcp -run TestIntegration_StreamableHTTPCompatibility -v
|
||||
```
|
||||
|
||||
You can also run the real-server smoke test directly if you provide the same environment variables that the Docker suite would inject:
|
||||
|
||||
```bash
|
||||
PICOCLAW_MCP_REAL_SERVER_JSON='{"enabled":true,"type":"http","url":"http://127.0.0.1:8080/mcp"}' \
|
||||
PICOCLAW_MCP_REAL_TOOL_NAME=echo \
|
||||
PICOCLAW_MCP_REAL_TOOL_ARGS_JSON='{"message":"hello"}' \
|
||||
PICOCLAW_MCP_REAL_EXPECT_SUBSTRING=hello \
|
||||
go test -tags=goolm,stdjson,integration ./pkg/mcp -run TestIntegration_RealConfiguredServer -v
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- avoid `-short`: the current integration tests skip in short mode
|
||||
- use direct `go test` for tight feedback loops, then validate the Docker suite before committing
|
||||
|
||||
## When to Add an Integration Test
|
||||
|
||||
Reach for an integration test when the risk lives in the interaction, not just in the function body. Good candidates include:
|
||||
|
||||
- code that crosses process or container boundaries
|
||||
- transport-specific behavior such as HTTP, SSE, stdio, or streamable MCP flows
|
||||
- CLI parsing and wiring that depends on real subprocess execution
|
||||
- configuration propagation through files, env vars, headers, or service discovery
|
||||
- regressions that usually appear only after merging separately reasonable PRs
|
||||
|
||||
Prefer a unit test when the behavior is pure, local, and fully controllable in-process.
|
||||
|
||||
## How to Add a New Integration Test
|
||||
|
||||
### 1. Start from the regression you want to prevent
|
||||
|
||||
Describe the real workflow that could break after a merge. The sharper the scenario, the better the test will age.
|
||||
|
||||
Examples:
|
||||
|
||||
- "PicoClaw can still connect to a streamable MCP server after transport normalization changes."
|
||||
- "A provider wrapper still parses the real CLI output format after refactors in response handling."
|
||||
|
||||
### 2. Implement the Go test
|
||||
|
||||
Add or extend a `*_integration_test.go` file in the package that owns the behavior and gate it with:
|
||||
|
||||
```go
|
||||
//go:build integration
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- keep the assertions focused on observable behavior
|
||||
- use bounded timeouts
|
||||
- prefer deterministic fixtures over internet access or shared external state
|
||||
- skip only when the dependency is intentionally optional, such as a locally installed third-party CLI
|
||||
|
||||
### 3. Decide whether it must gate CI merges
|
||||
|
||||
Use this rule of thumb:
|
||||
|
||||
- if the test is only a manual smoke check, a tagged Go test may be enough
|
||||
- if the test should prevent regressions from landing through PR merges, wire it into a Docker-backed suite
|
||||
|
||||
### 4. Reuse or add a suite
|
||||
|
||||
If an existing suite already exercises the same subsystem, extend it. Otherwise create:
|
||||
|
||||
```text
|
||||
integration/suites/<name>/
|
||||
├── docker-compose.yml
|
||||
└── suite.env
|
||||
```
|
||||
|
||||
Use `integration/fixtures/` for reusable helper services or fake servers.
|
||||
|
||||
### 5. Define the suite command
|
||||
|
||||
In `suite.env`, point `TEST_COMMAND` at the Go test you want CI to run.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
TEST_COMMAND='go test ./pkg/mcp -run TestIntegration_RealConfiguredServer -v'
|
||||
```
|
||||
|
||||
```bash
|
||||
TEST_COMMAND='go test ./pkg/somepkg -run TestIntegration_MyScenario -v'
|
||||
```
|
||||
|
||||
You can also run multiple tests if they share the same environment, but keep suites cohesive and easy to diagnose when they fail.
|
||||
|
||||
### 6. Model the dependencies in Docker Compose
|
||||
|
||||
Suite compose files can:
|
||||
|
||||
- define dependency services needed by the tests
|
||||
- extend or override the shared `integration-runner` service
|
||||
- inject environment variables into the runner for the tests to consume
|
||||
|
||||
Practical advice:
|
||||
|
||||
- prefer Docker service names over hard-coded host ports
|
||||
- add health checks when the runner depends on service readiness
|
||||
- keep the suite self-contained and deterministic
|
||||
|
||||
### 7. Validate locally before committing
|
||||
|
||||
At minimum:
|
||||
|
||||
```bash
|
||||
go test -tags=goolm,stdjson,integration ./path/to/package -run TestIntegration_Name -v
|
||||
bash ./scripts/run-integration-tests.sh <suite-name>
|
||||
```
|
||||
|
||||
The first command helps while authoring. The second proves that the CI path works end to end.
|
||||
|
||||
## Review Checklist for New Suites
|
||||
|
||||
Before opening the PR, check that the new suite:
|
||||
|
||||
- reproduces a realistic cross-component failure mode
|
||||
- is deterministic and isolated
|
||||
- does not require manual setup in CI
|
||||
- has clear failure output
|
||||
- finishes in a reasonable amount of time
|
||||
- cleans up after itself through the normal runner teardown
|
||||
|
||||
Once committed, the suite will be auto-discovered by the CI integration job.
|
||||
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
integration-runner:
|
||||
image: golang:1.25-bookworm
|
||||
working_dir: /workspace
|
||||
entrypoint: ["bash", "-c"]
|
||||
volumes:
|
||||
- ${INTEGRATION_REPO_ROOT}:/workspace
|
||||
- picoclaw-integration-gocache:/go-build-cache
|
||||
- picoclaw-integration-gomodcache:/go-mod-cache
|
||||
environment:
|
||||
GOCACHE: /go-build-cache
|
||||
GOMODCACHE: /go-mod-cache
|
||||
GOTOOLCHAIN: local
|
||||
CGO_ENABLED: "0"
|
||||
GOFLAGS: -tags=goolm,stdjson,integration
|
||||
|
||||
volumes:
|
||||
picoclaw-integration-gocache:
|
||||
picoclaw-integration-gomodcache:
|
||||
@@ -0,0 +1,23 @@
|
||||
FROM golang:1.25-bookworm AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 go build -o /out/mcp-streamable-server ./integration/fixtures/mcp-streamable-server
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
RUN adduser -D -u 10001 appuser
|
||||
|
||||
COPY --from=build /out/mcp-streamable-server /usr/local/bin/mcp-streamable-server
|
||||
|
||||
USER appuser
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=5s --timeout=3s --retries=12 CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mcp-streamable-server"]
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
server := mcp.NewServer(&mcp.Implementation{
|
||||
Name: "picoclaw-integration-streamable-server",
|
||||
Version: "1.0.0",
|
||||
}, nil)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "echo",
|
||||
Description: "Echo back the provided message",
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
|
||||
message, _ := args["message"].(string)
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: message},
|
||||
},
|
||||
}, nil, nil
|
||||
})
|
||||
|
||||
streamable := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
|
||||
return server
|
||||
}, &mcp.StreamableHTTPOptions{
|
||||
JSONResponse: envBool("STREAMABLE_JSON_RESPONSE", true),
|
||||
})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/mcp", streamable)
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("streamable MCP integration server listening on %s", srv.Addr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func envBool(name string, fallback bool) bool {
|
||||
value := strings.TrimSpace(strings.ToLower(os.Getenv(name)))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
switch value {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunIntegrationTestsScriptExecutesSuiteCommand(t *testing.T) {
|
||||
bashPath, err := exec.LookPath("bash")
|
||||
if err != nil {
|
||||
t.Skip("bash not available")
|
||||
}
|
||||
|
||||
repoRoot := repoRootFromTestFile(t)
|
||||
suitesRoot := filepath.Join(repoRoot, "integration", "suites")
|
||||
suiteDir, err := os.MkdirTemp(suitesRoot, "runner-script-")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(suiteDir)
|
||||
})
|
||||
|
||||
suiteName := filepath.Base(suiteDir)
|
||||
err = os.WriteFile(
|
||||
filepath.Join(suiteDir, "suite.env"),
|
||||
[]byte("TEST_COMMAND='printf runner-ok'\n"),
|
||||
0o644,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile(suite.env) error = %v", err)
|
||||
}
|
||||
err = os.WriteFile(
|
||||
filepath.Join(suiteDir, "docker-compose.yml"),
|
||||
[]byte("services:\n fake-dependency:\n image: busybox\n"),
|
||||
0o644,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile(docker-compose.yml) error = %v", err)
|
||||
}
|
||||
|
||||
stubDir := t.TempDir()
|
||||
logPath := filepath.Join(t.TempDir(), "docker.log")
|
||||
err = os.WriteFile(filepath.Join(stubDir, "docker"), []byte(`#!/bin/sh
|
||||
set -eu
|
||||
|
||||
log_file="${DOCKER_LOG:?}"
|
||||
{
|
||||
printf '%s\n' '---'
|
||||
for arg in "$@"; do
|
||||
printf '%s\n' "$arg"
|
||||
done
|
||||
} >>"$log_file"
|
||||
|
||||
subcommand=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
config|up|run|down)
|
||||
subcommand="$arg"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$subcommand" in
|
||||
config)
|
||||
printf '%s\n' integration-runner fake-dependency
|
||||
;;
|
||||
up)
|
||||
;;
|
||||
run)
|
||||
printf '%s\n' runner-ok
|
||||
;;
|
||||
down)
|
||||
;;
|
||||
*)
|
||||
printf 'unexpected docker invocation: %s\n' "$*" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
`), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile(docker stub) error = %v", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(bashPath, filepath.Join(repoRoot, "scripts", "run-integration-tests.sh"), suiteName)
|
||||
cmd.Dir = repoRoot
|
||||
cmd.Env = append(os.Environ(),
|
||||
"PATH="+stubDir+string(os.PathListSeparator)+os.Getenv("PATH"),
|
||||
"DOCKER_LOG="+logPath,
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("run-integration-tests.sh error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(string(output), "runner-ok") {
|
||||
t.Fatalf("script output did not include runner output:\n%s", output)
|
||||
}
|
||||
|
||||
logData, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(logPath) error = %v", err)
|
||||
}
|
||||
|
||||
runArgs := findLoggedDockerInvocation(t, string(logData), "run")
|
||||
if strings.Contains(strings.Join(runArgs, "\n"), "\nsh\n-c\n") {
|
||||
t.Fatalf("docker compose run unexpectedly wrapped TEST_COMMAND with sh -c:\n%v", runArgs)
|
||||
}
|
||||
|
||||
if !containsArg(runArgs, "integration-runner") {
|
||||
t.Fatalf("docker compose run args missing runner service:\n%v", runArgs)
|
||||
}
|
||||
if !containsArg(runArgs, "printf runner-ok") {
|
||||
t.Fatalf("docker compose run args missing suite command as a single argument:\n%v", runArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func repoRootFromTestFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd() error = %v", err)
|
||||
}
|
||||
return filepath.Dir(wd)
|
||||
}
|
||||
|
||||
func findLoggedDockerInvocation(t *testing.T, logData, subcommand string) []string {
|
||||
t.Helper()
|
||||
|
||||
for _, block := range strings.Split(logData, "---\n") {
|
||||
block = strings.TrimSpace(block)
|
||||
if block == "" {
|
||||
continue
|
||||
}
|
||||
args := strings.Split(block, "\n")
|
||||
if containsArg(args, subcommand) {
|
||||
return args
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("did not find docker %q invocation in log:\n%s", subcommand, logData)
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsArg(args []string, want string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
integration-runner:
|
||||
depends_on:
|
||||
mcp-streamable-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PICOCLAW_MCP_REAL_SERVER_JSON: >-
|
||||
{"enabled":true,"type":"http","url":"http://mcp-streamable-server:8080/mcp"}
|
||||
PICOCLAW_MCP_REAL_TOOL_NAME: echo
|
||||
PICOCLAW_MCP_REAL_TOOL_ARGS_JSON: >-
|
||||
{"message":"hello from docker integration suite"}
|
||||
PICOCLAW_MCP_REAL_EXPECT_SUBSTRING: hello from docker integration suite
|
||||
|
||||
mcp-streamable-server:
|
||||
build:
|
||||
context: ${INTEGRATION_REPO_ROOT}
|
||||
dockerfile: integration/fixtures/mcp-streamable-server/Dockerfile
|
||||
environment:
|
||||
STREAMABLE_JSON_RESPONSE: "true"
|
||||
@@ -0,0 +1 @@
|
||||
TEST_COMMAND='go test ./pkg/mcp -run TestIntegration_RealConfiguredServer -v'
|
||||
@@ -31,6 +31,10 @@ func (a *messageBusAdapter) PublishOutboundMedia(ctx context.Context, msg bus.Ou
|
||||
return a.inner.PublishOutboundMedia(ctx, msg)
|
||||
}
|
||||
|
||||
func (a *messageBusAdapter) GetStreamer(ctx context.Context, channel, chatID, sessionKey string) (bus.Streamer, bool) {
|
||||
return a.inner.GetStreamer(ctx, channel, chatID, sessionKey)
|
||||
}
|
||||
|
||||
func (a *messageBusAdapter) InboundChan() <-chan bus.InboundMessage {
|
||||
return a.inner.InboundChan()
|
||||
}
|
||||
|
||||
+20
-4
@@ -90,6 +90,7 @@ type processOptions struct {
|
||||
SenderDisplayName string // Current sender display name for dynamic context
|
||||
UserMessage string // User message content (may include prefix)
|
||||
ForcedSkills []string // Skills explicitly requested for this message
|
||||
TurnProfile config.EffectiveTurnProfile
|
||||
SystemPromptOverride string // Override the default system prompt (Used by SubTurns)
|
||||
Media []string // media:// refs from inbound message
|
||||
InitialSteeringMessages []providers.Message // Steering messages from refactor/agent
|
||||
@@ -119,9 +120,11 @@ const (
|
||||
pendingTurnPrefix = "pending-"
|
||||
metadataKeyMessageKind = "message_kind"
|
||||
metadataKeyToolCalls = "tool_calls"
|
||||
metadataKeyOutboundKind = "outbound_kind"
|
||||
messageKindThought = "thought"
|
||||
messageKindToolFeedback = "tool_feedback"
|
||||
messageKindToolCalls = "tool_calls"
|
||||
outboundKindFinal = "final"
|
||||
metadataKeyAccountID = "account_id"
|
||||
metadataKeyGuildID = "guild_id"
|
||||
metadataKeyTeamID = "team_id"
|
||||
@@ -532,17 +535,22 @@ func (al *AgentLoop) runAgentLoop(
|
||||
opts processOptions,
|
||||
) (string, error) {
|
||||
opts = normalizeProcessOptions(opts)
|
||||
var err error
|
||||
opts, err = resolveTurnProfileOptions(al.GetConfig(), opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Record last channel for heartbeat notifications (skip internal channels and cli)
|
||||
if opts.Dispatch.Channel() != "" &&
|
||||
opts.Dispatch.ChatID() != "" &&
|
||||
!constants.IsInternalChannel(opts.Dispatch.Channel()) {
|
||||
channelKey := fmt.Sprintf("%s:%s", opts.Dispatch.Channel(), opts.Dispatch.ChatID())
|
||||
if err := al.RecordLastChannel(channelKey); err != nil {
|
||||
if recordErr := al.RecordLastChannel(channelKey); recordErr != nil {
|
||||
logger.WarnCF(
|
||||
"agent",
|
||||
"Failed to record last channel",
|
||||
map[string]any{"error": err.Error()},
|
||||
map[string]any{"error": recordErr.Error()},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -585,7 +593,7 @@ func (al *AgentLoop) runAgentLoop(
|
||||
opts.Dispatch.SessionKey,
|
||||
opts.Dispatch.SessionScope,
|
||||
)
|
||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||
msg := bus.OutboundMessage{
|
||||
Context: outboundContextFromInbound(
|
||||
opts.Dispatch.InboundContext,
|
||||
opts.Dispatch.Channel(),
|
||||
@@ -597,7 +605,15 @@ func (al *AgentLoop) runAgentLoop(
|
||||
Scope: scope,
|
||||
Content: result.finalContent,
|
||||
ContextUsage: computeContextUsage(agent, opts.Dispatch.SessionKey),
|
||||
})
|
||||
}
|
||||
if modelName := strings.TrimSpace(result.modelName); modelName != "" {
|
||||
if msg.Context.Raw == nil {
|
||||
msg.Context.Raw = make(map[string]string, 1)
|
||||
}
|
||||
msg.Context.Raw["model_name"] = modelName
|
||||
}
|
||||
markFinalOutbound(&msg)
|
||||
al.bus.PublishOutbound(ctx, msg)
|
||||
}
|
||||
|
||||
if result.finalContent != "" {
|
||||
|
||||
@@ -319,6 +319,7 @@ func (al *AgentLoop) buildCommandsRuntime(
|
||||
agent.Provider = nextProvider
|
||||
agent.Candidates = nextCandidates
|
||||
agent.ThinkingLevel = parseThinkingLevel(modelCfg.ThinkingLevel)
|
||||
agent.ThinkingLevelConfigured = isConfiguredThinkingLevel(modelCfg.ThinkingLevel)
|
||||
|
||||
if oldProvider != nil && oldProvider != nextProvider {
|
||||
if stateful, ok := oldProvider.(providers.StatefulProvider); ok {
|
||||
@@ -351,7 +352,9 @@ func (al *AgentLoop) buildCommandsRuntime(
|
||||
return &commands.ContextStats{
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -193,6 +193,11 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||
SendResponse: false,
|
||||
AllowInterimPicoPublish: true,
|
||||
}
|
||||
var err error
|
||||
opts, err = resolveTurnProfileOptions(al.GetConfig(), opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// context-dependent commands check their own Runtime fields and report
|
||||
// "unavailable" when the required capability is nil.
|
||||
|
||||
+35
-11
@@ -76,11 +76,13 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
|
||||
|
||||
msg := bus.OutboundMessage{
|
||||
Context: bus.NewOutboundContext(channel, chatID, ""),
|
||||
SessionKey: sessionKey,
|
||||
Content: response,
|
||||
}
|
||||
if sessionKey != "" {
|
||||
msg.ContextUsage = computeContextUsage(al.agentForSession(sessionKey), sessionKey)
|
||||
}
|
||||
markFinalOutbound(&msg)
|
||||
al.bus.PublishOutbound(ctx, msg)
|
||||
logger.InfoCF("agent", "Published outbound response",
|
||||
map[string]any{
|
||||
@@ -100,7 +102,10 @@ func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string
|
||||
return ""
|
||||
}
|
||||
|
||||
func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, chatID string) {
|
||||
func (al *AgentLoop) publishPicoReasoning(
|
||||
ctx context.Context,
|
||||
reasoningContent, chatID, sessionKey, modelName string,
|
||||
) {
|
||||
if reasoningContent == "" || chatID == "" {
|
||||
return
|
||||
}
|
||||
@@ -112,14 +117,18 @@ func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent,
|
||||
pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer pubCancel()
|
||||
|
||||
raw := map[string]string{metadataKeyMessageKind: messageKindThought}
|
||||
if trimmedModelName := strings.TrimSpace(modelName); trimmedModelName != "" {
|
||||
raw["model_name"] = trimmedModelName
|
||||
}
|
||||
|
||||
if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||
Context: bus.InboundContext{
|
||||
Channel: "pico",
|
||||
ChatID: chatID,
|
||||
Raw: map[string]string{
|
||||
metadataKeyMessageKind: messageKindThought,
|
||||
},
|
||||
Raw: raw,
|
||||
},
|
||||
SessionKey: sessionKey,
|
||||
Content: reasoningContent,
|
||||
}); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
|
||||
@@ -140,6 +149,7 @@ func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent,
|
||||
func (al *AgentLoop) publishPicoToolCallInterim(
|
||||
ctx context.Context,
|
||||
ts *turnState,
|
||||
modelName string,
|
||||
reasoningContent string,
|
||||
content string,
|
||||
toolCalls []providers.ToolCall,
|
||||
@@ -152,7 +162,14 @@ func (al *AgentLoop) publishPicoToolCallInterim(
|
||||
pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
err := al.bus.PublishOutbound(
|
||||
pubCtx,
|
||||
outboundMessageForTurnWithKind(ts, reasoningContent, messageKindThought),
|
||||
outboundMessageForTurnWithOptions(
|
||||
ts,
|
||||
reasoningContent,
|
||||
outboundTurnMessageOptions{
|
||||
kind: messageKindThought,
|
||||
modelName: modelName,
|
||||
},
|
||||
),
|
||||
)
|
||||
pubCancel()
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) &&
|
||||
@@ -179,7 +196,12 @@ func (al *AgentLoop) publishPicoToolCallInterim(
|
||||
|
||||
if strings.TrimSpace(content) != "" && !duplicateToolCallContent {
|
||||
pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
err := al.bus.PublishOutbound(pubCtx, outboundMessageForTurn(ts, content))
|
||||
err := al.bus.PublishOutbound(
|
||||
pubCtx,
|
||||
outboundMessageForTurnWithOptions(ts, content, outboundTurnMessageOptions{
|
||||
modelName: modelName,
|
||||
}),
|
||||
)
|
||||
pubCancel()
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) &&
|
||||
!errors.Is(err, context.Canceled) &&
|
||||
@@ -206,11 +228,13 @@ func (al *AgentLoop) publishPicoToolCallInterim(
|
||||
return
|
||||
}
|
||||
|
||||
msg := outboundMessageForTurnWithKind(ts, "", messageKindToolCalls)
|
||||
if msg.Context.Raw == nil {
|
||||
msg.Context.Raw = map[string]string{}
|
||||
}
|
||||
msg.Context.Raw[metadataKeyToolCalls] = string(rawToolCalls)
|
||||
msg := outboundMessageForTurnWithOptions(ts, "", outboundTurnMessageOptions{
|
||||
kind: messageKindToolCalls,
|
||||
modelName: modelName,
|
||||
raw: map[string]string{
|
||||
metadataKeyToolCalls: string(rawToolCalls),
|
||||
},
|
||||
})
|
||||
|
||||
pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
err = al.bus.PublishOutbound(pubCtx, msg)
|
||||
|
||||
+962
-1
File diff suppressed because it is too large
Load Diff
@@ -86,15 +86,56 @@ func outboundMessageForTurn(ts *turnState, content string) bus.OutboundMessage {
|
||||
}
|
||||
}
|
||||
|
||||
func outboundMessageForTurnWithKind(ts *turnState, content, kind string) bus.OutboundMessage {
|
||||
msg := outboundMessageForTurn(ts, content)
|
||||
if strings.TrimSpace(kind) == "" {
|
||||
return msg
|
||||
func markFinalOutbound(msg *bus.OutboundMessage) {
|
||||
if msg == nil {
|
||||
return
|
||||
}
|
||||
if msg.Context.Raw == nil {
|
||||
msg.Context.Raw = make(map[string]string, 1)
|
||||
}
|
||||
msg.Context.Raw[metadataKeyMessageKind] = kind
|
||||
msg.Context.Raw[metadataKeyOutboundKind] = outboundKindFinal
|
||||
}
|
||||
|
||||
type outboundTurnMessageOptions struct {
|
||||
kind string
|
||||
modelName string
|
||||
raw map[string]string
|
||||
}
|
||||
|
||||
func outboundMessageForTurnWithOptions(
|
||||
ts *turnState,
|
||||
content string,
|
||||
opts outboundTurnMessageOptions,
|
||||
) bus.OutboundMessage {
|
||||
msg := outboundMessageForTurn(ts, content)
|
||||
trimmedKind := strings.TrimSpace(opts.kind)
|
||||
trimmedModelName := strings.TrimSpace(opts.modelName)
|
||||
rawCount := len(opts.raw)
|
||||
if trimmedKind != "" {
|
||||
rawCount++
|
||||
}
|
||||
if trimmedModelName != "" {
|
||||
rawCount++
|
||||
}
|
||||
if rawCount == 0 {
|
||||
return msg
|
||||
}
|
||||
|
||||
if msg.Context.Raw == nil {
|
||||
msg.Context.Raw = make(map[string]string, rawCount)
|
||||
}
|
||||
if trimmedKind != "" {
|
||||
msg.Context.Raw[metadataKeyMessageKind] = trimmedKind
|
||||
}
|
||||
if trimmedModelName != "" {
|
||||
msg.Context.Raw["model_name"] = trimmedModelName
|
||||
}
|
||||
for key, value := range opts.raw {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
}
|
||||
msg.Context.Raw[key] = value
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
@@ -440,6 +481,9 @@ func activeSkillNames(agent *AgentInstance, opts processOptions) []string {
|
||||
if agent == nil {
|
||||
return nil
|
||||
}
|
||||
if turnProfileSkillsOff(opts.TurnProfile) {
|
||||
return nil
|
||||
}
|
||||
|
||||
combined := make([]string, 0, len(agent.SkillsFilter)+len(opts.ForcedSkills))
|
||||
combined = append(combined, agent.SkillsFilter...)
|
||||
@@ -468,6 +512,9 @@ func activeSkillNames(agent *AgentInstance, opts processOptions) []string {
|
||||
resolved = append(resolved, name)
|
||||
}
|
||||
|
||||
if turnProfileCustomSkills(opts.TurnProfile) {
|
||||
return filterNamesByTurnProfile(resolved, opts.TurnProfile.AllowedSkills)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -511,13 +558,15 @@ func hasMediaRefs(messages []providers.Message) bool {
|
||||
|
||||
func sideQuestionModelName(agent *AgentInstance, usedLight bool) string {
|
||||
if usedLight && len(agent.LightCandidates) > 0 {
|
||||
// Use the first light candidate's model
|
||||
return agent.LightCandidates[0].Model
|
||||
if name := resolvedCandidateModelName(agent.LightCandidates, ""); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return agent.Model
|
||||
}
|
||||
|
||||
func modelNameFromIdentityKey(identityKey string) string {
|
||||
identityKey = strings.TrimSpace(identityKey)
|
||||
if identityKey == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -528,6 +577,14 @@ func modelNameFromIdentityKey(identityKey string) string {
|
||||
return identityKey
|
||||
}
|
||||
|
||||
func modelAliasFromCandidateIdentityKey(identityKey string) string {
|
||||
const prefix = "model_name:"
|
||||
if !strings.HasPrefix(identityKey, prefix) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(identityKey, prefix))
|
||||
}
|
||||
|
||||
func closeProviderIfStateful(provider providers.LLMProvider) {
|
||||
if stateful, ok := provider.(providers.StatefulProvider); ok {
|
||||
stateful.Close()
|
||||
|
||||
+247
-44
@@ -54,9 +54,13 @@ func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuil
|
||||
useBM25: useBM25,
|
||||
useRegex: useRegex,
|
||||
}); err != nil {
|
||||
logger.WarnCF("agent", "Failed to register tool discovery prompt contributor", map[string]any{
|
||||
logger.WarnCF(
|
||||
"agent",
|
||||
"Failed to register tool discovery prompt contributor",
|
||||
map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
return cb
|
||||
@@ -77,9 +81,13 @@ func (cb *ContextBuilder) WithAgentDiscovery(
|
||||
agentID: agentID,
|
||||
discover: discover,
|
||||
}); err != nil {
|
||||
logger.WarnCF("agent", "Failed to register agent discovery prompt contributor", map[string]any{
|
||||
logger.WarnCF(
|
||||
"agent",
|
||||
"Failed to register agent discovery prompt contributor",
|
||||
map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
return cb
|
||||
@@ -94,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")
|
||||
@@ -130,9 +144,34 @@ func (cb *ContextBuilder) promptRegistryOrDefault() *PromptRegistry {
|
||||
return cb.promptRegistry
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) getIdentity() string {
|
||||
func (cb *ContextBuilder) getIdentity(includeToolUseRule bool) string {
|
||||
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
||||
version := config.FormatVersion()
|
||||
rules := []string{}
|
||||
if includeToolUseRule {
|
||||
rules = append(rules, toolUseSystemPromptRule())
|
||||
}
|
||||
accuracyRule := "**Be helpful and accurate** - Briefly explain what you're doing."
|
||||
if includeToolUseRule {
|
||||
accuracyRule = "**Be helpful and accurate** - When using tools, briefly explain what you're doing."
|
||||
}
|
||||
rules = append(
|
||||
rules,
|
||||
accuracyRule,
|
||||
"**Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.",
|
||||
)
|
||||
if includeToolUseRule {
|
||||
rules = append(
|
||||
rules,
|
||||
fmt.Sprintf(
|
||||
"**Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md",
|
||||
workspacePath,
|
||||
),
|
||||
)
|
||||
}
|
||||
for i, rule := range rules {
|
||||
rules[i] = fmt.Sprintf("%d. %s", i+1, rule)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
`# picoclaw 🦞 (%s)
|
||||
@@ -147,14 +186,15 @@ Your workspace is at: %s
|
||||
|
||||
## Important Rules
|
||||
|
||||
1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.
|
||||
|
||||
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
|
||||
|
||||
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
|
||||
|
||||
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`,
|
||||
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
|
||||
%s
|
||||
`,
|
||||
version,
|
||||
workspacePath,
|
||||
workspacePath,
|
||||
workspacePath,
|
||||
workspacePath,
|
||||
strings.Join(rules, "\n\n"),
|
||||
)
|
||||
}
|
||||
|
||||
func formatToolDiscoveryRule(useBM25, useRegex bool) string {
|
||||
@@ -181,6 +221,20 @@ func (cb *ContextBuilder) BuildSystemPrompt() string {
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart {
|
||||
return cb.buildSystemPromptParts(systemPromptBuildOptions{
|
||||
IncludeSkillCatalog: true,
|
||||
IncludeToolUseRule: true,
|
||||
})
|
||||
}
|
||||
|
||||
type systemPromptBuildOptions struct {
|
||||
IncludeSkillCatalog bool
|
||||
IncludeToolUseRule bool
|
||||
AllowedSkills []string
|
||||
AllowedTools []string
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) buildSystemPromptParts(opts systemPromptBuildOptions) []PromptPart {
|
||||
stack := NewPromptStack(cb.promptRegistryOrDefault())
|
||||
add := func(part PromptPart) {
|
||||
if err := stack.Add(part); err != nil {
|
||||
@@ -201,7 +255,7 @@ func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart {
|
||||
Slot: PromptSlotIdentity,
|
||||
Source: PromptSource{ID: PromptSourceKernel, Name: "identity"},
|
||||
Title: "picoclaw identity",
|
||||
Content: cb.getIdentity(),
|
||||
Content: cb.getIdentity(opts.IncludeToolUseRule),
|
||||
Stable: true,
|
||||
Cache: PromptCacheEphemeral,
|
||||
})
|
||||
@@ -222,8 +276,19 @@ func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart {
|
||||
}
|
||||
|
||||
// Skills - show summary, AI can read full content with read_file tool
|
||||
skillsSummary := cb.skillsLoader.BuildSkillsSummary()
|
||||
skillsSummary := ""
|
||||
if opts.IncludeSkillCatalog {
|
||||
skillsSummary = cb.buildSkillsSummary(opts.AllowedSkills)
|
||||
}
|
||||
if skillsSummary != "" {
|
||||
skillIntro := "The following skills extend your capabilities."
|
||||
readFileAllowed := promptAllowsTool(
|
||||
PromptBuildRequest{AllowedTools: opts.AllowedTools},
|
||||
"read_file",
|
||||
)
|
||||
if opts.IncludeToolUseRule && readFileAllowed {
|
||||
skillIntro += " To use a skill, read its SKILL.md file using the read_file tool."
|
||||
}
|
||||
add(PromptPart{
|
||||
ID: "capability.skill_catalog",
|
||||
Layer: PromptLayerCapability,
|
||||
@@ -232,9 +297,9 @@ func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart {
|
||||
Title: "skill catalog",
|
||||
Content: fmt.Sprintf(`# Skills
|
||||
|
||||
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
|
||||
%s
|
||||
|
||||
%s`, skillsSummary),
|
||||
%s`, skillIntro, skillsSummary),
|
||||
Stable: true,
|
||||
Cache: PromptCacheEphemeral,
|
||||
})
|
||||
@@ -319,6 +384,96 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
|
||||
return prompt
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) buildSystemPromptForRequest(
|
||||
req PromptBuildRequest,
|
||||
) (string, []providers.ContentBlock) {
|
||||
if req.SuppressDefaultSystemPrompt {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
useDefaultCache := !req.SuppressSkillContext &&
|
||||
!req.SuppressToolUseRule &&
|
||||
len(req.AllowedSkills) == 0 &&
|
||||
len(req.AllowedTools) == 0
|
||||
if useDefaultCache {
|
||||
staticPrompt := cb.BuildSystemPromptWithCache()
|
||||
return staticPrompt, []providers.ContentBlock{
|
||||
promptContentBlock(PromptPart{
|
||||
ID: "kernel.static",
|
||||
Layer: PromptLayerKernel,
|
||||
Slot: PromptSlotIdentity,
|
||||
Source: PromptSource{ID: PromptSourceKernel, Name: "static"},
|
||||
Content: staticPrompt,
|
||||
}, &providers.CacheControl{Type: "ephemeral"}),
|
||||
}
|
||||
}
|
||||
|
||||
parts := cb.buildSystemPromptParts(systemPromptBuildOptions{
|
||||
IncludeSkillCatalog: !req.SuppressSkillContext,
|
||||
IncludeToolUseRule: !req.SuppressToolUseRule,
|
||||
AllowedSkills: req.AllowedSkills,
|
||||
AllowedTools: req.AllowedTools,
|
||||
})
|
||||
staticPrompt := renderPromptPartsLegacy(parts)
|
||||
blocks := make([]providers.ContentBlock, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if strings.TrimSpace(part.Content) == "" {
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, promptContentBlock(part, cacheControlForPromptPart(part)))
|
||||
}
|
||||
return staticPrompt, blocks
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) buildSkillsSummary(allowed []string) string {
|
||||
if cb.skillsLoader == nil {
|
||||
return ""
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return cb.skillsLoader.BuildSkillsSummary()
|
||||
}
|
||||
allowedSet := cleanAllowedSet(allowed)
|
||||
if len(allowedSet) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, "<skills>")
|
||||
for _, s := range cb.skillsLoader.ListSkills() {
|
||||
if _, ok := allowedSet[strings.ToLower(strings.TrimSpace(s.Name))]; !ok {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, " <skill>")
|
||||
lines = append(lines, fmt.Sprintf(" <name>%s</name>", xmlEscapeForPrompt(s.Name)))
|
||||
lines = append(
|
||||
lines,
|
||||
fmt.Sprintf(" <description>%s</description>", xmlEscapeForPrompt(s.Description)),
|
||||
)
|
||||
lines = append(
|
||||
lines,
|
||||
fmt.Sprintf(" <location>%s</location>", xmlEscapeForPrompt(s.Path)),
|
||||
)
|
||||
lines = append(lines, fmt.Sprintf(" <source>%s</source>", xmlEscapeForPrompt(s.Source)))
|
||||
lines = append(lines, " </skill>")
|
||||
}
|
||||
if len(lines) == 1 {
|
||||
return ""
|
||||
}
|
||||
lines = append(lines, "</skills>")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func xmlEscapeForPrompt(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
"\"", """,
|
||||
"'", "'",
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
|
||||
// EstimateSystemTokens estimates the token count of the full system message
|
||||
// that would be sent to the LLM, mirroring the composition logic in BuildMessages.
|
||||
// It includes: static prompt, dynamic context, active skills, and summary with
|
||||
@@ -687,19 +842,18 @@ func (cb *ContextBuilder) BuildMessages(
|
||||
func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []providers.Message {
|
||||
messages := []providers.Message{}
|
||||
|
||||
// The static part (identity, bootstrap, skills, memory) is cached locally to
|
||||
// avoid repeated file I/O and string building on every call (fixes issue #607).
|
||||
// Dynamic parts (time, session, summary) are appended per request.
|
||||
// The default static part (identity, bootstrap, skills, memory) is cached
|
||||
// locally to avoid repeated file I/O and string building on every call
|
||||
// (fixes issue #607). Profile-customized static prompts are built on demand.
|
||||
// Dynamic parts (time, session, summary) are appended per request unless the
|
||||
// profile suppresses PicoClaw system context.
|
||||
// Everything is sent as a single system message for provider compatibility:
|
||||
// - Anthropic adapter extracts messages[0] (Role=="system") and maps its content
|
||||
// to the top-level "system" parameter in the Messages API request. A single
|
||||
// contiguous system block makes this extraction straightforward.
|
||||
// - Codex maps only the first system message to its instructions field.
|
||||
// - OpenAI-compat passes messages through as-is.
|
||||
staticPrompt := cb.BuildSystemPromptWithCache()
|
||||
|
||||
// Build short dynamic context (time, runtime, session) — changes per request
|
||||
dynamicCtx := cb.buildDynamicContext(req.Channel, req.ChatID, req.SenderID, req.SenderDisplayName)
|
||||
staticPrompt, contentBlocks := cb.buildSystemPromptForRequest(req)
|
||||
|
||||
// Compose a single system message: static (cached) + dynamic + optional summary.
|
||||
// Keeping all system content in one message ensures every provider adapter can
|
||||
@@ -710,20 +864,20 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov
|
||||
// cache-aware adapters (Anthropic) can set per-block cache_control.
|
||||
// The static block is marked "ephemeral" — its prefix hash is stable
|
||||
// across requests, enabling LLM-side KV cache reuse.
|
||||
stringParts := []string{staticPrompt}
|
||||
|
||||
contentBlocks := []providers.ContentBlock{
|
||||
promptContentBlock(PromptPart{
|
||||
ID: "kernel.static",
|
||||
Layer: PromptLayerKernel,
|
||||
Slot: PromptSlotIdentity,
|
||||
Source: PromptSource{ID: PromptSourceKernel, Name: "static"},
|
||||
Content: staticPrompt,
|
||||
}, &providers.CacheControl{Type: "ephemeral"}),
|
||||
var stringParts []string
|
||||
if strings.TrimSpace(staticPrompt) != "" {
|
||||
stringParts = append(stringParts, staticPrompt)
|
||||
}
|
||||
|
||||
promptParts := append([]PromptPart(nil), req.Overlays...)
|
||||
promptParts = append(promptParts, cb.buildActiveSkillsPromptParts(req.ActiveSkills)...)
|
||||
if !req.SuppressDefaultSystemPrompt && !req.SuppressSkillContext {
|
||||
activeSkills := append([]string(nil), req.ActiveSkills...)
|
||||
if len(req.AllowedSkills) > 0 {
|
||||
activeSkills = filterNamesByTurnProfile(activeSkills, req.AllowedSkills)
|
||||
}
|
||||
promptParts = append(promptParts, cb.buildActiveSkillsPromptParts(activeSkills)...)
|
||||
}
|
||||
if !req.SuppressDefaultSystemPrompt {
|
||||
if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), req); err != nil {
|
||||
logger.WarnCF("agent", "Prompt contributor collection failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
@@ -731,6 +885,7 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov
|
||||
} else {
|
||||
promptParts = append(promptParts, contributedParts...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(promptParts) > 0 {
|
||||
for _, overlay := range sortPromptParts(promptParts) {
|
||||
@@ -752,6 +907,16 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov
|
||||
}
|
||||
}
|
||||
|
||||
dynamicChars := 0
|
||||
if !req.SuppressDefaultSystemPrompt {
|
||||
// Build short dynamic context (time, runtime, session) — changes per request
|
||||
dynamicCtx := cb.buildDynamicContext(
|
||||
req.Channel,
|
||||
req.ChatID,
|
||||
req.SenderID,
|
||||
req.SenderDisplayName,
|
||||
)
|
||||
dynamicChars = len(dynamicCtx)
|
||||
runtimePart := PromptPart{
|
||||
ID: "context.runtime",
|
||||
Layer: PromptLayerContext,
|
||||
@@ -775,13 +940,30 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov
|
||||
Content: fmt.Sprintf(
|
||||
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
|
||||
"for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s",
|
||||
req.Summary),
|
||||
req.Summary,
|
||||
),
|
||||
Stable: false,
|
||||
Cache: PromptCacheNone,
|
||||
}
|
||||
stringParts = append(stringParts, summaryPart.Content)
|
||||
contentBlocks = append(contentBlocks, promptContentBlock(summaryPart, nil))
|
||||
}
|
||||
}
|
||||
|
||||
if len(stringParts) == 0 && req.ToolUseFallback {
|
||||
fallbackPart := PromptPart{
|
||||
ID: "kernel.tool_use_fallback",
|
||||
Layer: PromptLayerKernel,
|
||||
Slot: PromptSlotIdentity,
|
||||
Source: PromptSource{ID: PromptSourceKernel, Name: "tool_use_fallback"},
|
||||
Title: "tool use fallback",
|
||||
Content: toolUseSystemPromptRule(),
|
||||
Stable: true,
|
||||
Cache: PromptCacheEphemeral,
|
||||
}
|
||||
stringParts = append(stringParts, fallbackPart.Content)
|
||||
contentBlocks = append(contentBlocks, promptContentBlock(fallbackPart, nil))
|
||||
}
|
||||
|
||||
fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
|
||||
|
||||
@@ -795,7 +977,7 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov
|
||||
logger.DebugCF("agent", "System prompt built",
|
||||
map[string]any{
|
||||
"static_chars": len(staticPrompt),
|
||||
"dynamic_chars": len(dynamicCtx),
|
||||
"dynamic_chars": dynamicChars,
|
||||
"total_chars": len(fullSystemPrompt),
|
||||
"has_summary": req.Summary != "",
|
||||
"overlays": len(req.Overlays),
|
||||
@@ -814,11 +996,13 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov
|
||||
// Single system message containing all context — compatible with all providers.
|
||||
// SystemParts enables cache-aware adapters to set per-block cache_control;
|
||||
// Content is the concatenated fallback for adapters that don't read SystemParts.
|
||||
if strings.TrimSpace(fullSystemPrompt) != "" {
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "system",
|
||||
Content: fullSystemPrompt,
|
||||
SystemParts: contentBlocks,
|
||||
})
|
||||
}
|
||||
|
||||
// Add conversation history
|
||||
messages = append(messages, history...)
|
||||
@@ -829,6 +1013,9 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov
|
||||
if strings.TrimSpace(req.CurrentMessage) != "" || len(req.Media) > 0 {
|
||||
messages = append(messages, userPromptMessage(req.CurrentMessage, req.Media))
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
messages = append(messages, userPromptMessage("", nil))
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
@@ -933,7 +1120,11 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||
break
|
||||
}
|
||||
if next.ToolCallID == "" {
|
||||
logger.DebugCF("agent", "Dropping tool result without tool_call_id", map[string]any{})
|
||||
logger.DebugCF(
|
||||
"agent",
|
||||
"Dropping tool result without tool_call_id",
|
||||
map[string]any{},
|
||||
)
|
||||
continue
|
||||
}
|
||||
if _, ok := expected[next.ToolCallID]; !ok {
|
||||
@@ -943,9 +1134,13 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||
continue
|
||||
}
|
||||
if seenInBlock[next.ToolCallID] {
|
||||
logger.DebugCF("agent", "Dropping duplicate tool result in tool block", map[string]any{
|
||||
logger.DebugCF(
|
||||
"agent",
|
||||
"Dropping duplicate tool result in tool block",
|
||||
map[string]any{
|
||||
"tool_call_id": next.ToolCallID,
|
||||
})
|
||||
},
|
||||
)
|
||||
continue
|
||||
}
|
||||
seenInBlock[next.ToolCallID] = true
|
||||
@@ -955,7 +1150,11 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||
|
||||
allFound := !invalidToolCallID
|
||||
if invalidToolCallID {
|
||||
logger.DebugCF("agent", "Dropping assistant message with empty tool_call_id", map[string]any{})
|
||||
logger.DebugCF(
|
||||
"agent",
|
||||
"Dropping assistant message with empty tool_call_id",
|
||||
map[string]any{},
|
||||
)
|
||||
}
|
||||
for toolCallID, found := range expected {
|
||||
if !found {
|
||||
@@ -985,9 +1184,13 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||
}
|
||||
|
||||
if msg.Role == "tool" {
|
||||
logger.DebugCF("agent", "Dropping orphaned tool message after validation", map[string]any{
|
||||
logger.DebugCF(
|
||||
"agent",
|
||||
"Dropping orphaned tool message after validation",
|
||||
map[string]any{
|
||||
"tool_call_id": msg.ToolCallID,
|
||||
})
|
||||
},
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -197,8 +198,10 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
|
||||
result := seahorse.Message{
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
ModelName: msg.ModelName,
|
||||
ReasoningContent: msg.ReasoningContent,
|
||||
TokenCount: tokenizer.EstimateMessageTokens(msg),
|
||||
CreatedAt: normalizeSeahorseMessageCreatedAt(msg.CreatedAt),
|
||||
}
|
||||
|
||||
// Convert ToolCalls → MessageParts
|
||||
@@ -234,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))
|
||||
@@ -243,6 +253,7 @@ func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes
|
||||
pm := protocoltypes.Message{
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
ModelName: msg.ModelName,
|
||||
ReasoningContent: msg.ReasoningContent,
|
||||
}
|
||||
|
||||
|
||||
@@ -171,16 +171,25 @@ 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)
|
||||
if result.ReasoningContent != "I thought about this carefully" {
|
||||
t.Errorf("ReasoningContent = %q, want 'I thought about this carefully'", result.ReasoningContent)
|
||||
}
|
||||
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) {
|
||||
@@ -189,6 +198,7 @@ func TestSeahorseToProviderMessagesWithReasoning(t *testing.T) {
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "response",
|
||||
ModelName: "gpt-5.4",
|
||||
ReasoningContent: "thinking process",
|
||||
},
|
||||
},
|
||||
@@ -201,6 +211,9 @@ func TestSeahorseToProviderMessagesWithReasoning(t *testing.T) {
|
||||
if messages[0].ReasoningContent != "thinking process" {
|
||||
t.Errorf("ReasoningContent = %q, want 'thinking process'", messages[0].ReasoningContent)
|
||||
}
|
||||
if messages[0].ModelName != "gpt-5.4" {
|
||||
t.Errorf("ModelName = %q, want %q", messages[0].ModelName, "gpt-5.4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeahorseToProviderMessages(t *testing.T) {
|
||||
@@ -280,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
|
||||
@@ -72,7 +83,9 @@ func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUs
|
||||
return &bus.ContextUsage{
|
||||
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"
|
||||
@@ -31,6 +30,7 @@ type AgentInstance struct {
|
||||
MaxTokens int
|
||||
Temperature float64
|
||||
ThinkingLevel ThinkingLevel
|
||||
ThinkingLevelConfigured bool
|
||||
ContextWindow int
|
||||
SummarizeMessageThreshold int
|
||||
SummarizeTokenPercent int
|
||||
@@ -184,6 +184,7 @@ func NewAgentInstance(
|
||||
thinkingLevelStr = mc.ThinkingLevel
|
||||
}
|
||||
thinkingLevel := parseThinkingLevel(thinkingLevelStr)
|
||||
thinkingLevelConfigured := isConfiguredThinkingLevel(thinkingLevelStr)
|
||||
|
||||
summarizeMessageThreshold := defaults.SummarizeMessageThreshold
|
||||
if summarizeMessageThreshold == 0 {
|
||||
@@ -251,6 +252,7 @@ func NewAgentInstance(
|
||||
MaxTokens: maxTokens,
|
||||
Temperature: temperature,
|
||||
ThinkingLevel: thinkingLevel,
|
||||
ThinkingLevelConfigured: thinkingLevelConfigured,
|
||||
ContextWindow: contextWindow,
|
||||
SummarizeMessageThreshold: summarizeMessageThreshold,
|
||||
SummarizeTokenPercent: summarizeTokenPercent,
|
||||
@@ -411,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)
|
||||
|
||||
@@ -21,6 +21,9 @@ type MessageBus interface {
|
||||
// PublishOutboundMedia sends an outbound media message.
|
||||
PublishOutboundMedia(ctx context.Context, msg bus.OutboundMediaMessage) error
|
||||
|
||||
// GetStreamer returns a channel streamer when the active channel supports streaming.
|
||||
GetStreamer(ctx context.Context, channel, chatID, sessionKey string) (bus.Streamer, bool)
|
||||
|
||||
// InboundChan returns the channel for receiving inbound messages.
|
||||
InboundChan() <-chan bus.InboundMessage
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -29,6 +29,36 @@ func modelConfigIdentityKey(mc *config.ModelConfig) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func effectiveDefaultProvider(defaultProvider string) string {
|
||||
defaultProvider = strings.TrimSpace(defaultProvider)
|
||||
if defaultProvider == "" {
|
||||
return "openai"
|
||||
}
|
||||
return providers.NormalizeProvider(defaultProvider)
|
||||
}
|
||||
|
||||
func modelProviderAndIDForResolution(defaultProvider string, mc *config.ModelConfig) (provider string, modelID string) {
|
||||
if mc == nil {
|
||||
return "", ""
|
||||
}
|
||||
return providers.ExtractProtocol(mc)
|
||||
}
|
||||
|
||||
func cloneModelConfigForResolution(
|
||||
defaultProvider string,
|
||||
mc *config.ModelConfig,
|
||||
workspace string,
|
||||
) *config.ModelConfig {
|
||||
if mc == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *mc
|
||||
if clone.Workspace == "" {
|
||||
clone.Workspace = workspace
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func candidateFromModelConfig(
|
||||
defaultProvider string,
|
||||
mc *config.ModelConfig,
|
||||
@@ -37,7 +67,7 @@ func candidateFromModelConfig(
|
||||
return providers.FallbackCandidate{}, false
|
||||
}
|
||||
|
||||
protocol, modelID := providers.ExtractProtocol(mc)
|
||||
protocol, modelID := modelProviderAndIDForResolution(defaultProvider, mc)
|
||||
if strings.TrimSpace(modelID) == "" {
|
||||
return providers.FallbackCandidate{}, false
|
||||
}
|
||||
@@ -45,12 +75,13 @@ func candidateFromModelConfig(
|
||||
return providers.FallbackCandidate{
|
||||
Provider: protocol,
|
||||
Model: modelID,
|
||||
DisplayName: strings.TrimSpace(mc.ModelName),
|
||||
RPM: mc.RPM,
|
||||
IdentityKey: modelConfigIdentityKey(mc),
|
||||
}, true
|
||||
}
|
||||
|
||||
func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig {
|
||||
func lookupModelConfigByRef(cfg *config.Config, raw string, defaultProvider ...string) *config.ModelConfig {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || cfg == nil {
|
||||
return nil
|
||||
@@ -66,6 +97,10 @@ func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig
|
||||
rawKey = providers.ModelKey(rawRef.Provider, rawRef.Model)
|
||||
}
|
||||
|
||||
fallbackProvider := ""
|
||||
if len(defaultProvider) > 0 {
|
||||
fallbackProvider = effectiveDefaultProvider(defaultProvider[0])
|
||||
}
|
||||
for i := range cfg.ModelList {
|
||||
mc := cfg.ModelList[i]
|
||||
if mc == nil {
|
||||
@@ -75,13 +110,15 @@ func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig
|
||||
if fullModel == "" {
|
||||
continue
|
||||
}
|
||||
protocol, modelID := modelProviderAndIDForResolution(fallbackProvider, mc)
|
||||
if fullModel == raw {
|
||||
return mc
|
||||
}
|
||||
protocol, modelID := providers.ExtractProtocol(mc)
|
||||
if modelID == raw {
|
||||
if fallbackProvider == "" || providers.NormalizeProvider(protocol) == fallbackProvider {
|
||||
return mc
|
||||
}
|
||||
}
|
||||
if rawKey != "" && providers.ModelKey(protocol, modelID) == rawKey {
|
||||
return mc
|
||||
}
|
||||
@@ -99,8 +136,9 @@ func resolveModelCandidate(
|
||||
if raw == "" {
|
||||
return providers.FallbackCandidate{}, false
|
||||
}
|
||||
defaultProvider = effectiveDefaultProvider(defaultProvider)
|
||||
|
||||
if mc := lookupModelConfigByRef(cfg, raw); mc != nil {
|
||||
if mc := lookupModelConfigByRef(cfg, raw, defaultProvider); mc != nil {
|
||||
return candidateFromModelConfig(defaultProvider, mc)
|
||||
}
|
||||
|
||||
@@ -112,6 +150,7 @@ func resolveModelCandidate(
|
||||
return providers.FallbackCandidate{
|
||||
Provider: ref.Provider,
|
||||
Model: ref.Model,
|
||||
DisplayName: raw,
|
||||
}, true
|
||||
}
|
||||
|
||||
@@ -160,6 +199,18 @@ func resolvedCandidateProvider(candidates []providers.FallbackCandidate, fallbac
|
||||
return fallback
|
||||
}
|
||||
|
||||
func resolvedCandidateModelName(candidates []providers.FallbackCandidate, fallback string) string {
|
||||
if len(candidates) > 0 {
|
||||
if name := modelAliasFromCandidateIdentityKey(candidates[0].IdentityKey); strings.TrimSpace(name) != "" {
|
||||
return name
|
||||
}
|
||||
if displayName := strings.TrimSpace(candidates[0].DisplayName); displayName != "" {
|
||||
return displayName
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
|
||||
func resolvedModelConfig(cfg *config.Config, modelName, workspace string) (*config.ModelConfig, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("config is nil")
|
||||
@@ -177,3 +228,48 @@ func resolvedModelConfig(cfg *config.Config, modelName, workspace string) (*conf
|
||||
|
||||
return &clone, nil
|
||||
}
|
||||
|
||||
func resolveActiveModelConfig(
|
||||
cfg *config.Config,
|
||||
workspace string,
|
||||
candidates []providers.FallbackCandidate,
|
||||
activeModel string,
|
||||
defaultProvider string,
|
||||
) *config.ModelConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
defaultProvider = effectiveDefaultProvider(defaultProvider)
|
||||
|
||||
if len(candidates) > 0 {
|
||||
candidate := candidates[0]
|
||||
identityKey := strings.TrimSpace(candidate.IdentityKey)
|
||||
if identityKey != "" {
|
||||
for _, mc := range cfg.ModelList {
|
||||
if mc == nil || modelConfigIdentityKey(mc) != identityKey {
|
||||
continue
|
||||
}
|
||||
protocol, modelID := modelProviderAndIDForResolution(defaultProvider, mc)
|
||||
if providers.ModelKey(protocol, modelID) == providers.ModelKey(candidate.Provider, candidate.Model) {
|
||||
return cloneModelConfigForResolution(defaultProvider, mc, workspace)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, mc := range cfg.ModelList {
|
||||
if mc == nil {
|
||||
continue
|
||||
}
|
||||
protocol, modelID := modelProviderAndIDForResolution(defaultProvider, mc)
|
||||
if providers.ModelKey(protocol, modelID) == providers.ModelKey(candidate.Provider, candidate.Model) {
|
||||
return cloneModelConfigForResolution(defaultProvider, mc, workspace)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if mc := lookupModelConfigByRef(cfg, activeModel, defaultProvider); mc != nil {
|
||||
return cloneModelConfigForResolution(defaultProvider, mc, workspace)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func TestModelNameFromIdentityKey_LegacyProviderModel(t *testing.T) {
|
||||
if got := modelNameFromIdentityKey("openai/gpt-5.4"); got != "gpt-5.4" {
|
||||
t.Fatalf("modelNameFromIdentityKey() = %q, want %q", got, "gpt-5.4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelNameFromIdentityKey_PreservesNonLegacyIdentity(t *testing.T) {
|
||||
if got := modelNameFromIdentityKey("model_name:primary"); got != "model_name:primary" {
|
||||
t.Fatalf("modelNameFromIdentityKey() = %q, want %q", got, "model_name:primary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelAliasFromCandidateIdentityKey(t *testing.T) {
|
||||
if got := modelAliasFromCandidateIdentityKey("model_name:primary"); got != "primary" {
|
||||
t.Fatalf("modelAliasFromCandidateIdentityKey() = %q, want %q", got, "primary")
|
||||
}
|
||||
if got := modelAliasFromCandidateIdentityKey("openai/gpt-5.4"); got != "" {
|
||||
t.Fatalf("modelAliasFromCandidateIdentityKey() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedCandidateModelName_PrefersIdentityAlias(t *testing.T) {
|
||||
got := resolvedCandidateModelName([]providers.FallbackCandidate{
|
||||
{Provider: "openai", Model: "gpt-5.4", IdentityKey: "model_name:primary"},
|
||||
}, "fallback-model")
|
||||
if got != "primary" {
|
||||
t.Fatalf("resolvedCandidateModelName() = %q, want %q", got, "primary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedCandidateModelName_DoesNotScanFallbackAliases(t *testing.T) {
|
||||
got := resolvedCandidateModelName([]providers.FallbackCandidate{
|
||||
{Provider: "openai", Model: "gpt-5.4"},
|
||||
{Provider: "openai", Model: "gpt-5.4-mini", IdentityKey: "model_name:fallback"},
|
||||
}, "primary-model")
|
||||
if got != "primary-model" {
|
||||
t.Fatalf("resolvedCandidateModelName() = %q, want %q", got, "primary-model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedCandidateModelName_UsesCandidateDisplayName(t *testing.T) {
|
||||
got := resolvedCandidateModelName([]providers.FallbackCandidate{
|
||||
{Provider: "openai", Model: "gpt-5.4", DisplayName: "gpt-5.4-display"},
|
||||
}, "fallback-model")
|
||||
if got != "gpt-5.4-display" {
|
||||
t.Fatalf("resolvedCandidateModelName() = %q, want %q", got, "gpt-5.4-display")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveActiveModelConfig_PrefersCandidateIdentityKey(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "glm-4.7",
|
||||
Provider: "zhipu",
|
||||
Model: "glm-4.7",
|
||||
Streaming: config.ModelStreamingConfig{Enabled: false},
|
||||
},
|
||||
{
|
||||
ModelName: "suanneng-glm-4.7",
|
||||
Provider: "zhipu",
|
||||
Model: "glm-4.7",
|
||||
Streaming: config.ModelStreamingConfig{Enabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := resolveActiveModelConfig(
|
||||
cfg,
|
||||
"/workspace",
|
||||
[]providers.FallbackCandidate{{
|
||||
Provider: "zhipu",
|
||||
Model: "glm-4.7",
|
||||
IdentityKey: "model_name:suanneng-glm-4.7",
|
||||
}},
|
||||
"glm-4.7",
|
||||
"openai",
|
||||
)
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("resolveActiveModelConfig() = nil, want model config")
|
||||
}
|
||||
if got.ModelName != "suanneng-glm-4.7" {
|
||||
t.Fatalf("model_name = %q, want %q", got.ModelName, "suanneng-glm-4.7")
|
||||
}
|
||||
if !got.Streaming.Enabled {
|
||||
t.Fatal("streaming.enabled = false, want true from identity-matched model config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveActiveModelConfig_LoadBalancedAliasUsesSelectedCandidate(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "lb-model",
|
||||
Model: "openai/primary",
|
||||
Streaming: config.ModelStreamingConfig{Enabled: false},
|
||||
},
|
||||
{
|
||||
ModelName: "lb-model",
|
||||
Model: "openai/secondary",
|
||||
Streaming: config.ModelStreamingConfig{Enabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := resolveActiveModelConfig(
|
||||
cfg,
|
||||
"/workspace",
|
||||
[]providers.FallbackCandidate{{
|
||||
Provider: "openai",
|
||||
Model: "secondary",
|
||||
IdentityKey: "model_name:lb-model",
|
||||
}},
|
||||
"lb-model",
|
||||
"openai",
|
||||
)
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("resolveActiveModelConfig() = nil, want model config")
|
||||
}
|
||||
if got.Model != "openai/secondary" {
|
||||
t.Fatalf("model = %q, want openai/secondary", got.Model)
|
||||
}
|
||||
if !got.Streaming.Enabled {
|
||||
t.Fatal("streaming.enabled = false, want true from selected load-balanced entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveActiveModelConfig_DoesNotFallbackToOpenAIForDefaultProviderCandidate(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "openai-gpt",
|
||||
Provider: "openai",
|
||||
Model: "gpt-4o",
|
||||
Streaming: config.ModelStreamingConfig{Enabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := resolveActiveModelConfig(
|
||||
cfg,
|
||||
"/workspace",
|
||||
[]providers.FallbackCandidate{{
|
||||
Provider: "nvidia",
|
||||
Model: "gpt-4o",
|
||||
}},
|
||||
"gpt-4o",
|
||||
"nvidia",
|
||||
)
|
||||
|
||||
if got != nil {
|
||||
t.Fatalf("resolveActiveModelConfig() = %#v, want nil for non-active provider config", got)
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,36 @@ toolLoop:
|
||||
|
||||
toolName := tc.Name
|
||||
toolArgs := cloneStringAnyMap(tc.Arguments)
|
||||
denyByTurnProfile := func() bool {
|
||||
if turnProfileToolAllowed(ts.profile, toolName) {
|
||||
return false
|
||||
}
|
||||
exec.allResponsesHandled = false
|
||||
denyContent := fmt.Sprintf("Tool %q is not allowed by the active turn profile.", toolName)
|
||||
al.emitEvent(
|
||||
runtimeevents.KindAgentToolExecSkipped,
|
||||
ts.eventMeta("runTurn", "turn.tool.skipped"),
|
||||
ToolExecSkippedPayload{
|
||||
Tool: toolName,
|
||||
Reason: denyContent,
|
||||
},
|
||||
)
|
||||
deniedMsg := providers.Message{
|
||||
Role: "tool",
|
||||
Content: denyContent,
|
||||
ToolCallID: tc.ID,
|
||||
}
|
||||
messages = append(messages, deniedMsg)
|
||||
if !ts.opts.NoHistory {
|
||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg)
|
||||
ts.recordPersistedMessage(deniedMsg)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if denyByTurnProfile() {
|
||||
continue
|
||||
}
|
||||
|
||||
if al.hooks != nil {
|
||||
toolReq, decision := al.hooks.BeforeTool(turnCtx, &ToolCallHookRequest{
|
||||
@@ -180,7 +210,11 @@ toolLoop:
|
||||
toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen),
|
||||
)
|
||||
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
|
||||
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback))
|
||||
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithOptions(
|
||||
ts,
|
||||
feedbackMsg,
|
||||
outboundTurnMessageOptions{kind: messageKindToolFeedback},
|
||||
))
|
||||
fbCancel()
|
||||
}
|
||||
|
||||
@@ -359,8 +393,10 @@ toolLoop:
|
||||
content := al.cfg.FilterSensitiveData(result.ForLLM)
|
||||
msg := subTurnResultPromptMessage(content)
|
||||
messages = append(messages, msg)
|
||||
if !ts.opts.NoHistory {
|
||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
|
||||
}
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -437,6 +473,10 @@ toolLoop:
|
||||
}
|
||||
}
|
||||
|
||||
if denyByTurnProfile() {
|
||||
continue
|
||||
}
|
||||
|
||||
argsJSON, _ := json.Marshal(toolArgs)
|
||||
argsPreview := utils.Truncate(string(argsJSON), 200)
|
||||
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolName, argsPreview),
|
||||
@@ -467,7 +507,11 @@ toolLoop:
|
||||
toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen),
|
||||
)
|
||||
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
|
||||
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback))
|
||||
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithOptions(
|
||||
ts,
|
||||
feedbackMsg,
|
||||
outboundTurnMessageOptions{kind: messageKindToolFeedback},
|
||||
))
|
||||
fbCancel()
|
||||
}
|
||||
|
||||
@@ -740,8 +784,10 @@ toolLoop:
|
||||
content := al.cfg.FilterSensitiveData(result.ForLLM)
|
||||
msg := subTurnResultPromptMessage(content)
|
||||
messages = append(messages, msg)
|
||||
if !ts.opts.NoHistory {
|
||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
|
||||
}
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -794,7 +840,7 @@ toolLoop:
|
||||
})
|
||||
}
|
||||
}
|
||||
if ts.opts.EnableSummary {
|
||||
if !ts.opts.NoHistory && ts.opts.EnableSummary {
|
||||
al.contextManager.Compact(turnCtx, &CompactRequest{
|
||||
SessionKey: ts.sessionKey,
|
||||
Reason: ContextCompressReasonSummarize,
|
||||
|
||||
@@ -33,6 +33,7 @@ func (p *Pipeline) Finalize(
|
||||
ts.setPhase(TurnPhaseCompleted)
|
||||
return turnResult{
|
||||
finalContent: finalContent,
|
||||
modelName: exec.llmModelName,
|
||||
status: turnStatus,
|
||||
followUps: append([]bus.InboundMessage(nil), ts.followUps...),
|
||||
}, nil
|
||||
@@ -44,6 +45,7 @@ func (p *Pipeline) Finalize(
|
||||
finalMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: finalContent,
|
||||
ModelName: exec.llmModelName,
|
||||
ReasoningContent: responseReasoningContent(exec.response),
|
||||
}
|
||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, finalMsg)
|
||||
@@ -58,11 +60,12 @@ func (p *Pipeline) Finalize(
|
||||
Message: err.Error(),
|
||||
},
|
||||
)
|
||||
cancelConfiguredStreamingLLM(turnCtx, exec)
|
||||
return turnResult{status: TurnEndStatusError}, err
|
||||
}
|
||||
}
|
||||
|
||||
if ts.opts.EnableSummary {
|
||||
if !ts.opts.NoHistory && ts.opts.EnableSummary {
|
||||
al.contextManager.Compact(
|
||||
turnCtx,
|
||||
&CompactRequest{
|
||||
@@ -73,9 +76,31 @@ func (p *Pipeline) Finalize(
|
||||
)
|
||||
}
|
||||
|
||||
contextUsage := computeContextUsage(ts.agent, ts.sessionKey)
|
||||
streamErr := finalizeConfiguredStreamingLLM(turnCtx, ts, exec, finalContent, contextUsage)
|
||||
// If streaming never became visible, keep the legacy Pico interim publish path
|
||||
// so the final answer is still delivered outside normal SendResponse.
|
||||
if ((streamErr != nil && !isConfiguredStreamingVisibleError(streamErr)) || exec.streamingFallback) &&
|
||||
!ts.opts.SendResponse && ts.opts.AllowInterimPicoPublish && finalContent != "" {
|
||||
msg := outboundMessageForTurnWithOptions(ts, finalContent, outboundTurnMessageOptions{
|
||||
modelName: exec.llmModelName,
|
||||
})
|
||||
msg.ContextUsage = contextUsage
|
||||
markFinalOutbound(&msg)
|
||||
_ = al.bus.PublishOutbound(turnCtx, msg)
|
||||
}
|
||||
if streamErr != nil && isConfiguredStreamingVisibleError(streamErr) {
|
||||
ts.setPhase(TurnPhaseCompleted)
|
||||
return turnResult{
|
||||
finalContent: finalContent,
|
||||
status: TurnEndStatusError,
|
||||
followUps: append([]bus.InboundMessage(nil), ts.followUps...),
|
||||
}, streamErr
|
||||
}
|
||||
ts.setPhase(TurnPhaseCompleted)
|
||||
return turnResult{
|
||||
finalContent: finalContent,
|
||||
modelName: exec.llmModelName,
|
||||
status: turnStatus,
|
||||
followUps: append([]bus.InboundMessage(nil), ts.followUps...),
|
||||
}, nil
|
||||
|
||||
+232
-69
@@ -37,9 +37,10 @@ func (p *Pipeline) CallLLM(
|
||||
// PreLLM: graceful terminal handling
|
||||
exec.gracefulTerminal, _ = ts.gracefulInterruptRequested()
|
||||
exec.providerToolDefs = ts.agent.Tools.ToProviderDefs()
|
||||
exec.providerToolDefs = filterToolsByTurnProfile(exec.providerToolDefs, ts.profile)
|
||||
|
||||
// Native web search support
|
||||
webSearchEnabled := al.cfg.Tools.IsToolEnabled("web")
|
||||
webSearchEnabled := al.cfg.Tools.IsToolEnabled("web") && turnProfileToolAllowed(ts.profile, "web_search")
|
||||
exec.useNativeSearch = webSearchEnabled && al.cfg.Tools.Web.PreferNative &&
|
||||
func() bool {
|
||||
if ns, ok := ts.agent.Provider.(providers.NativeSearchCapable); ok {
|
||||
@@ -47,7 +48,6 @@ func (p *Pipeline) CallLLM(
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
if exec.useNativeSearch {
|
||||
filtered := make([]providers.ToolDefinition, 0, len(exec.providerToolDefs))
|
||||
for _, td := range exec.providerToolDefs {
|
||||
@@ -73,14 +73,7 @@ func (p *Pipeline) CallLLM(
|
||||
if exec.useNativeSearch {
|
||||
exec.llmOpts["native_search"] = true
|
||||
}
|
||||
if ts.agent.ThinkingLevel != ThinkingOff {
|
||||
if tc, ok := ts.agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() {
|
||||
exec.llmOpts["thinking_level"] = string(ts.agent.ThinkingLevel)
|
||||
} else {
|
||||
logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring",
|
||||
map[string]any{"agent_id": ts.agent.ID, "thinking_level": string(ts.agent.ThinkingLevel)})
|
||||
}
|
||||
}
|
||||
applyTurnThinkingOptions(exec, ts.agent, exec.activeProvider, true)
|
||||
|
||||
exec.llmModel = exec.activeModel
|
||||
|
||||
@@ -98,15 +91,27 @@ func (p *Pipeline) CallLLM(
|
||||
switch decision.normalizedAction() {
|
||||
case HookActionContinue, HookActionModify:
|
||||
if llmReq != nil {
|
||||
prevModel := exec.llmModel
|
||||
exec.llmModel = llmReq.Model
|
||||
exec.callMessages = llmReq.Messages
|
||||
exec.providerToolDefs = llmReq.Tools
|
||||
exec.providerToolDefs = filterToolsByTurnProfile(llmReq.Tools, ts.profile)
|
||||
exec.llmOpts = llmReq.Options
|
||||
nativeSearchAllowed := exec.useNativeSearch &&
|
||||
turnProfileToolAllowed(ts.profile, "web_search")
|
||||
if !nativeSearchAllowed {
|
||||
delete(exec.llmOpts, "native_search")
|
||||
}
|
||||
if strings.TrimSpace(exec.llmModel) != "" && exec.llmModel != prevModel {
|
||||
p.applyBeforeLLMModelRewrite(ts, exec)
|
||||
applyTurnThinkingOptions(exec, ts.agent, exec.activeProvider, true)
|
||||
}
|
||||
}
|
||||
case HookActionAbortTurn:
|
||||
cancelConfiguredStreamingLLM(turnCtx, exec)
|
||||
exec.abortedByHook = true
|
||||
return ControlBreak, nil
|
||||
case HookActionHardAbort:
|
||||
cancelConfiguredStreamingLLM(turnCtx, exec)
|
||||
_ = ts.requestHardAbort()
|
||||
exec.abortedByHardAbort = true
|
||||
return ControlBreak, nil
|
||||
@@ -155,16 +160,44 @@ func (p *Pipeline) CallLLM(
|
||||
al.activeRequests.Add(1)
|
||||
defer al.activeRequests.Done()
|
||||
|
||||
if response, handled, streamErr := p.tryConfiguredStreamingLLM(
|
||||
providerCtx,
|
||||
ts,
|
||||
exec,
|
||||
messagesForCall,
|
||||
toolDefsForCall,
|
||||
); handled {
|
||||
return response, streamErr
|
||||
}
|
||||
|
||||
if len(exec.activeCandidates) > 1 && p.Fallback != nil {
|
||||
fbResult, fbErr := p.Fallback.Execute(
|
||||
fbResult, fbErr := p.Fallback.ExecuteCandidate(
|
||||
providerCtx,
|
||||
exec.activeCandidates,
|
||||
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
||||
candidateProvider := exec.activeProvider
|
||||
if cp, ok := ts.agent.CandidateProviders[providers.ModelKey(provider, model)]; ok {
|
||||
candidateProvider = cp
|
||||
func(ctx context.Context, candidate providers.FallbackCandidate) (*providers.LLMResponse, error) {
|
||||
candidateProvider, err := providerForFallbackCandidate(
|
||||
ts.agent,
|
||||
exec.activeProvider,
|
||||
exec.activeCandidates,
|
||||
candidate.Provider,
|
||||
candidate.Model,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, model, exec.llmOpts)
|
||||
callOpts := shallowCloneLLMOptions(exec.llmOpts)
|
||||
delete(callOpts, "thinking_level")
|
||||
candidateCfg := resolveActiveModelConfig(
|
||||
p.Cfg,
|
||||
ts.agent.Workspace,
|
||||
[]providers.FallbackCandidate{candidate},
|
||||
candidate.Model,
|
||||
p.Cfg.Agents.Defaults.Provider,
|
||||
)
|
||||
candidateThinking := thinkingSettingsFromModelConfig(candidateCfg)
|
||||
applyThinkingOption(callOpts, candidateProvider, candidateThinking, true, ts.agent.ID)
|
||||
exec.suppressReasoning = shouldSuppressReasoningFor(candidateThinking)
|
||||
return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, candidate.Model, callOpts)
|
||||
},
|
||||
)
|
||||
if fbErr != nil {
|
||||
@@ -178,6 +211,16 @@ func (p *Pipeline) CallLLM(
|
||||
map[string]any{"agent_id": ts.agent.ID, "iteration": iteration},
|
||||
)
|
||||
}
|
||||
for _, candidate := range exec.activeCandidates {
|
||||
if candidate.StableKey() != fbResult.IdentityKey {
|
||||
continue
|
||||
}
|
||||
exec.llmModelName = resolvedCandidateModelName(
|
||||
[]providers.FallbackCandidate{candidate},
|
||||
exec.llmModelName,
|
||||
)
|
||||
break
|
||||
}
|
||||
return fbResult.Response, nil
|
||||
}
|
||||
return exec.activeProvider.Chat(providerCtx, messagesForCall, toolDefsForCall, exec.llmModel, exec.llmOpts)
|
||||
@@ -203,6 +246,9 @@ func (p *Pipeline) CallLLM(
|
||||
exec.abortedByHardAbort = true
|
||||
return ControlBreak, nil
|
||||
}
|
||||
if isConfiguredStreamingVisibleError(err) {
|
||||
break
|
||||
}
|
||||
|
||||
// Retry without media if vision is unsupported
|
||||
if hasMediaRefs(exec.callMessages) && isVisionUnsupportedError(err) && retry < maxRetries {
|
||||
@@ -234,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") ||
|
||||
@@ -260,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,
|
||||
@@ -268,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(),
|
||||
})
|
||||
@@ -369,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)
|
||||
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...)
|
||||
exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
|
||||
exec.callMessages = exec.messages
|
||||
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
|
||||
@@ -415,9 +470,11 @@ func (p *Pipeline) CallLLM(
|
||||
exec.response = llmResp.Response
|
||||
}
|
||||
case HookActionAbortTurn:
|
||||
cancelConfiguredStreamingLLM(turnCtx, exec)
|
||||
exec.abortedByHook = true
|
||||
return ControlBreak, nil
|
||||
case HookActionHardAbort:
|
||||
cancelConfiguredStreamingLLM(turnCtx, exec)
|
||||
_ = ts.requestHardAbort()
|
||||
exec.abortedByHardAbort = true
|
||||
return ControlBreak, nil
|
||||
@@ -432,13 +489,31 @@ func (p *Pipeline) CallLLM(
|
||||
}
|
||||
}
|
||||
|
||||
if exec.suppressReasoning {
|
||||
exec.response.Reasoning = ""
|
||||
exec.response.ReasoningContent = ""
|
||||
exec.response.ReasoningDetails = nil
|
||||
}
|
||||
reasoningContent := responseReasoningContent(exec.response)
|
||||
shouldPublishPicoToolCallInterim := ts.channel == "pico" && len(exec.response.ToolCalls) > 0
|
||||
if shouldPublishPicoToolCallInterim {
|
||||
// Pico tool-call turns publish their reasoning/content/tool summary as a
|
||||
// structured sequence after the tool-call payload is normalized below.
|
||||
} else if ts.channel == "pico" {
|
||||
go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID)
|
||||
if exec.streamingPublisher != nil && exec.streamingPublisher.ReasoningPublished() {
|
||||
if err := exec.streamingPublisher.FinalizeReasoning(turnCtx, reasoningContent); err != nil {
|
||||
logger.WarnCF("agent", "Failed to finalize streamed pico reasoning", map[string]any{
|
||||
"channel": ts.channel,
|
||||
"chat_id": ts.chatID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Publish pico thoughts before the turn context is canceled at return time.
|
||||
// The async variant can race with turn teardown and intermittently drop the
|
||||
// thought message in CI even though the LLM produced reasoning content.
|
||||
al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID, ts.sessionKey, exec.llmModelName)
|
||||
}
|
||||
} else {
|
||||
go al.handleReasoning(
|
||||
turnCtx,
|
||||
@@ -480,6 +555,7 @@ func (p *Pipeline) CallLLM(
|
||||
responseContent = exec.response.ReasoningContent
|
||||
}
|
||||
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
|
||||
cancelConfiguredStreamingLLM(turnCtx, exec)
|
||||
logger.InfoCF("agent", "Steering arrived after direct LLM response; continuing turn",
|
||||
map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
@@ -489,6 +565,7 @@ func (p *Pipeline) CallLLM(
|
||||
exec.pendingMessages = append(exec.pendingMessages, steerMsgs...)
|
||||
return ControlContinue, nil
|
||||
}
|
||||
|
||||
exec.finalContent = responseContent
|
||||
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
|
||||
map[string]any{
|
||||
@@ -498,6 +575,7 @@ func (p *Pipeline) CallLLM(
|
||||
})
|
||||
return ControlBreak, nil
|
||||
}
|
||||
cancelConfiguredStreamingLLM(turnCtx, exec)
|
||||
|
||||
// Tool-call path: normalize and prepare for tool execution
|
||||
exec.normalizedToolCalls = make([]providers.ToolCall, 0, len(exec.response.ToolCalls))
|
||||
@@ -521,6 +599,7 @@ func (p *Pipeline) CallLLM(
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: exec.response.Content,
|
||||
ModelName: exec.llmModelName,
|
||||
ReasoningContent: reasoningContent,
|
||||
}
|
||||
for _, tc := range exec.normalizedToolCalls {
|
||||
@@ -564,6 +643,7 @@ func (p *Pipeline) CallLLM(
|
||||
al.publishPicoToolCallInterim(
|
||||
turnCtx,
|
||||
ts,
|
||||
exec.llmModelName,
|
||||
reasoningContent,
|
||||
exec.response.Content,
|
||||
assistantMsg.ToolCalls,
|
||||
@@ -572,3 +652,86 @@ func (p *Pipeline) CallLLM(
|
||||
|
||||
return ControlToolLoop, nil
|
||||
}
|
||||
|
||||
func (p *Pipeline) applyBeforeLLMModelRewrite(ts *turnState, exec *turnExecution) {
|
||||
if p == nil || ts == nil || ts.agent == nil || exec == nil {
|
||||
return
|
||||
}
|
||||
rawModel := strings.TrimSpace(exec.llmModel)
|
||||
if rawModel == "" {
|
||||
return
|
||||
}
|
||||
|
||||
defaultProvider := "openai"
|
||||
if p.Cfg != nil {
|
||||
if provider := strings.TrimSpace(p.Cfg.Agents.Defaults.Provider); provider != "" {
|
||||
defaultProvider = provider
|
||||
}
|
||||
}
|
||||
defaultProvider = effectiveDefaultProvider(defaultProvider)
|
||||
candidates := resolveModelCandidates(p.Cfg, defaultProvider, rawModel, nil)
|
||||
exec.activeCandidates = candidates
|
||||
exec.activeModel = resolvedCandidateModel(candidates, rawModel)
|
||||
exec.llmModel = exec.activeModel
|
||||
exec.activeModelConfig = resolveActiveModelConfig(p.Cfg, ts.agent.Workspace, candidates, rawModel, defaultProvider)
|
||||
}
|
||||
|
||||
func providerForFallbackCandidate(
|
||||
agent *AgentInstance,
|
||||
activeProvider providers.LLMProvider,
|
||||
activeCandidates []providers.FallbackCandidate,
|
||||
provider string,
|
||||
model string,
|
||||
) (providers.LLMProvider, error) {
|
||||
if agent != nil {
|
||||
if cp, ok := agent.CandidateProviders[providers.ModelKey(provider, model)]; ok && cp != nil {
|
||||
return cp, nil
|
||||
}
|
||||
}
|
||||
if activeProvider == nil {
|
||||
return nil, fmt.Errorf("fallback model %q has no active provider", model)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -36,14 +36,14 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
||||
contextualSkills = ts.agent.ContextBuilder.ResolveActiveSkillsForContext(ts.activeSkills)
|
||||
}
|
||||
ts.recordSkillContextSnapshot(skillContextTriggerInitialBuild, contextualSkills)
|
||||
initialPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media)
|
||||
initialPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media, cfg)
|
||||
initialPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
||||
messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt(initialPromptReq)
|
||||
|
||||
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
|
||||
|
||||
if !ts.opts.NoHistory {
|
||||
toolDefs := ts.agent.Tools.ToProviderDefs()
|
||||
toolDefs := filterToolsByTurnProfile(ts.agent.Tools.ToProviderDefs(), ts.profile)
|
||||
if isOverContextBudget(ts.agent.ContextWindow, messages, toolDefs, ts.agent.MaxTokens) {
|
||||
logger.WarnCF("agent", "Proactive compression: context budget exceeded before LLM call",
|
||||
map[string]any{"session_key": ts.sessionKey})
|
||||
@@ -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)
|
||||
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...)
|
||||
messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
|
||||
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +124,11 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
||||
if usedLight && ts.agent.LightProvider != nil {
|
||||
activeProvider = ts.agent.LightProvider
|
||||
}
|
||||
activeModelName := strings.TrimSpace(ts.agent.Model)
|
||||
if usedLight {
|
||||
activeModelName = strings.TrimSpace(sideQuestionModelName(ts.agent, true))
|
||||
}
|
||||
activeModelName = resolvedCandidateModelName(activeCandidates, activeModelName)
|
||||
|
||||
exec := newTurnExecution(
|
||||
ts.agent,
|
||||
@@ -99,6 +139,14 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
||||
)
|
||||
exec.activeCandidates = activeCandidates
|
||||
exec.activeModel = activeModel
|
||||
exec.activeModelConfig = resolveActiveModelConfig(
|
||||
p.Cfg,
|
||||
ts.agent.Workspace,
|
||||
activeCandidates,
|
||||
activeModel,
|
||||
p.Cfg.Agents.Defaults.Provider,
|
||||
)
|
||||
exec.llmModelName = activeModelName
|
||||
exec.activeProvider = activeProvider
|
||||
exec.usedLight = usedLight
|
||||
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func (p *Pipeline) tryConfiguredStreamingLLM(
|
||||
ctx context.Context,
|
||||
ts *turnState,
|
||||
exec *turnExecution,
|
||||
messagesForCall []providers.Message,
|
||||
toolDefsForCall []providers.ToolDefinition,
|
||||
) (*providers.LLMResponse, bool, error) {
|
||||
exec.streamingPublisher = nil
|
||||
exec.streamingFallback = false
|
||||
if !p.configuredStreamingEligible(ts, exec) {
|
||||
return nil, false, nil
|
||||
}
|
||||
streamProvider, ok := exec.activeProvider.(providers.StreamingProvider)
|
||||
if !ok {
|
||||
logger.DebugCF("agent", "configured streaming not used", map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"channel": ts.channel,
|
||||
"model": exec.activeModel,
|
||||
"reason": "provider_not_streaming",
|
||||
})
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
streamer, ok := p.Bus.GetStreamer(ctx, ts.channel, ts.chatID, ts.sessionKey)
|
||||
if !ok || streamer == nil {
|
||||
logger.DebugCF("agent", "configured streaming not used", map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"channel": ts.channel,
|
||||
"chat_id": ts.chatID,
|
||||
"model": exec.activeModel,
|
||||
"reason": "streamer_unavailable",
|
||||
})
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
publisher := &streamingChunkPublisher{
|
||||
streamer: streamer,
|
||||
channel: ts.channel,
|
||||
chatID: ts.chatID,
|
||||
modelName: exec.llmModelName,
|
||||
}
|
||||
|
||||
logger.DebugCF("agent", "configured streaming enabled", map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"channel": ts.channel,
|
||||
"chat_id": ts.chatID,
|
||||
"model": exec.llmModel,
|
||||
})
|
||||
|
||||
chunkCount := 0
|
||||
firstChunkAt := time.Time{}
|
||||
lastChunkAt := time.Time{}
|
||||
recordChunk := func() {
|
||||
now := time.Now()
|
||||
chunkCount++
|
||||
if firstChunkAt.IsZero() {
|
||||
firstChunkAt = now
|
||||
}
|
||||
lastChunkAt = now
|
||||
}
|
||||
var response *providers.LLMResponse
|
||||
var streamErr error
|
||||
if eventProvider, ok := exec.activeProvider.(providers.StreamingEventProvider); ok {
|
||||
response, streamErr = eventProvider.ChatStreamEvents(
|
||||
ctx,
|
||||
messagesForCall,
|
||||
toolDefsForCall,
|
||||
exec.llmModel,
|
||||
exec.llmOpts,
|
||||
func(chunk providers.StreamChunk) {
|
||||
recordChunk()
|
||||
if !exec.suppressReasoning && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
||||
publisher.UpdateReasoning(ctx, chunk.ReasoningContent)
|
||||
}
|
||||
if strings.TrimSpace(chunk.Content) != "" {
|
||||
publisher.Update(ctx, chunk.Content)
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
response, streamErr = streamProvider.ChatStream(
|
||||
ctx,
|
||||
messagesForCall,
|
||||
toolDefsForCall,
|
||||
exec.llmModel,
|
||||
exec.llmOpts,
|
||||
func(accumulated string) {
|
||||
recordChunk()
|
||||
publisher.Update(ctx, accumulated)
|
||||
},
|
||||
)
|
||||
}
|
||||
logConfiguredStreamingSummary(ts, exec, chunkCount, firstChunkAt, lastChunkAt, streamErr)
|
||||
if streamErr == nil {
|
||||
if updateErr := publisher.Err(); updateErr != nil {
|
||||
logFields := map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"channel": ts.channel,
|
||||
"model": exec.llmModel,
|
||||
"error": updateErr.Error(),
|
||||
}
|
||||
if publisher.Published() {
|
||||
logger.WarnCF("agent", "ChatStream update failed after visible output", logFields)
|
||||
return nil, true, configuredStreamingVisibleError{err: updateErr}
|
||||
}
|
||||
logger.WarnCF("agent", "ChatStream update failed before visible output; retrying with Chat", logFields)
|
||||
publisher.Cancel(ctx)
|
||||
fallbackResponse, err := exec.activeProvider.Chat(
|
||||
ctx,
|
||||
messagesForCall,
|
||||
toolDefsForCall,
|
||||
exec.llmModel,
|
||||
exec.llmOpts,
|
||||
)
|
||||
if err == nil && fallbackResponse != nil {
|
||||
exec.streamingFallback = true
|
||||
}
|
||||
return fallbackResponse, true, err
|
||||
}
|
||||
}
|
||||
if streamErr != nil {
|
||||
if !publisher.Published() {
|
||||
logger.WarnCF("agent", "ChatStream failed before visible output; retrying with Chat", map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"channel": ts.channel,
|
||||
"model": exec.llmModel,
|
||||
"error": streamErr.Error(),
|
||||
})
|
||||
publisher.Cancel(ctx)
|
||||
fallbackResponse, err := exec.activeProvider.Chat(
|
||||
ctx,
|
||||
messagesForCall,
|
||||
toolDefsForCall,
|
||||
exec.llmModel,
|
||||
exec.llmOpts,
|
||||
)
|
||||
if err == nil && fallbackResponse != nil {
|
||||
exec.streamingFallback = true
|
||||
}
|
||||
return fallbackResponse, true, err
|
||||
}
|
||||
return nil, true, configuredStreamingVisibleError{err: streamErr}
|
||||
}
|
||||
|
||||
if response != nil {
|
||||
exec.streamingPublisher = publisher
|
||||
}
|
||||
|
||||
return response, true, nil
|
||||
}
|
||||
|
||||
func logConfiguredStreamingSummary(
|
||||
ts *turnState,
|
||||
exec *turnExecution,
|
||||
chunkCount int,
|
||||
firstChunkAt time.Time,
|
||||
lastChunkAt time.Time,
|
||||
streamErr error,
|
||||
) {
|
||||
fields := map[string]any{
|
||||
"chunks": chunkCount,
|
||||
}
|
||||
if ts != nil {
|
||||
fields["agent_id"] = ts.agent.ID
|
||||
fields["channel"] = ts.channel
|
||||
}
|
||||
if exec != nil {
|
||||
fields["model"] = exec.llmModel
|
||||
}
|
||||
if !firstChunkAt.IsZero() && !lastChunkAt.IsZero() {
|
||||
fields["chunk_span_ms"] = lastChunkAt.Sub(firstChunkAt).Milliseconds()
|
||||
}
|
||||
if streamErr != nil {
|
||||
fields["error"] = streamErr.Error()
|
||||
}
|
||||
logger.DebugCF("agent", "configured streaming completed", fields)
|
||||
}
|
||||
|
||||
type configuredStreamingVisibleError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e configuredStreamingVisibleError) Error() string {
|
||||
if e.err == nil {
|
||||
return "configured streaming failed after visible output"
|
||||
}
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e configuredStreamingVisibleError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func isConfiguredStreamingVisibleError(err error) bool {
|
||||
var visibleErr configuredStreamingVisibleError
|
||||
return errors.As(err, &visibleErr)
|
||||
}
|
||||
|
||||
func finalizeConfiguredStreamingLLM(
|
||||
ctx context.Context,
|
||||
ts *turnState,
|
||||
exec *turnExecution,
|
||||
content string,
|
||||
contextUsage *bus.ContextUsage,
|
||||
) error {
|
||||
if exec == nil || exec.streamingPublisher == nil {
|
||||
return nil
|
||||
}
|
||||
publisher := exec.streamingPublisher
|
||||
exec.streamingPublisher = nil
|
||||
visibleBeforeFinalize := publisher.Published()
|
||||
if err := publisher.Finalize(ctx, content, contextUsage); err != nil {
|
||||
if visibleBeforeFinalize {
|
||||
logger.WarnCF("agent", "stream final flush failed after visible output", map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"channel": ts.channel,
|
||||
"model": exec.llmModel,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return configuredStreamingVisibleError{err: err}
|
||||
}
|
||||
publisher.Cancel(ctx)
|
||||
logger.WarnCF("agent", "stream final flush failed", map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"channel": ts.channel,
|
||||
"model": exec.llmModel,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cancelConfiguredStreamingLLM(ctx context.Context, exec *turnExecution) {
|
||||
if exec == nil || exec.streamingPublisher == nil {
|
||||
return
|
||||
}
|
||||
publisher := exec.streamingPublisher
|
||||
exec.streamingPublisher = nil
|
||||
publisher.Cancel(ctx)
|
||||
}
|
||||
|
||||
func (p *Pipeline) configuredStreamingEligible(ts *turnState, exec *turnExecution) bool {
|
||||
if p == nil || ts == nil || exec == nil || p.Bus == nil {
|
||||
logger.DebugCF("agent", "configured streaming not used", map[string]any{
|
||||
"reason": "missing_pipeline_state",
|
||||
})
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(ts.channel) == "" || strings.TrimSpace(ts.chatID) == "" {
|
||||
logger.DebugCF("agent", "configured streaming not used", map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"channel": ts.channel,
|
||||
"chat_id": ts.chatID,
|
||||
"model": exec.activeModel,
|
||||
"reason": "missing_channel_context",
|
||||
})
|
||||
return false
|
||||
}
|
||||
if !ts.opts.SendResponse && !ts.opts.AllowInterimPicoPublish {
|
||||
logger.DebugCF("agent", "configured streaming not used", map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"channel": ts.channel,
|
||||
"chat_id": ts.chatID,
|
||||
"model": exec.activeModel,
|
||||
"reason": "turn_output_disabled",
|
||||
})
|
||||
return false
|
||||
}
|
||||
if len(exec.activeCandidates) != 1 {
|
||||
logger.DebugCF("agent", "configured streaming not used", map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"channel": ts.channel,
|
||||
"model": exec.activeModel,
|
||||
"candidates": len(exec.activeCandidates),
|
||||
"reason": "fallback_candidates_enabled",
|
||||
})
|
||||
return false
|
||||
}
|
||||
if exec.activeModelConfig == nil || !exec.activeModelConfig.Streaming.Enabled {
|
||||
modelName := ""
|
||||
modelStreaming := false
|
||||
if exec.activeModelConfig != nil {
|
||||
modelName = exec.activeModelConfig.ModelName
|
||||
modelStreaming = exec.activeModelConfig.Streaming.Enabled
|
||||
}
|
||||
logger.DebugCF("agent", "configured streaming not used", map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"channel": ts.channel,
|
||||
"model": exec.activeModel,
|
||||
"model_name": modelName,
|
||||
"model_streaming": modelStreaming,
|
||||
"has_model_config": exec.activeModelConfig != nil,
|
||||
"reason": "model_streaming_disabled",
|
||||
})
|
||||
return false
|
||||
}
|
||||
channelStreaming, ok := p.channelStreamingConfig(ts.channel)
|
||||
if !ok || !channelStreaming.Enabled {
|
||||
logger.DebugCF("agent", "configured streaming not used", map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"channel": ts.channel,
|
||||
"model": exec.activeModel,
|
||||
"channel_streaming": channelStreaming.Enabled,
|
||||
"has_channel_config": ok,
|
||||
"reason": "channel_streaming_disabled",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Pipeline) channelStreamingConfig(channelName string) (config.StreamingConfig, bool) {
|
||||
if p == nil || p.Cfg == nil || p.Cfg.Channels == nil {
|
||||
return config.StreamingConfig{}, false
|
||||
}
|
||||
ch := p.Cfg.Channels[channelName]
|
||||
if ch == nil {
|
||||
return config.StreamingConfig{}, false
|
||||
}
|
||||
decoded, err := ch.GetDecoded()
|
||||
if err != nil {
|
||||
logger.WarnCF("agent", "channel streaming config decode failed", map[string]any{
|
||||
"channel": channelName,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return config.StreamingConfig{}, false
|
||||
}
|
||||
return streamingConfigFromDecodedSettings(decoded)
|
||||
}
|
||||
|
||||
func streamingConfigFromDecodedSettings(decoded any) (config.StreamingConfig, bool) {
|
||||
value := reflect.ValueOf(decoded)
|
||||
if !value.IsValid() {
|
||||
return config.StreamingConfig{}, false
|
||||
}
|
||||
if value.Kind() == reflect.Ptr {
|
||||
if value.IsNil() {
|
||||
return config.StreamingConfig{}, false
|
||||
}
|
||||
value = value.Elem()
|
||||
}
|
||||
if value.Kind() != reflect.Struct {
|
||||
return config.StreamingConfig{}, false
|
||||
}
|
||||
|
||||
field := value.FieldByName("Streaming")
|
||||
if !field.IsValid() || !field.CanInterface() {
|
||||
return config.StreamingConfig{}, false
|
||||
}
|
||||
streaming, ok := field.Interface().(config.StreamingConfig)
|
||||
return streaming, ok
|
||||
}
|
||||
|
||||
type streamingChunkPublisher struct {
|
||||
streamer bus.Streamer
|
||||
channel string
|
||||
chatID string
|
||||
modelName string
|
||||
published bool
|
||||
reasoningPublished bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *streamingChunkPublisher) Update(ctx context.Context, accumulated string) {
|
||||
if p == nil || p.streamer == nil || strings.TrimSpace(accumulated) == "" {
|
||||
return
|
||||
}
|
||||
if setter, ok := p.streamer.(interface{ SetModelName(modelName string) }); ok {
|
||||
setter.SetModelName(p.modelName)
|
||||
}
|
||||
if err := p.streamer.Update(ctx, accumulated); err != nil {
|
||||
p.err = err
|
||||
logger.WarnCF("agent", "stream update failed", map[string]any{
|
||||
"channel": p.channel,
|
||||
"chat_id": p.chatID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
p.published = true
|
||||
}
|
||||
|
||||
func (p *streamingChunkPublisher) UpdateReasoning(ctx context.Context, accumulated string) {
|
||||
if p == nil || p.streamer == nil || strings.TrimSpace(accumulated) == "" {
|
||||
return
|
||||
}
|
||||
if setter, ok := p.streamer.(interface{ SetModelName(modelName string) }); ok {
|
||||
setter.SetModelName(p.modelName)
|
||||
}
|
||||
reasoningStreamer, ok := p.streamer.(bus.ReasoningStreamer)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := reasoningStreamer.UpdateReasoning(ctx, accumulated); err != nil {
|
||||
p.err = err
|
||||
logger.WarnCF("agent", "stream reasoning update failed", map[string]any{
|
||||
"channel": p.channel,
|
||||
"chat_id": p.chatID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
p.reasoningPublished = true
|
||||
}
|
||||
|
||||
func (p *streamingChunkPublisher) Published() bool {
|
||||
return p != nil && p.published
|
||||
}
|
||||
|
||||
func (p *streamingChunkPublisher) ReasoningPublished() bool {
|
||||
return p != nil && p.reasoningPublished
|
||||
}
|
||||
|
||||
func (p *streamingChunkPublisher) Err() error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p.err
|
||||
}
|
||||
|
||||
func (p *streamingChunkPublisher) Finalize(ctx context.Context, content string, contextUsage *bus.ContextUsage) error {
|
||||
if p == nil || p.streamer == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(content) == "" && !p.published {
|
||||
return nil
|
||||
}
|
||||
if setter, ok := p.streamer.(interface{ SetModelName(modelName string) }); ok {
|
||||
setter.SetModelName(p.modelName)
|
||||
}
|
||||
var err error
|
||||
if streamer, ok := p.streamer.(bus.ContextUsageStreamer); ok {
|
||||
err = streamer.FinalizeWithContext(ctx, content, contextUsage)
|
||||
} else {
|
||||
err = p.streamer.Finalize(ctx, content)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("stream finalize: %w", err)
|
||||
}
|
||||
p.published = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *streamingChunkPublisher) FinalizeReasoning(ctx context.Context, content string) error {
|
||||
if p == nil || p.streamer == nil || !p.reasoningPublished || strings.TrimSpace(content) == "" {
|
||||
return nil
|
||||
}
|
||||
reasoningStreamer, ok := p.streamer.(bus.ReasoningStreamer)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := reasoningStreamer.FinalizeReasoning(ctx, content); err != nil {
|
||||
return fmt.Errorf("stream reasoning finalize: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *streamingChunkPublisher) ClearFinalizedStreamMarker() {
|
||||
if p == nil || p.streamer == nil {
|
||||
return
|
||||
}
|
||||
if cleaner, ok := p.streamer.(interface{ ClearFinalizedStreamMarker() }); ok {
|
||||
cleaner.ClearFinalizedStreamMarker()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *streamingChunkPublisher) Cancel(ctx context.Context) {
|
||||
if p == nil || p.streamer == nil {
|
||||
return
|
||||
}
|
||||
p.streamer.Cancel(ctx)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -115,6 +115,13 @@ type PromptBuildRequest struct {
|
||||
|
||||
ActiveSkills []string
|
||||
Overlays []PromptPart
|
||||
|
||||
SuppressDefaultSystemPrompt bool
|
||||
SuppressSkillContext bool
|
||||
SuppressToolUseRule bool
|
||||
AllowedSkills []string
|
||||
AllowedTools []string
|
||||
ToolUseFallback bool
|
||||
}
|
||||
|
||||
type PromptContributor interface {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
type toolDiscoveryPromptContributor struct {
|
||||
@@ -23,9 +25,17 @@ func (c toolDiscoveryPromptContributor) PromptSource() PromptSourceDescriptor {
|
||||
|
||||
func (c toolDiscoveryPromptContributor) ContributePrompt(
|
||||
_ context.Context,
|
||||
_ PromptBuildRequest,
|
||||
req PromptBuildRequest,
|
||||
) ([]PromptPart, error) {
|
||||
content := formatToolDiscoveryRule(c.useBM25, c.useRegex)
|
||||
if req.SuppressToolUseRule {
|
||||
return nil, nil
|
||||
}
|
||||
useBM25 := c.useBM25 && promptAllowsTool(req, tools.BM25SearchToolName)
|
||||
useRegex := c.useRegex && promptAllowsTool(req, tools.RegexSearchToolName)
|
||||
if !useBM25 && !useRegex {
|
||||
return nil, nil
|
||||
}
|
||||
content := formatToolDiscoveryRule(useBM25, useRegex)
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -62,12 +72,19 @@ func (c mcpServerPromptContributor) PromptSource() PromptSourceDescriptor {
|
||||
|
||||
func (c mcpServerPromptContributor) ContributePrompt(
|
||||
_ context.Context,
|
||||
_ PromptBuildRequest,
|
||||
req PromptBuildRequest,
|
||||
) ([]PromptPart, error) {
|
||||
if req.SuppressToolUseRule {
|
||||
return nil, nil
|
||||
}
|
||||
serverName := strings.TrimSpace(c.serverName)
|
||||
if serverName == "" || c.toolCount <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(req.AllowedTools) > 0 &&
|
||||
!promptAllowsToolPrefix(req, "mcp_"+promptSourceComponent(serverName)+"_") {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
availability := "available as native tools"
|
||||
if c.deferred {
|
||||
@@ -110,8 +127,14 @@ func (c agentDiscoveryPromptContributor) PromptSource() PromptSourceDescriptor {
|
||||
|
||||
func (c agentDiscoveryPromptContributor) ContributePrompt(
|
||||
_ context.Context,
|
||||
_ PromptBuildRequest,
|
||||
req PromptBuildRequest,
|
||||
) ([]PromptPart, error) {
|
||||
if req.SuppressToolUseRule {
|
||||
return nil, nil
|
||||
}
|
||||
if !promptAllowsTool(req, "spawn") {
|
||||
return nil, nil
|
||||
}
|
||||
if c.discover == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -178,3 +201,28 @@ func promptSourceComponent(value string) string {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func promptAllowsTool(req PromptBuildRequest, name string) bool {
|
||||
if len(req.AllowedTools) == 0 {
|
||||
return true
|
||||
}
|
||||
allowed := cleanAllowedSet(req.AllowedTools)
|
||||
_, ok := allowed[strings.ToLower(strings.TrimSpace(name))]
|
||||
return ok
|
||||
}
|
||||
|
||||
func promptAllowsToolPrefix(req PromptBuildRequest, prefix string) bool {
|
||||
if len(req.AllowedTools) == 0 {
|
||||
return true
|
||||
}
|
||||
prefix = strings.ToLower(strings.TrimSpace(prefix))
|
||||
if prefix == "" {
|
||||
return false
|
||||
}
|
||||
for _, name := range req.AllowedTools {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(name)), prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -197,6 +197,75 @@ func TestContextBuilder_CollectsToolDiscoveryContributor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_SuppressesToolDiscoveryContributorWhenToolsUnavailable(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
|
||||
cb := NewContextBuilder(t.TempDir()).WithToolDiscovery(true, false)
|
||||
|
||||
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
||||
CurrentMessage: "hello",
|
||||
SuppressToolUseRule: true,
|
||||
})
|
||||
system := messages[0]
|
||||
if strings.Contains(system.Content, "tool_search_tool_bm25") {
|
||||
t.Fatalf("system prompt includes tool discovery despite tools being unavailable: %q", system.Content)
|
||||
}
|
||||
for _, part := range system.SystemParts {
|
||||
if part.PromptSource == string(PromptSourceToolDiscovery) {
|
||||
t.Fatalf("system parts include tool discovery despite tools being unavailable: %#v", part)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_SuppressesToolReferencesWhenToolsUnavailable(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
|
||||
writeTurnProfileSkill(
|
||||
t,
|
||||
workspace,
|
||||
"research",
|
||||
"---\ndescription: research skill\n---\n# research\n\nResearch carefully.",
|
||||
)
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
||||
CurrentMessage: "hello",
|
||||
SuppressToolUseRule: true,
|
||||
})
|
||||
system := messages[0]
|
||||
if strings.Contains(system.Content, "When using tools") ||
|
||||
strings.Contains(system.Content, "read_file tool") ||
|
||||
strings.Contains(system.Content, "update "+workspace+"/memory/MEMORY.md") {
|
||||
t.Fatalf("system prompt includes tool references despite tools being unavailable: %q", system.Content)
|
||||
}
|
||||
if !strings.Contains(system.Content, "<name>research</name>") {
|
||||
t.Fatalf("system prompt should keep non-tool skill catalog context, got: %q", system.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_CustomToolAllowListSuppressesReadFileSkillInstruction(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
|
||||
writeTurnProfileSkill(
|
||||
t,
|
||||
workspace,
|
||||
"research",
|
||||
"---\ndescription: research skill\n---\n# research\n\nResearch carefully.",
|
||||
)
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
||||
CurrentMessage: "hello",
|
||||
AllowedTools: []string{"web_search"},
|
||||
})
|
||||
system := messages[0]
|
||||
if strings.Contains(system.Content, "read_file tool") {
|
||||
t.Fatalf("system prompt includes read_file skill instruction without read_file permission: %q", system.Content)
|
||||
}
|
||||
if !strings.Contains(system.Content, "<name>research</name>") {
|
||||
t.Fatalf("system prompt should keep skill catalog context, got: %q", system.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_CollectsMCPServerContributor(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
|
||||
cb := NewContextBuilder(t.TempDir())
|
||||
@@ -232,6 +301,111 @@ func TestContextBuilder_CollectsMCPServerContributor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_SuppressesMCPServerContributorWhenToolsUnavailable(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
|
||||
cb := NewContextBuilder(t.TempDir())
|
||||
err := cb.RegisterPromptContributor(mcpServerPromptContributor{
|
||||
serverName: "GitHub Server",
|
||||
toolCount: 3,
|
||||
deferred: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPromptContributor() error = %v", err)
|
||||
}
|
||||
|
||||
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
||||
CurrentMessage: "hello",
|
||||
SuppressToolUseRule: true,
|
||||
})
|
||||
system := messages[0]
|
||||
if strings.Contains(system.Content, "MCP server `GitHub Server` is connected") ||
|
||||
strings.Contains(system.Content, "available as native tools") {
|
||||
t.Fatalf("system prompt includes MCP tooling despite tools being unavailable: %q", system.Content)
|
||||
}
|
||||
for _, part := range system.SystemParts {
|
||||
if part.PromptSource == "mcp:github_server" {
|
||||
t.Fatalf("system parts include MCP tooling despite tools being unavailable: %#v", part)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_SuppressesAgentDiscoveryContributorWhenToolsUnavailable(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
|
||||
cb := NewContextBuilder(t.TempDir()).WithAgentDiscovery(
|
||||
"main",
|
||||
func(agentID string) []AgentDescriptor {
|
||||
return []AgentDescriptor{{
|
||||
ID: "helper",
|
||||
Name: "Helper",
|
||||
Description: "Helps with tasks",
|
||||
}}
|
||||
},
|
||||
)
|
||||
|
||||
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
||||
CurrentMessage: "hello",
|
||||
SuppressToolUseRule: true,
|
||||
})
|
||||
system := messages[0]
|
||||
if strings.Contains(system.Content, "Agent Discovery") ||
|
||||
strings.Contains(system.Content, "calling spawn") {
|
||||
t.Fatalf("system prompt includes agent discovery despite tools being unavailable: %q", system.Content)
|
||||
}
|
||||
for _, part := range system.SystemParts {
|
||||
if part.PromptSource == string(PromptSourceAgentDiscovery) {
|
||||
t.Fatalf("system parts include agent discovery despite tools being unavailable: %#v", part)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_CustomToolAllowListSuppressesUnallowedToolContributors(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
|
||||
cb := NewContextBuilder(t.TempDir()).
|
||||
WithToolDiscovery(true, true).
|
||||
WithAgentDiscovery(
|
||||
"main",
|
||||
func(agentID string) []AgentDescriptor {
|
||||
return []AgentDescriptor{{
|
||||
ID: "helper",
|
||||
Name: "Helper",
|
||||
Description: "Helps with tasks",
|
||||
}}
|
||||
},
|
||||
)
|
||||
err := cb.RegisterPromptContributor(mcpServerPromptContributor{
|
||||
serverName: "GitHub Server",
|
||||
toolCount: 3,
|
||||
deferred: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPromptContributor() error = %v", err)
|
||||
}
|
||||
|
||||
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
||||
CurrentMessage: "hello",
|
||||
AllowedTools: []string{"echo_text"},
|
||||
})
|
||||
system := messages[0]
|
||||
blockedSnippets := []string{
|
||||
"tool_search_tool_bm25",
|
||||
"tool_search_tool_regex",
|
||||
"MCP server `GitHub Server` is connected",
|
||||
"Agent Discovery",
|
||||
"calling spawn",
|
||||
}
|
||||
for _, snippet := range blockedSnippets {
|
||||
if strings.Contains(system.Content, snippet) {
|
||||
t.Fatalf("system prompt includes unallowed tool contributor %q: %q", snippet, system.Content)
|
||||
}
|
||||
}
|
||||
for _, part := range system.SystemParts {
|
||||
switch part.PromptSource {
|
||||
case string(PromptSourceToolDiscovery), string(PromptSourceAgentDiscovery), "mcp:github_server":
|
||||
t.Fatalf("system parts include unallowed tool contributor: %#v", part)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type testPromptContributor struct {
|
||||
desc PromptSourceDescriptor
|
||||
part PromptPart
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
@@ -13,8 +14,9 @@ func promptBuildRequestForTurn(
|
||||
summary string,
|
||||
currentMessage string,
|
||||
media []string,
|
||||
cfg *config.Config,
|
||||
) PromptBuildRequest {
|
||||
return PromptBuildRequest{
|
||||
req := PromptBuildRequest{
|
||||
History: history,
|
||||
Summary: summary,
|
||||
CurrentMessage: currentMessage,
|
||||
@@ -26,6 +28,92 @@ func promptBuildRequestForTurn(
|
||||
ActiveSkills: activeSkillNames(ts.agent, ts.opts),
|
||||
Overlays: promptOverlaysForOptions(ts.opts),
|
||||
}
|
||||
hasCallableTools := true
|
||||
if ts.profile.Enabled {
|
||||
hasCallableTools = turnProfileHasCallableTools(ts.profile, ts.agent.Tools.ToProviderDefs()) ||
|
||||
turnProfileNativeSearchCallable(cfg, ts.profile, ts.agent)
|
||||
}
|
||||
if turnProfileSystemPromptOff(ts.profile) {
|
||||
req.SuppressDefaultSystemPrompt = true
|
||||
req.SuppressSkillContext = true
|
||||
req.ToolUseFallback = hasCallableTools
|
||||
}
|
||||
if ts.profile.Enabled && !hasCallableTools {
|
||||
req.SuppressToolUseRule = true
|
||||
}
|
||||
if turnProfileSkillsOff(ts.profile) {
|
||||
req.SuppressSkillContext = true
|
||||
}
|
||||
if turnProfileCustomSkills(ts.profile) {
|
||||
req.AllowedSkills = append([]string(nil), ts.profile.AllowedSkills...)
|
||||
}
|
||||
if ts.profile.Enabled && ts.profile.ToolsMode == config.TurnProfileModeCustom {
|
||||
req.AllowedTools = append([]string(nil), ts.profile.AllowedTools...)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func turnProfileNativeSearchCallable(
|
||||
cfg *config.Config,
|
||||
profile config.EffectiveTurnProfile,
|
||||
agent *AgentInstance,
|
||||
) bool {
|
||||
if cfg == nil || agent == nil {
|
||||
return false
|
||||
}
|
||||
if !cfg.Tools.IsToolEnabled("web") || !cfg.Tools.Web.PreferNative {
|
||||
return false
|
||||
}
|
||||
if !turnProfileToolAllowed(profile, "web_search") {
|
||||
return false
|
||||
}
|
||||
nativeProvider, ok := agent.Provider.(providers.NativeSearchCapable)
|
||||
return ok && nativeProvider.SupportsNativeSearch()
|
||||
}
|
||||
|
||||
func promptBuildRequestForProcessOptions(
|
||||
agent *AgentInstance,
|
||||
opts processOptions,
|
||||
history []providers.Message,
|
||||
summary string,
|
||||
currentMessage string,
|
||||
media []string,
|
||||
) PromptBuildRequest {
|
||||
req := PromptBuildRequest{
|
||||
History: history,
|
||||
Summary: summary,
|
||||
CurrentMessage: currentMessage,
|
||||
Media: append([]string(nil), media...),
|
||||
Channel: opts.Channel,
|
||||
ChatID: opts.ChatID,
|
||||
SenderID: opts.SenderID,
|
||||
SenderDisplayName: opts.SenderDisplayName,
|
||||
ActiveSkills: activeSkillNames(agent, opts),
|
||||
Overlays: promptOverlaysForOptions(opts),
|
||||
}
|
||||
profile := opts.TurnProfile
|
||||
hasCallableTools := true
|
||||
if profile.Enabled && agent != nil {
|
||||
hasCallableTools = turnProfileHasCallableTools(profile, agent.Tools.ToProviderDefs())
|
||||
}
|
||||
if turnProfileSystemPromptOff(profile) {
|
||||
req.SuppressDefaultSystemPrompt = true
|
||||
req.SuppressSkillContext = true
|
||||
req.ToolUseFallback = hasCallableTools
|
||||
}
|
||||
if profile.Enabled && !hasCallableTools {
|
||||
req.SuppressToolUseRule = true
|
||||
}
|
||||
if turnProfileSkillsOff(profile) {
|
||||
req.SuppressSkillContext = true
|
||||
}
|
||||
if turnProfileCustomSkills(profile) {
|
||||
req.AllowedSkills = append([]string(nil), profile.AllowedSkills...)
|
||||
}
|
||||
if profile.Enabled && profile.ToolsMode == config.TurnProfileModeCustom {
|
||||
req.AllowedTools = append([]string(nil), profile.AllowedTools...)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func promptOverlaysForOptions(opts processOptions) []PromptPart {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user