Compare commits
191 commits
fix/build_
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
eb0653074b | ||
|
|
f62de5c0d4 | ||
|
|
e0370aafcc | ||
|
|
56cca3f12f | ||
|
|
87048499ff | ||
|
|
4a81f0e740 | ||
|
|
223ebdf0c7 | ||
|
|
255a67e2da | ||
|
|
777269b429 | ||
|
|
d2c0b69243 | ||
|
|
7dc78425d1 | ||
|
|
b3a7b7ad64 | ||
|
|
b12f03be2e | ||
|
|
894c6251c5 | ||
|
|
306f96cfe3 | ||
|
|
1055e082a4 | ||
|
|
91f024eb1d | ||
|
|
6801cc7ab8 | ||
|
|
09d3dff432 | ||
|
|
6e6293e596 | ||
|
|
f571a142bf | ||
|
|
af901617ac | ||
|
|
2ae25b1038 | ||
|
|
e1ed47b0ff | ||
|
|
8362203631 | ||
|
|
148583e7bb | ||
|
|
a3edbcd05e | ||
|
|
c6a09a35e2 | ||
|
|
ffa184d183 | ||
|
|
8508f80608 | ||
|
|
569939a7b3 | ||
|
|
2287de521e | ||
|
|
871892ff15 | ||
|
|
d5c8bfffbc | ||
|
|
f062cb41d7 | ||
|
|
610e9e3fe8 | ||
|
|
01280eaa53 | ||
|
|
bacb9aba7c | ||
|
|
6d7d1b0909 | ||
|
|
3788e9edad | ||
|
|
c2044e5a2c | ||
|
|
7c8cd7c66a | ||
|
|
f4338d3aab | ||
|
|
b7edd35d13 | ||
|
|
d0ab5aed7a | ||
|
|
1c25dcd239 | ||
|
|
0ac8703e0f | ||
|
|
131f33f084 | ||
|
|
6dd30a0c77 | ||
|
|
7a8d7fb218 | ||
|
|
703f630f33 | ||
|
|
2834db13de | ||
|
|
e948106d50 | ||
|
|
6e8590900b | ||
|
|
b8f4257cee | ||
|
|
96fd887cad | ||
|
|
dd8e247550 | ||
|
|
27bd816b1c | ||
|
|
f1f6e1131b | ||
|
|
6f6270b39d | ||
|
|
41d6156dce | ||
|
|
ad78ba06ea | ||
|
|
9b7fc7aa6c | ||
|
|
e7c0dc821a | ||
|
|
658961b728 | ||
|
|
788cda5c7a | ||
|
|
e304dce40e | ||
|
|
81a050555d | ||
|
|
4d3070e849 | ||
|
|
e3a05bd36d | ||
|
|
0977f59fee | ||
|
|
00742b0196 | ||
|
|
0419497c72 | ||
|
|
864bfa1cef | ||
|
|
c0bc8a3f9d | ||
|
|
96621eff21 | ||
|
|
a7e52e8a25 | ||
|
|
eb4e187550 | ||
|
|
0129da1c8e | ||
|
|
d601b75268 | ||
|
|
5745957429 | ||
|
|
ba4abff4a4 | ||
|
|
a1b55fd4f9 | ||
|
|
d63430ab33 | ||
|
|
71c49812ae | ||
|
|
7a1f5fe8b9 | ||
|
|
057683d94c | ||
|
|
a0245c7b02 | ||
|
|
f3ef7090c5 | ||
|
|
be67aed4dc | ||
|
|
f4a5d6e808 | ||
|
|
330aa297e2 | ||
|
|
4e8bd73a58 | ||
|
|
828a7cba70 | ||
|
|
490d90749c | ||
|
|
272dee3fca | ||
|
|
a94ba82181 | ||
|
|
b00ff5bc5d | ||
|
|
b792d8b77b | ||
|
|
6e1fab80e2 | ||
|
|
b03fa61764 | ||
|
|
ad5232ade8 | ||
|
|
1722cfc282 | ||
|
|
5c0492900e | ||
|
|
23df824c77 | ||
|
|
87ee76b117 | ||
|
|
7b3e800407 | ||
|
|
c731ecdc74 | ||
|
|
cd7717bc15 | ||
|
|
e656ddf5bb | ||
|
|
38baf1ccd0 | ||
|
|
8dca2a1319 | ||
|
|
97b1c3efec | ||
|
|
188ee24d2e | ||
|
|
78fd080189 | ||
|
|
f62e8621fc | ||
|
|
4eeb69688e | ||
|
|
1ff8a418f6 | ||
|
|
4d6337fd26 | ||
|
|
b3d9f86a01 | ||
|
|
f4a24614b8 | ||
|
|
e613258fa5 | ||
|
|
795ee362ea | ||
|
|
b954e6b8dc | ||
|
|
fce800414d | ||
|
|
b2249df3ea | ||
|
|
6e8a81bfbf | ||
|
|
dc80e8f5f2 | ||
|
|
d9717b5632 | ||
|
|
8caf9aeb2b | ||
|
|
eedebabbea | ||
|
|
f0dc709b17 | ||
|
|
4ddd650be4 | ||
|
|
9bc702ebaf | ||
|
|
612097b411 | ||
|
|
bdaff5cb69 | ||
|
|
1b2f8aac79 | ||
|
|
32c8b8ce6a | ||
|
|
d2f6a08981 | ||
|
|
3c4523e7aa | ||
|
|
fc89fea319 | ||
|
|
bcc3d447a1 | ||
|
|
06fad95719 | ||
|
|
9f0f914ad7 | ||
|
|
a34120b821 | ||
|
|
6ee66123f2 | ||
|
|
6db17b8211 | ||
|
|
df486b9939 | ||
|
|
5b0c9e2708 | ||
|
|
039f35563e | ||
|
|
0ff78fa53f | ||
|
|
484ef399f1 | ||
|
|
c8335bfd47 | ||
|
|
c47f5fd2c4 | ||
|
|
fe51cd504f | ||
|
|
765a165475 | ||
|
|
abeb2d8e0a | ||
|
|
f5f1dc9808 | ||
|
|
409251e69d | ||
|
|
847218ef29 | ||
|
|
0ef25f779e | ||
|
|
6429f6af9a | ||
|
|
bca131909d | ||
|
|
07748bf076 | ||
|
|
3b173c0bee |
335 changed files with 43228 additions and 3594 deletions
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
10
.github/workflows/pr.yml
vendored
10
.github/workflows/pr.yml
vendored
|
|
@ -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,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
|
||||
|
|
|
|||
6
Makefile
6
Makefile
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 News
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw on AliExpress!** You can now purchase LicheeRV-Claw from [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), making it easier to try PicoClaw on compact RISC-V hardware.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
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**!
|
||||
|
|
@ -447,7 +455,7 @@ For full provider configuration details, see [Providers & Models](docs/guides/pr
|
|||
|
||||
## 💬 Channels (Chat Apps)
|
||||
|
||||
Talk to your PicoClaw through 18+ messaging platforms:
|
||||
Talk to your PicoClaw through 19+ messaging platforms:
|
||||
|
||||
| Channel | Setup | Protocol | Docs |
|
||||
|---------|-------|----------|------|
|
||||
|
|
@ -465,6 +473,7 @@ Talk to your PicoClaw through 18+ messaging platforms:
|
|||
| **VK** | Easy (group token) | Long Poll | [Guide](docs/channels/vk/README.md) |
|
||||
| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/guides/chat-apps.md#irc) |
|
||||
| **OneBot** | Medium (WebSocket URL) | OneBot v11 | [Guide](docs/channels/onebot/README.md) |
|
||||
| **MQTT** | Easy (broker + agent_id) | MQTT pub/sub | [Guide](docs/channels/mqtt/README.md) |
|
||||
| **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) |
|
||||
| **Pico** | Easy (enable) | Native protocol | Built-in |
|
||||
| **Pico Client** | Easy (WebSocket URL) | WebSocket | Built-in |
|
||||
|
|
@ -484,7 +493,8 @@ 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 |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1000 queries/day | AI-powered, China-optimized |
|
||||
| [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 |
|
||||
| [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search |
|
||||
|
|
@ -617,7 +627,7 @@ For detailed guides beyond this README:
|
|||
| Topic | Description |
|
||||
|-------|-------------|
|
||||
| [Docker & Quick Start](docs/guides/docker.md) | Docker Compose setup, Launcher/Agent modes |
|
||||
| [Chat Apps](docs/guides/chat-apps.md) | All 17+ channel setup guides |
|
||||
| [Chat Apps](docs/guides/chat-apps.md) | All 18+ channel setup guides |
|
||||
| [Configuration](docs/guides/configuration.md) | Environment variables, workspace layout, security sandbox |
|
||||
| [MCP Server CLI](docs/reference/mcp-cli.md) | Add, list, test, edit, and remove MCP server entries from the CLI |
|
||||
| [Scheduled Tasks and Cron Jobs](docs/reference/cron.md) | Cron schedule types, deliver modes, command gates, job storage |
|
||||
|
|
|
|||
BIN
assets/licheerv-claw.jpg
Normal file
BIN
assets/licheerv-claw.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 360 KiB After Width: | Height: | Size: 432 KiB |
|
|
@ -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,17 +177,11 @@ func loadMCPConfigSchema() (*jsonschema.Resolved, error) {
|
|||
}
|
||||
|
||||
func inferTransportType(server config.MCPServerConfig) string {
|
||||
switch server.Type {
|
||||
case "stdio", "http", "sse":
|
||||
return server.Type
|
||||
transport := config.EffectiveMCPTransportType(server)
|
||||
if transport == "" {
|
||||
return "unknown"
|
||||
}
|
||||
if server.URL != "" {
|
||||
return "sse"
|
||||
}
|
||||
if server.Command != "" {
|
||||
return "stdio"
|
||||
}
|
||||
return "unknown"
|
||||
return transport
|
||||
}
|
||||
|
||||
func renderServerTarget(server config.MCPServerConfig) string {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"version": 3,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
|
|
@ -11,6 +12,8 @@
|
|||
"summarize_message_threshold": 20,
|
||||
"summarize_token_percent": 75,
|
||||
"split_on_marker": false,
|
||||
"max_llm_retries": 2,
|
||||
"llm_retry_backoff_secs": 2,
|
||||
"tool_feedback": {
|
||||
"enabled": false,
|
||||
"max_args_length": 300,
|
||||
|
|
@ -18,17 +21,26 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"evolution": {
|
||||
"enabled": false,
|
||||
"mode": "observe",
|
||||
"state_dir": "",
|
||||
"min_task_count": 2,
|
||||
"min_success_ratio": 0.7,
|
||||
"cold_path_trigger": "after_turn",
|
||||
"cold_path_times": []
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"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"
|
||||
},
|
||||
|
|
@ -36,23 +48,24 @@
|
|||
"_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"
|
||||
},
|
||||
{
|
||||
"model_name": "gemini",
|
||||
"_comment": "Optional: set \"tool_schema_transform\": \"simple\" for providers that reject complex tool JSON Schema.",
|
||||
"model": "antigravity/gemini-2.0-flash",
|
||||
"auth_method": "oauth"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
|
|
@ -61,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,
|
||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||
"base_url": "",
|
||||
"proxy": "",
|
||||
"type": "telegram",
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"use_markdown_v2": false,
|
||||
"reasoning_channel_id": "",
|
||||
"streaming": {
|
||||
"enabled": true
|
||||
"settings": {
|
||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||
"base_url": "",
|
||||
"proxy": "",
|
||||
"use_markdown_v2": false,
|
||||
"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,
|
||||
"bridge_url": "ws://localhost:3001",
|
||||
"use_native": false,
|
||||
"session_store_path": "",
|
||||
"type": "whatsapp",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
"reasoning_channel_id": "",
|
||||
"settings": {
|
||||
"bridge_url": "ws://localhost:3001",
|
||||
"use_native": false,
|
||||
"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": "",
|
||||
"random_reaction_emoji": [],
|
||||
"is_lark": false
|
||||
"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
|
||||
|
|
@ -178,68 +211,82 @@
|
|||
"text": ["Thinking...", "Processing...", "Typing..."]
|
||||
},
|
||||
"reasoning_channel_id": "",
|
||||
"crypto_database_path": "",
|
||||
"crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY"
|
||||
"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,
|
||||
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
||||
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_path": "/webhook/line",
|
||||
"type": "line",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
"reasoning_channel_id": "",
|
||||
"settings": {
|
||||
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
||||
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_path": "/webhook/line"
|
||||
}
|
||||
},
|
||||
"onebot": {
|
||||
"enabled": false,
|
||||
"ws_url": "ws://127.0.0.1:3001",
|
||||
"access_token": "",
|
||||
"reconnect_interval": 5,
|
||||
"group_trigger_prefix": [],
|
||||
"type": "onebot",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
"reasoning_channel_id": "",
|
||||
"group_trigger": {
|
||||
"prefixes": []
|
||||
},
|
||||
"settings": {
|
||||
"ws_url": "ws://127.0.0.1:3001",
|
||||
"access_token": "",
|
||||
"reconnect_interval": 5
|
||||
}
|
||||
},
|
||||
"wecom": {
|
||||
"_comment": "WeCom AI Bot over WebSocket.",
|
||||
"enabled": false,
|
||||
"bot_id": "YOUR_BOT_ID",
|
||||
"secret": "YOUR_SECRET",
|
||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||
"send_thinking_message": true,
|
||||
"type": "wecom",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
"reasoning_channel_id": "",
|
||||
"settings": {
|
||||
"bot_id": "YOUR_BOT_ID",
|
||||
"secret": "YOUR_SECRET",
|
||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||
"send_thinking_message": true
|
||||
}
|
||||
},
|
||||
"pico": {
|
||||
"enabled": false,
|
||||
"token": "YOUR_PICO_TOKEN",
|
||||
"allow_token_query": false,
|
||||
"allow_origins": [],
|
||||
"ping_interval": 30,
|
||||
"read_timeout": 60,
|
||||
"max_connections": 100,
|
||||
"allow_from": []
|
||||
"type": "pico",
|
||||
"allow_from": [],
|
||||
"settings": {
|
||||
"token": "YOUR_PICO_TOKEN",
|
||||
"allow_token_query": false,
|
||||
"allow_origins": [],
|
||||
"ping_interval": 30,
|
||||
"read_timeout": 60,
|
||||
"max_connections": 100
|
||||
}
|
||||
},
|
||||
"pico_client": {
|
||||
"enabled": false,
|
||||
"url": "wss://remote-pico-server/pico/ws",
|
||||
"token": "YOUR_PICO_TOKEN",
|
||||
"session_id": "",
|
||||
"ping_interval": 30,
|
||||
"read_timeout": 60,
|
||||
"allow_from": []
|
||||
"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
|
||||
}
|
||||
},
|
||||
"irc": {
|
||||
"enabled": false,
|
||||
"server": "irc.libera.chat:6697",
|
||||
"tls": true,
|
||||
"nick": "mybot",
|
||||
"user": "",
|
||||
"real_name": "",
|
||||
"password": "",
|
||||
"nickserv_password": "",
|
||||
"sasl_user": "",
|
||||
"sasl_password": "",
|
||||
"channels": ["#mychannel"],
|
||||
"request_caps": ["server-time", "message-tags"],
|
||||
"type": "irc",
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": true
|
||||
|
|
@ -247,7 +294,20 @@
|
|||
"typing": {
|
||||
"enabled": false
|
||||
},
|
||||
"reasoning_channel_id": ""
|
||||
"reasoning_channel_id": "",
|
||||
"settings": {
|
||||
"server": "irc.libera.chat:6697",
|
||||
"tls": true,
|
||||
"nick": "mybot",
|
||||
"user": "",
|
||||
"real_name": "",
|
||||
"password": "",
|
||||
"nickserv_password": "",
|
||||
"sasl_user": "",
|
||||
"sasl_password": "",
|
||||
"channels": ["#mychannel"],
|
||||
"request_caps": ["server-time", "message-tags"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
|
|
@ -256,7 +316,6 @@
|
|||
"web": {
|
||||
"enabled": true,
|
||||
"prefer_native": true,
|
||||
"fetch_limit_bytes": 10485760,
|
||||
"format": "plaintext",
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
|
|
@ -279,6 +338,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",
|
||||
|
|
@ -479,6 +544,15 @@
|
|||
"approval_timeout_ms": 60000
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"logging": {
|
||||
"enabled": true,
|
||||
"include": ["agent.*"],
|
||||
"exclude": [],
|
||||
"min_severity": "info",
|
||||
"include_payload": false
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
|
||||
"host": "localhost",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ Internal architecture notes for major runtime mechanisms and subsystem design.
|
|||
- [SubTurn Mechanism](subturn.md): sub-agent coordination, concurrency control, and lifecycle handling.
|
||||
- [Session System](session-system.md): session scope allocation, JSONL persistence, alias compatibility, and migration. ([ZH](session-system.zh.md))
|
||||
- [Routing System](routing-system.md): agent dispatch, session policy selection, and light/heavy model routing. ([ZH](routing-system.zh.md))
|
||||
- [Runtime Events](runtime-events.md): runtime event envelope, centralized event logging, filters, and examples. ([ZH](runtime-events.zh.md))
|
||||
- [Agent Self-Evolution](agent-self-evolution.md): learning records, draft generation, application modes, and state layout.
|
||||
- [Hook System Guide](hooks/README.md): current hook architecture and protocol details.
|
||||
- [Agent Refactor](agent-refactor/README.md): notes and checkpoints for the agent refactor work.
|
||||
|
||||
|
|
|
|||
47
docs/architecture/agent-self-evolution.md
Normal file
47
docs/architecture/agent-self-evolution.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Agent Self-Evolution
|
||||
|
||||
Agent self-evolution lets PicoClaw learn from completed turns and turn repeated successful behavior into skill improvements. The runtime is controlled by the top-level `evolution` config block.
|
||||
|
||||
## Flow
|
||||
|
||||
The hot path runs at the end of an agent turn. When `evolution.enabled` is true, it records a learning record with the turn summary, success state, used skills, tool executions, and session/workspace metadata. Heartbeat turns are skipped.
|
||||
|
||||
The cold path groups related task records, checks the configured success threshold, and prepares skill drafts for patterns that have enough evidence. Drafts can target new skills or append/replace/merge existing workspace skills.
|
||||
|
||||
The apply path validates generated `SKILL.md` content before writing. Invalid drafts are rejected before a skill directory or file is created.
|
||||
|
||||
## Safety Considerations
|
||||
|
||||
Evolution creates a persistent feedback loop: user input can become a task record, task records can be clustered into an LLM-generated draft, and an accepted draft can become `SKILL.md` content that is loaded into future agent prompts. Treat generated skill content as prompt-sensitive material, especially in `apply` mode.
|
||||
|
||||
The current local scanner is a narrow guardrail, not a complete safety boundary. It rejects structurally invalid drafts and a small set of obvious secret-like substrings, but it does not reliably detect prompt injection, unsafe instructions, or every form of sensitive data. Use `observe` or `draft` when human review is required before skill changes reach disk.
|
||||
|
||||
In `apply` mode, accepted drafts can update workspace skills automatically. Existing skills are backed up before replacement, but recovery is manual: an operator must restore the desired backup if an applied skill should be rolled back.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `observe` | Record learning data only. No cold-path draft generation runs automatically. |
|
||||
| `draft` | Record learning data and generate candidate skill drafts when the cold path runs. |
|
||||
| `apply` | Generate drafts and allow accepted drafts to update workspace skills. |
|
||||
|
||||
When `evolution.enabled` is false, `mode` is treated as disabled at runtime.
|
||||
|
||||
## Cold Path Trigger
|
||||
|
||||
`cold_path_trigger` only matters in `draft` and `apply` modes.
|
||||
|
||||
| Trigger | Behavior |
|
||||
|---------|----------|
|
||||
| `after_turn` | Run the cold path after eligible turns. |
|
||||
| `scheduled` | Run the cold path at configured `cold_path_times`. |
|
||||
| `manual` | Do not run automatically. There is no user-facing Web/API/CLI trigger yet; code can still invoke `Runtime.RunColdPathOnce`. |
|
||||
|
||||
`cold_path_times` uses `HH:MM` strings and is ignored unless the trigger is `scheduled`.
|
||||
|
||||
## State
|
||||
|
||||
By default, evolution state is stored under the workspace. `state_dir` can redirect that state to another directory. The state includes learning records, clustered pattern records, drafts, and skill profiles.
|
||||
|
||||
For user-facing configuration fields, see the [Configuration Guide](../guides/configuration.md#agent-self-evolution).
|
||||
|
|
@ -13,7 +13,7 @@ The repository no longer ships standalone example source files. The Go and Pytho
|
|||
|
||||
| Type | Interface | Stage | Can modify data |
|
||||
| --- | --- | --- | --- |
|
||||
| Observer | `EventObserver` | EventBus broadcast | No |
|
||||
| Observer | `RuntimeEventObserver` | Runtime event bus broadcast | No |
|
||||
| LLM interceptor | `LLMInterceptor` | `before_llm` / `after_llm` | Yes |
|
||||
| Tool interceptor | `ToolInterceptor` | `before_tool` / `after_tool` | Yes |
|
||||
| Tool approver | `ToolApprover` | `approve_tool` | No, returns allow/deny |
|
||||
|
|
@ -136,9 +136,9 @@ Example:
|
|||
"/tmp/review_gate.py"
|
||||
],
|
||||
"observe": [
|
||||
"tool_exec_start",
|
||||
"tool_exec_end",
|
||||
"tool_exec_skipped"
|
||||
"agent.tool.exec_start",
|
||||
"agent.tool.exec_end",
|
||||
"agent.tool.exec_skipped"
|
||||
],
|
||||
"intercept": [
|
||||
"before_tool",
|
||||
|
|
@ -174,7 +174,7 @@ Both examples are intentionally safe: they only log, never rewrite, and never de
|
|||
|
||||
The following is a minimal logging hook for in-process use. It implements:
|
||||
|
||||
1. `EventObserver`
|
||||
1. `RuntimeEventObserver`
|
||||
2. `LLMInterceptor`
|
||||
3. `ToolInterceptor`
|
||||
4. `ToolApprover`
|
||||
|
|
@ -196,6 +196,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
|
|
@ -217,12 +218,12 @@ func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook {
|
|||
}
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error {
|
||||
func (h *ExampleLoggerHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error {
|
||||
_ = ctx
|
||||
if h == nil || !h.logEvents {
|
||||
return nil
|
||||
}
|
||||
h.record("event", evt.Meta, map[string]any{
|
||||
h.record("event", evt.Scope, map[string]any{
|
||||
"event": evt.Kind.String(),
|
||||
"payload": evt.Payload,
|
||||
}, nil)
|
||||
|
|
@ -275,7 +276,7 @@ func (h *ExampleLoggerHook) ApproveTool(
|
|||
return decision, nil
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) {
|
||||
func (h *ExampleLoggerHook) record(stage string, refs any, payload any, decision any) {
|
||||
logger.InfoCF("hooks", "Example hook observed", map[string]any{
|
||||
"stage": stage,
|
||||
})
|
||||
|
|
@ -286,7 +287,7 @@ func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload a
|
|||
entry := map[string]any{
|
||||
"ts": time.Now().UTC(),
|
||||
"stage": stage,
|
||||
"meta": meta,
|
||||
"refs": refs,
|
||||
"payload": payload,
|
||||
"decision": decision,
|
||||
}
|
||||
|
|
@ -428,7 +429,7 @@ If you only see `before_llm` and `after_llm`, that usually means the request did
|
|||
The following script is a minimal process-hook example. It uses only the Python standard library and supports:
|
||||
|
||||
1. `hook.hello`
|
||||
2. `hook.event`
|
||||
2. `hook.runtime_event`
|
||||
3. `hook.before_tool`
|
||||
4. `hook.approve_tool`
|
||||
|
||||
|
|
@ -564,8 +565,8 @@ def main() -> int:
|
|||
})
|
||||
|
||||
if not message_id:
|
||||
if method == "hook.event" and LOG_EVENTS:
|
||||
log_stderr(f"observed event: {params.get('Kind')}")
|
||||
if method == "hook.runtime_event" and LOG_EVENTS:
|
||||
log_stderr(f"observed event: {params.get('kind')}")
|
||||
continue
|
||||
|
||||
try:
|
||||
|
|
@ -606,9 +607,9 @@ if __name__ == "__main__":
|
|||
"/abs/path/to/review_gate.py"
|
||||
],
|
||||
"observe": [
|
||||
"tool_exec_start",
|
||||
"tool_exec_end",
|
||||
"tool_exec_skipped"
|
||||
"agent.tool.exec_start",
|
||||
"agent.tool.exec_end",
|
||||
"agent.tool.exec_skipped"
|
||||
],
|
||||
"intercept": [
|
||||
"before_tool",
|
||||
|
|
@ -626,7 +627,7 @@ if __name__ == "__main__":
|
|||
### Environment Variables
|
||||
|
||||
- `PICOCLAW_HOOK_LOG_EVENTS`
|
||||
Whether to write `hook.event` summaries to `stderr`, enabled by default
|
||||
Whether to write `hook.runtime_event` summaries to `stderr`, enabled by default
|
||||
- `PICOCLAW_HOOK_LOG_FILE`
|
||||
Path to an external log file. When set, the script appends inbound hook requests, notifications, and outbound responses as JSON Lines
|
||||
|
||||
|
|
@ -645,7 +646,7 @@ Typical interpretation:
|
|||
|
||||
- Only `hook.hello`
|
||||
The process started and completed the handshake, but no business hook request has arrived yet
|
||||
- `hook.event`
|
||||
- `hook.runtime_event`
|
||||
The `observe` configuration is working
|
||||
- `hook.before_tool`
|
||||
The `intercept: ["before_tool", ...]` configuration is working
|
||||
|
|
@ -664,7 +665,7 @@ A complete sample:
|
|||
```json
|
||||
{"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false}
|
||||
{"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.event","params":{"Kind":"tool_exec_start"},"notification":true}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.runtime_event","params":{"kind":"agent.tool.exec_start"},"notification":true}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null}
|
||||
```
|
||||
|
|
@ -672,7 +673,7 @@ A complete sample:
|
|||
Additional notes:
|
||||
|
||||
- Timestamps are UTC
|
||||
- `notification=true` means it was a notification such as `hook.event`, which does not expect a response
|
||||
- `notification=true` means it was a notification such as `hook.runtime_event`, which does not expect a response
|
||||
- `id` increases within a single hook process; if the process restarts, the counter starts over
|
||||
|
||||
## Process-Hook Protocol
|
||||
|
|
@ -681,7 +682,7 @@ Current process hooks use `JSON-RPC over stdio`:
|
|||
|
||||
- PicoClaw starts the external process
|
||||
- Requests and responses are exchanged as one JSON message per line
|
||||
- `hook.event` is a notification and does not need a response
|
||||
- `hook.runtime_event` is a notification and does not need a response
|
||||
- `hook.before_llm`, `hook.after_llm`, `hook.before_tool`, `hook.after_tool`, and `hook.approve_tool` are request/response calls
|
||||
|
||||
The host does not currently accept new RPCs initiated by the process hook. In practice, that means an external hook can only respond to PicoClaw calls; it cannot call back into the host to send channel messages.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
| 类型 | 接口 | 作用阶段 | 能否改写 |
|
||||
| --- | --- | --- | --- |
|
||||
| 观察型 | `EventObserver` | EventBus 广播事件时 | 否 |
|
||||
| 观察型 | `RuntimeEventObserver` | runtime event bus 广播事件时 | 否 |
|
||||
| LLM 拦截型 | `LLMInterceptor` | `before_llm` / `after_llm` | 是 |
|
||||
| Tool 拦截型 | `ToolInterceptor` | `before_tool` / `after_tool` | 是 |
|
||||
| Tool 审批型 | `ToolApprover` | `approve_tool` | 否,返回批准/拒绝 |
|
||||
|
|
@ -136,9 +136,9 @@ HookManager 的排序规则是:
|
|||
"/tmp/review_gate.py"
|
||||
],
|
||||
"observe": [
|
||||
"tool_exec_start",
|
||||
"tool_exec_end",
|
||||
"tool_exec_skipped"
|
||||
"agent.tool.exec_start",
|
||||
"agent.tool.exec_end",
|
||||
"agent.tool.exec_skipped"
|
||||
],
|
||||
"intercept": [
|
||||
"before_tool",
|
||||
|
|
@ -174,7 +174,7 @@ tail -f /tmp/picoclaw-hook-review-gate.log
|
|||
|
||||
下面这段代码是一个最小的“记录型” in-process hook。它实现了:
|
||||
|
||||
1. `EventObserver`
|
||||
1. `RuntimeEventObserver`
|
||||
2. `LLMInterceptor`
|
||||
3. `ToolInterceptor`
|
||||
4. `ToolApprover`
|
||||
|
|
@ -196,6 +196,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
|
|
@ -217,12 +218,12 @@ func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook {
|
|||
}
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error {
|
||||
func (h *ExampleLoggerHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error {
|
||||
_ = ctx
|
||||
if h == nil || !h.logEvents {
|
||||
return nil
|
||||
}
|
||||
h.record("event", evt.Meta, map[string]any{
|
||||
h.record("event", evt.Scope, map[string]any{
|
||||
"event": evt.Kind.String(),
|
||||
"payload": evt.Payload,
|
||||
}, nil)
|
||||
|
|
@ -275,7 +276,7 @@ func (h *ExampleLoggerHook) ApproveTool(
|
|||
return decision, nil
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) {
|
||||
func (h *ExampleLoggerHook) record(stage string, refs any, payload any, decision any) {
|
||||
logger.InfoCF("hooks", "Example hook observed", map[string]any{
|
||||
"stage": stage,
|
||||
})
|
||||
|
|
@ -286,7 +287,7 @@ func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload a
|
|||
entry := map[string]any{
|
||||
"ts": time.Now().UTC(),
|
||||
"stage": stage,
|
||||
"meta": meta,
|
||||
"refs": refs,
|
||||
"payload": payload,
|
||||
"decision": decision,
|
||||
}
|
||||
|
|
@ -428,7 +429,7 @@ func init() {
|
|||
下面这段脚本是一个最小的 `process hook` 示例。它只使用 Python 标准库,支持:
|
||||
|
||||
1. `hook.hello`
|
||||
2. `hook.event`
|
||||
2. `hook.runtime_event`
|
||||
3. `hook.before_tool`
|
||||
4. `hook.approve_tool`
|
||||
|
||||
|
|
@ -564,8 +565,8 @@ def main() -> int:
|
|||
})
|
||||
|
||||
if not message_id:
|
||||
if method == "hook.event" and LOG_EVENTS:
|
||||
log_stderr(f"observed event: {params.get('Kind')}")
|
||||
if method == "hook.runtime_event" and LOG_EVENTS:
|
||||
log_stderr(f"observed event: {params.get('kind')}")
|
||||
continue
|
||||
|
||||
try:
|
||||
|
|
@ -606,9 +607,9 @@ if __name__ == "__main__":
|
|||
"/abs/path/to/review_gate.py"
|
||||
],
|
||||
"observe": [
|
||||
"tool_exec_start",
|
||||
"tool_exec_end",
|
||||
"tool_exec_skipped"
|
||||
"agent.tool.exec_start",
|
||||
"agent.tool.exec_end",
|
||||
"agent.tool.exec_skipped"
|
||||
],
|
||||
"intercept": [
|
||||
"before_tool",
|
||||
|
|
@ -626,7 +627,7 @@ if __name__ == "__main__":
|
|||
### 环境变量
|
||||
|
||||
- `PICOCLAW_HOOK_LOG_EVENTS`
|
||||
是否把 `hook.event` 写到 `stderr`,默认开启
|
||||
是否把 `hook.runtime_event` 写到 `stderr`,默认开启
|
||||
- `PICOCLAW_HOOK_LOG_FILE`
|
||||
外部日志文件路径。设置后,脚本会把收到的 hook 请求、notification 和返回结果按 JSON Lines 追加到该文件
|
||||
|
||||
|
|
@ -645,7 +646,7 @@ if __name__ == "__main__":
|
|||
|
||||
- 只看到 `hook.hello`
|
||||
说明进程启动并完成握手了,但还没有新的业务 hook 请求真正打进来
|
||||
- 看到 `hook.event`
|
||||
- 看到 `hook.runtime_event`
|
||||
说明 `observe` 配置生效了
|
||||
- 看到 `hook.before_tool`
|
||||
说明 `intercept: ["before_tool", ...]` 生效了
|
||||
|
|
@ -664,7 +665,7 @@ if __name__ == "__main__":
|
|||
```json
|
||||
{"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false}
|
||||
{"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.event","params":{"Kind":"tool_exec_start"},"notification":true}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.runtime_event","params":{"kind":"agent.tool.exec_start"},"notification":true}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null}
|
||||
```
|
||||
|
|
@ -672,7 +673,7 @@ if __name__ == "__main__":
|
|||
补充说明:
|
||||
|
||||
- 时间戳是 UTC,不是本地时区
|
||||
- `notification=true` 表示这是 `hook.event` 这类不需要响应的通知
|
||||
- `notification=true` 表示这是 `hook.runtime_event` 这类不需要响应的通知
|
||||
- `id` 会随着当前进程内的请求递增;如果 hook 进程重启,计数会重新开始
|
||||
|
||||
## Process Hook 协议约定
|
||||
|
|
@ -681,7 +682,7 @@ if __name__ == "__main__":
|
|||
|
||||
- PicoClaw 启动外部进程
|
||||
- 请求和响应都按“一行一个 JSON 消息”传输
|
||||
- `hook.event` 是 notification,不需要响应
|
||||
- `hook.runtime_event` 是 notification,不需要响应
|
||||
- `hook.before_llm` / `hook.after_llm` / `hook.before_tool` / `hook.after_tool` / `hook.approve_tool` 是 request/response
|
||||
|
||||
当前宿主不会接受 process hook 主动发起的新 RPC。也就是说,外部 hook 现在只能“响应 PicoClaw 的调用”,不能反向调用宿主去发送 channel 消息。
|
||||
|
|
|
|||
|
|
@ -437,21 +437,28 @@ Approval hook for deciding whether to allow execution of sensitive tools.
|
|||
|
||||
---
|
||||
|
||||
## 7. `hook.event` (notification)
|
||||
## 7. `hook.runtime_event` (notification)
|
||||
|
||||
Observer event, broadcast only, no response required. `id` is `0` or absent.
|
||||
Runtime observer event, broadcast only, no response required. `id` is `0` or absent.
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "hook.event",
|
||||
"method": "hook.runtime_event",
|
||||
"params": {
|
||||
"Kind": "tool_exec_start",
|
||||
"Meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1"
|
||||
"kind": "agent.tool.exec_start",
|
||||
"source": {
|
||||
"component": "agent",
|
||||
"name": "agent-1"
|
||||
},
|
||||
"Payload": {
|
||||
"scope": {
|
||||
"agent_id": "agent-1",
|
||||
"session_key": "session-1",
|
||||
"turn_id": "turn-1",
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
},
|
||||
"payload": {
|
||||
"Tool": "echo_text",
|
||||
"Arguments": {"text": "hello"}
|
||||
}
|
||||
|
|
@ -460,12 +467,14 @@ Observer event, broadcast only, no response required. `id` is `0` or absent.
|
|||
```
|
||||
|
||||
Common `Kind` values:
|
||||
- `turn_start` / `turn_end`
|
||||
- `llm_request` / `llm_response`
|
||||
- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped`
|
||||
- `steering_injected`
|
||||
- `interrupt_received`
|
||||
- `error`
|
||||
- `agent.turn.start` / `agent.turn.end`
|
||||
- `agent.llm.request` / `agent.llm.response`
|
||||
- `agent.tool.exec_start` / `agent.tool.exec_end` / `agent.tool.exec_skipped`
|
||||
- `agent.steering.injected`
|
||||
- `agent.interrupt.received`
|
||||
- `agent.error`
|
||||
|
||||
Legacy observe configuration names such as `turn_end` and `tool_exec_start` are still accepted and normalized to runtime event names. New process hook notifications use `hook.runtime_event`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -513,7 +522,7 @@ Standard flow for plugin tool injection:
|
|||
```python
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
tools = params.get("tools", [])
|
||||
|
||||
|
||||
# Add plugin tool definition
|
||||
tools.append({
|
||||
"type": "function",
|
||||
|
|
@ -529,7 +538,7 @@ def handle_before_llm(params: dict) -> dict:
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
|
|
@ -546,12 +555,12 @@ def handle_before_llm(params: dict) -> dict:
|
|||
```python
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
|
||||
|
||||
if tool == "my_plugin_tool":
|
||||
# Implement tool logic here
|
||||
args = params.get("arguments", {})
|
||||
input_text = args.get("input", "")
|
||||
|
||||
|
||||
# Return result directly, no need to register in ToolRegistry
|
||||
return {
|
||||
"action": "respond",
|
||||
|
|
@ -561,8 +570,8 @@ def handle_before_tool(params: dict) -> dict:
|
|||
"is_error": False
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
This way, external hooks can fully implement plugin tools without registering any tool implementation inside PicoClaw.
|
||||
This way, external hooks can fully implement plugin tools without registering any tool implementation inside PicoClaw.
|
||||
|
|
|
|||
|
|
@ -437,21 +437,28 @@
|
|||
|
||||
---
|
||||
|
||||
## 7. `hook.event`(notification)
|
||||
## 7. `hook.runtime_event`(notification)
|
||||
|
||||
观察型事件,仅广播,无需响应。`id` 为 `0` 或不存在。
|
||||
runtime 观察型事件,仅广播,无需响应。`id` 为 `0` 或不存在。
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "hook.event",
|
||||
"method": "hook.runtime_event",
|
||||
"params": {
|
||||
"Kind": "tool_exec_start",
|
||||
"Meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1"
|
||||
"kind": "agent.tool.exec_start",
|
||||
"source": {
|
||||
"component": "agent",
|
||||
"name": "agent-1"
|
||||
},
|
||||
"Payload": {
|
||||
"scope": {
|
||||
"agent_id": "agent-1",
|
||||
"session_key": "session-1",
|
||||
"turn_id": "turn-1",
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
},
|
||||
"payload": {
|
||||
"Tool": "echo_text",
|
||||
"Arguments": {"text": "hello"}
|
||||
}
|
||||
|
|
@ -460,12 +467,14 @@
|
|||
```
|
||||
|
||||
常见 `Kind` 值:
|
||||
- `turn_start` / `turn_end`
|
||||
- `llm_request` / `llm_response`
|
||||
- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped`
|
||||
- `steering_injected`
|
||||
- `interrupt_received`
|
||||
- `error`
|
||||
- `agent.turn.start` / `agent.turn.end`
|
||||
- `agent.llm.request` / `agent.llm.response`
|
||||
- `agent.tool.exec_start` / `agent.tool.exec_end` / `agent.tool.exec_skipped`
|
||||
- `agent.steering.injected`
|
||||
- `agent.interrupt.received`
|
||||
- `agent.error`
|
||||
|
||||
旧 observe 配置名如 `turn_end`、`tool_exec_start` 仍然可用,并会归一化为 runtime event 名称。新的 process hook 通知使用 `hook.runtime_event`。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -513,7 +522,7 @@
|
|||
```python
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
tools = params.get("tools", [])
|
||||
|
||||
|
||||
# 添加插件工具定义
|
||||
tools.append({
|
||||
"type": "function",
|
||||
|
|
@ -529,7 +538,7 @@ def handle_before_llm(params: dict) -> dict:
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
|
|
@ -546,12 +555,12 @@ def handle_before_llm(params: dict) -> dict:
|
|||
```python
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
|
||||
|
||||
if tool == "my_plugin_tool":
|
||||
# 在这里实现工具逻辑
|
||||
args = params.get("arguments", {})
|
||||
input_text = args.get("input", "")
|
||||
|
||||
|
||||
# 直接返回结果,无需在 ToolRegistry 注册
|
||||
return {
|
||||
"action": "respond",
|
||||
|
|
@ -561,8 +570,8 @@ def handle_before_tool(params: dict) -> dict:
|
|||
"is_error": False
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
通过这种方式,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具实现。
|
||||
通过这种方式,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具实现。
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ def handle_hello(params: dict) -> dict:
|
|||
def handle_before_llm(params: dict) -> dict:
|
||||
"""Inject weather query tool definition"""
|
||||
tools = params.get("tools", [])
|
||||
|
||||
|
||||
# Add weather query tool
|
||||
tools.append({
|
||||
"type": "function",
|
||||
|
|
@ -86,7 +86,7 @@ def handle_before_llm(params: dict) -> dict:
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
|
|
@ -102,17 +102,17 @@ def handle_before_tool(params: dict) -> dict:
|
|||
"""Handle tool call, return result directly"""
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
|
||||
if tool == "get_weather":
|
||||
city = args.get("city", "")
|
||||
result = get_weather(city)
|
||||
|
||||
|
||||
# Use respond action to return result directly, skip ToolRegistry
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": result,
|
||||
}
|
||||
|
||||
|
||||
# Other tools continue normal flow
|
||||
return {"action": "continue"}
|
||||
|
||||
|
|
@ -142,7 +142,7 @@ def send_response(message_id: int, result: Any | None = None, error: str | None
|
|||
payload["error"] = {"code": -32000, "message": error}
|
||||
else:
|
||||
payload["result"] = result if result is not None else {}
|
||||
|
||||
|
||||
sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
|
@ -152,19 +152,19 @@ def main() -> int:
|
|||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
method = message.get("method")
|
||||
message_id = message.get("id", 0)
|
||||
params = message.get("params") or {}
|
||||
|
||||
|
||||
if not message_id:
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
result = handle_request(str(method or ""), params)
|
||||
send_response(int(message_id), result=result)
|
||||
|
|
@ -172,7 +172,7 @@ def main() -> int:
|
|||
send_response(int(message_id), error=str(exc))
|
||||
except Exception as exc:
|
||||
send_response(int(message_id), error=f"unexpected error: {exc}")
|
||||
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
|
|
@ -375,7 +375,7 @@ Multiple tools can be injected simultaneously:
|
|||
```python
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
tools = params.get("tools", [])
|
||||
|
||||
|
||||
# Tool 1: Weather query
|
||||
tools.append({
|
||||
"type": "function",
|
||||
|
|
@ -391,7 +391,7 @@ def handle_before_llm(params: dict) -> dict:
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# Tool 2: Calculator
|
||||
tools.append({
|
||||
"type": "function",
|
||||
|
|
@ -407,7 +407,7 @@ def handle_before_llm(params: dict) -> dict:
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
|
|
@ -422,13 +422,13 @@ def handle_before_llm(params: dict) -> dict:
|
|||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
|
||||
if tool == "get_weather":
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": get_weather(args.get("city", "")),
|
||||
}
|
||||
|
||||
|
||||
if tool == "calculate":
|
||||
# Simple calculation example
|
||||
try:
|
||||
|
|
@ -451,7 +451,7 @@ def handle_before_tool(params: dict) -> dict:
|
|||
"is_error": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
|
|
@ -504,7 +504,7 @@ func (h *WeatherPluginHook) BeforeLLM(
|
|||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
return req, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
|
|
@ -514,7 +514,7 @@ func (h *WeatherPluginHook) BeforeTool(
|
|||
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
|
||||
if call.Tool == "get_weather" {
|
||||
city := call.Arguments["city"].(string)
|
||||
|
||||
|
||||
// Set HookResult, use respond action
|
||||
next := call.Clone()
|
||||
next.HookResult = &tools.ToolResult{
|
||||
|
|
@ -522,10 +522,10 @@ func (h *WeatherPluginHook) BeforeTool(
|
|||
Silent: false,
|
||||
IsError: false,
|
||||
}
|
||||
|
||||
|
||||
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
|
||||
}
|
||||
|
||||
|
||||
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
|
|
@ -572,14 +572,14 @@ This means:
|
|||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
|
||||
# Security check: only handle plugin tools
|
||||
if tool in ["get_weather", "calculate"]:
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": execute_plugin_tool(tool, args),
|
||||
}
|
||||
|
||||
|
||||
# Other tools continue normal flow (will go through approval)
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ def handle_hello(params: dict) -> dict:
|
|||
def handle_before_llm(params: dict) -> dict:
|
||||
"""注入天气查询工具定义"""
|
||||
tools = params.get("tools", [])
|
||||
|
||||
|
||||
# 添加天气查询工具
|
||||
tools.append({
|
||||
"type": "function",
|
||||
|
|
@ -86,7 +86,7 @@ def handle_before_llm(params: dict) -> dict:
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
|
|
@ -102,17 +102,17 @@ def handle_before_tool(params: dict) -> dict:
|
|||
"""处理工具调用,直接返回结果"""
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
|
||||
if tool == "get_weather":
|
||||
city = args.get("city", "")
|
||||
result = get_weather(city)
|
||||
|
||||
|
||||
# 使用 respond action 直接返回结果,跳过 ToolRegistry
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": result,
|
||||
}
|
||||
|
||||
|
||||
# 其他工具继续正常流程
|
||||
return {"action": "continue"}
|
||||
|
||||
|
|
@ -142,7 +142,7 @@ def send_response(message_id: int, result: Any | None = None, error: str | None
|
|||
payload["error"] = {"code": -32000, "message": error}
|
||||
else:
|
||||
payload["result"] = result if result is not None else {}
|
||||
|
||||
|
||||
sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
|
@ -152,19 +152,19 @@ def main() -> int:
|
|||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
method = message.get("method")
|
||||
message_id = message.get("id", 0)
|
||||
params = message.get("params") or {}
|
||||
|
||||
|
||||
if not message_id:
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
result = handle_request(str(method or ""), params)
|
||||
send_response(int(message_id), result=result)
|
||||
|
|
@ -172,7 +172,7 @@ def main() -> int:
|
|||
send_response(int(message_id), error=str(exc))
|
||||
except Exception as exc:
|
||||
send_response(int(message_id), error=f"unexpected error: {exc}")
|
||||
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
|
|
@ -375,7 +375,7 @@ media://<store-id>
|
|||
```python
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
tools = params.get("tools", [])
|
||||
|
||||
|
||||
# 工具1:天气查询
|
||||
tools.append({
|
||||
"type": "function",
|
||||
|
|
@ -391,7 +391,7 @@ def handle_before_llm(params: dict) -> dict:
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# 工具2:计算器
|
||||
tools.append({
|
||||
"type": "function",
|
||||
|
|
@ -407,7 +407,7 @@ def handle_before_llm(params: dict) -> dict:
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
|
|
@ -422,13 +422,13 @@ def handle_before_llm(params: dict) -> dict:
|
|||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
|
||||
if tool == "get_weather":
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": get_weather(args.get("city", "")),
|
||||
}
|
||||
|
||||
|
||||
if tool == "calculate":
|
||||
# 简单计算示例
|
||||
try:
|
||||
|
|
@ -451,7 +451,7 @@ def handle_before_tool(params: dict) -> dict:
|
|||
"is_error": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
|
|
@ -504,7 +504,7 @@ func (h *WeatherPluginHook) BeforeLLM(
|
|||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
return req, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
|
|
@ -514,7 +514,7 @@ func (h *WeatherPluginHook) BeforeTool(
|
|||
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
|
||||
if call.Tool == "get_weather" {
|
||||
city := call.Arguments["city"].(string)
|
||||
|
||||
|
||||
// 设置 HookResult,使用 respond action
|
||||
next := call.Clone()
|
||||
next.HookResult = &tools.ToolResult{
|
||||
|
|
@ -522,10 +522,10 @@ func (h *WeatherPluginHook) BeforeTool(
|
|||
Silent: false,
|
||||
IsError: false,
|
||||
}
|
||||
|
||||
|
||||
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
|
||||
}
|
||||
|
||||
|
||||
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
|
|
@ -572,14 +572,14 @@ func getWeatherData(city string) string {
|
|||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
|
||||
# 安全检查:只处理插件工具
|
||||
if tool in ["get_weather", "calculate"]:
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": execute_plugin_tool(tool, args),
|
||||
}
|
||||
|
||||
|
||||
# 其他工具继续正常流程(会经过审批)
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
|
|
|||
216
docs/architecture/runtime-events.md
Normal file
216
docs/architecture/runtime-events.md
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
# Runtime Events And Event Logging
|
||||
|
||||
PicoClaw runtime events are the read-only observation surface for agent, channel, gateway, message bus, and MCP activity. Publishing events and printing logs are separate responsibilities:
|
||||
|
||||
- Event publishing: components publish `pkg/events.Event` values to the runtime event bus for hooks, tests, diagnostics, and future UI consumers.
|
||||
- Event logging: the built-in runtime event logger subscribes to the same bus and prints only the events selected by configuration.
|
||||
|
||||
This keeps runtime code focused on publishing events while log policy stays centralized.
|
||||
|
||||
## Default Behavior
|
||||
|
||||
By default, only `agent.*` events are printed:
|
||||
|
||||
```json
|
||||
{
|
||||
"events": {
|
||||
"logging": {
|
||||
"enabled": true,
|
||||
"include": ["agent.*"],
|
||||
"min_severity": "info",
|
||||
"include_payload": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This preserves the previous behavior: agent turn, LLM, tool, steering, subturn, and error events appear in logs. Channel, gateway, bus, and MCP events are still published to the runtime event bus, but they are not printed unless configured.
|
||||
|
||||
## Configuration
|
||||
|
||||
The configuration lives under `events.logging` in `config.json`:
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
| ----- | ---- | ------- | ----------- |
|
||||
| `enabled` | bool | `true` | Enables the built-in event logger subscription |
|
||||
| `include` | string[] | `["agent.*"]` | Event kinds to print; supports exact matches, `*`, and patterns such as `agent.*` |
|
||||
| `exclude` | string[] | `[]` | Event kinds to suppress after include matching |
|
||||
| `min_severity` | string | `info` | Minimum severity: `debug`, `info`, `warn`, or `error` |
|
||||
| `include_payload` | bool | `false` | Adds raw event payloads to log fields |
|
||||
|
||||
`include_payload` is disabled by default. Agent events print safe summary fields such as `user_len`, `args_count`, and `content_len` instead of full user messages or tool arguments. Enable raw payload logging only for short-lived diagnostics in a trusted log environment.
|
||||
|
||||
## Matching Rules
|
||||
|
||||
`include` and `exclude` match the `Event.Kind` string:
|
||||
|
||||
```json
|
||||
{
|
||||
"events": {
|
||||
"logging": {
|
||||
"include": ["gateway.*", "channel.lifecycle.*", "agent.error"],
|
||||
"exclude": ["gateway.ready"],
|
||||
"min_severity": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Common patterns:
|
||||
|
||||
- `["agent.*"]`: print agent events only.
|
||||
- `["*"]`: print all runtime events.
|
||||
- `["gateway.*", "channel.*"]`: print gateway and channel events only.
|
||||
- `exclude: ["agent.llm.delta"]`: suppress high-volume streaming delta events.
|
||||
- `min_severity: "warn"`: print warn and error events only.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The same settings can be overridden with environment variables:
|
||||
|
||||
```bash
|
||||
PICOCLAW_EVENTS_LOGGING_ENABLED=true
|
||||
PICOCLAW_EVENTS_LOGGING_INCLUDE="gateway.*,channel.lifecycle.*"
|
||||
PICOCLAW_EVENTS_LOGGING_EXCLUDE="gateway.ready"
|
||||
PICOCLAW_EVENTS_LOGGING_MIN_SEVERITY=info
|
||||
PICOCLAW_EVENTS_LOGGING_INCLUDE_PAYLOAD=false
|
||||
```
|
||||
|
||||
`include` and `exclude` use comma-separated values.
|
||||
|
||||
## Event Names And Triggers
|
||||
|
||||
The table below lists the current runtime event kinds, when they are emitted, and the most useful event details. `Source`, `Scope`, and `Correlation` are shared envelope fields that may appear on every event. The "Details" column refers to useful payload fields or log summary fields.
|
||||
|
||||
### Agent
|
||||
|
||||
| Event | Trigger | Details |
|
||||
| ----- | ------- | ------- |
|
||||
| `agent.turn.start` | An agent starts processing one user or system input after the turn scope has been created. | `user_len`, `media_count`; scope usually includes `agent_id`, `session_key`, `turn_id`, `channel`, `chat_id`, `message_id` |
|
||||
| `agent.turn.end` | A turn exits, whether it completed, errored, or was hard-aborted. | `status` (`completed`/`error`/`aborted`), `iterations_total`, `duration_ms`, `final_len` |
|
||||
| `agent.llm.request` | Before each LLM provider request. | `model`, `messages`, `tools`, `max_tokens` |
|
||||
| `agent.llm.delta` | Reserved for streaming LLM deltas; the kind is defined, but the current implementation has no natural emit site. | `content_delta_len`, `reasoning_delta_len` |
|
||||
| `agent.llm.response` | After the LLM provider returns a complete response. | `content_len`, `tool_calls`, `has_reasoning` |
|
||||
| `agent.llm.retry` | Before retrying an LLM request after context, rate-limit, transient provider, or fallback handling. | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` |
|
||||
| `agent.context.compress` | Agent context history is compressed, for example during proactive budget checks or LLM retry handling. | `reason`, `dropped_messages`, `remaining_messages` |
|
||||
| `agent.session.summarize` | Async session history summarization completes. | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` |
|
||||
| `agent.tool.exec_start` | Before the agent executes a tool call. | `tool`, `args_count`; full arguments are not logged by default |
|
||||
| `agent.tool.exec_end` | After a tool call completes, including successful results, tool errors, and async results. | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` |
|
||||
| `agent.tool.exec_skipped` | A tool call is skipped because the tool is unavailable, arguments are invalid, or turn control logic requires skipping it. | `tool`, `reason` |
|
||||
| `agent.steering.injected` | Queued steering messages are injected into the next LLM context. | `count`, `total_content_len` |
|
||||
| `agent.follow_up.queued` | An async tool result is queued back into the inbound/follow-up flow. | `source_tool`, `content_len` |
|
||||
| `agent.interrupt.received` | A turn accepts steering, graceful interrupt, or hard-abort input. | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` |
|
||||
| `agent.subturn.spawn` | A parent turn creates a child turn/subagent. | `child_agent_id`, `label`, `parent_turn_id` |
|
||||
| `agent.subturn.end` | A child turn ends. | `child_agent_id`, `status` |
|
||||
| `agent.subturn.result_delivered` | A child turn result is delivered to the target channel/chat. | `target_channel`, `target_chat_id`, `content_len` |
|
||||
| `agent.subturn.orphan` | A child turn result cannot be delivered or cannot be associated back to its parent turn. | `parent_turn_id`, `child_turn_id`, `reason` |
|
||||
| `agent.error` | Agent execution reports an error. | `stage`, `error` |
|
||||
|
||||
### Channel
|
||||
|
||||
| Event | Trigger | Details |
|
||||
| ----- | ------- | ------- |
|
||||
| `channel.lifecycle.initialized` | The channel manager creates and registers a channel instance from config. | `type`; scope includes `channel` |
|
||||
| `channel.lifecycle.started` | Channel `Start()` succeeds and worker goroutines have been started; added channels during hot reload also emit it. | `type` |
|
||||
| `channel.lifecycle.start_failed` | Channel `Start()` fails. | `type`, `error`; severity is `error` |
|
||||
| `channel.lifecycle.stopped` | Channel `Stop()` succeeds. | `type` |
|
||||
| `channel.webhook.registered` | A channel webhook handler is registered on the shared HTTP mux. | `type`; scope includes `channel` |
|
||||
| `channel.webhook.unregistered` | A channel webhook handler is removed from the shared HTTP mux. | `type`; scope includes `channel` |
|
||||
| `channel.message.outbound_queued` | An outbound text or media message is queued into its channel worker. | `media`, `content_len`, `reply_to_message_id`; scope comes from the original inbound context |
|
||||
| `channel.message.outbound_sent` | An outbound text or media message is sent successfully, or a placeholder edit handled the response. | `media`, `content_len`, `message_ids`, `reply_to_message_id` |
|
||||
| `channel.message.outbound_failed` | An outbound text or media message exhausts retries or hits a permanent failure. | `media`, `content_len`, `retries`, `error`, `reply_to_message_id`; severity is `error` |
|
||||
| `channel.rate_limited` | A channel worker is waiting for a rate-limit token and the context is canceled, interrupting this delivery. | `media`, `content_len`, `error`, `reply_to_message_id`; severity is `warn` |
|
||||
|
||||
### Message Bus
|
||||
|
||||
| Event | Trigger | Details |
|
||||
| ----- | ------- | ------- |
|
||||
| `bus.publish.failed` | Publishing inbound, outbound, media, audio, or voice-control data fails, or required context is missing. | `stream`, `error`; scope is derived from message context when possible |
|
||||
| `bus.close.started` | Message bus shutdown begins. | `drained` is usually `0` |
|
||||
| `bus.close.drained` | Shutdown waits for buffered messages to drain and at least one buffered message was drained. | `drained` |
|
||||
| `bus.close.completed` | Message bus shutdown completes. | `drained` |
|
||||
|
||||
### Gateway
|
||||
|
||||
| Event | Trigger | Details |
|
||||
| ----- | ------- | ------- |
|
||||
| `gateway.start` | Gateway startup reaches the agent/runtime event bus/bootstrap binding point. | `duration_ms` |
|
||||
| `gateway.ready` | Gateway services, channel manager, HTTP server, and other core services are ready. | `duration_ms` |
|
||||
| `gateway.shutdown` | Gateway shutdown begins. | No fixed payload; envelope fields may be the only fields |
|
||||
| `gateway.reload.started` | Hot reload execution starts. | `duration_ms` |
|
||||
| `gateway.reload.completed` | Hot reload completes successfully. | `duration_ms` |
|
||||
| `gateway.reload.failed` | Hot reload fails. | `duration_ms`, `error`; severity is `error` |
|
||||
|
||||
### MCP
|
||||
|
||||
| Event | Trigger | Details |
|
||||
| ----- | ------- | ------- |
|
||||
| `mcp.server.connecting` | The MCP manager is about to connect to a server. | `server`, `type`, `url`, `command` |
|
||||
| `mcp.server.connected` | An MCP server connects and its tool list has been initialized. | `server`, `type`, `url`, `command`, `tool_count` |
|
||||
| `mcp.server.failed` | An MCP server connection fails, or the manager is closed before connecting. | `server`, `type`, `url`, `command`, `error`; severity is `error` |
|
||||
| `mcp.tool.discovered` | A tool from an MCP server is discovered and registered. | `server`, `type`, `url`, `command`, `tool` |
|
||||
| `mcp.tool.call.start` | The MCP tool wrapper starts a remote tool call. | `server`, `tool`; when emitted inside an agent turn, scope includes turn/chat information |
|
||||
| `mcp.tool.call.end` | The MCP tool wrapper finishes a remote tool call, including failures. | `server`, `tool`, `duration_ms`, `is_error`, `error` |
|
||||
|
||||
## Log Fields
|
||||
|
||||
Runtime event logs include stable envelope fields when available:
|
||||
|
||||
- `event_id`
|
||||
- `event_kind`
|
||||
- `severity`
|
||||
- `event_time`
|
||||
- `source_component`
|
||||
- `source_name`
|
||||
- `agent_id`
|
||||
- `session_key`
|
||||
- `turn_id`
|
||||
- `channel`
|
||||
- `account`
|
||||
- `chat_id`
|
||||
- `topic_id`
|
||||
- `space_id`
|
||||
- `space_type`
|
||||
- `chat_type`
|
||||
- `sender_id`
|
||||
- `message_id`
|
||||
- `trace_id`
|
||||
- `parent_turn_id`
|
||||
- `request_id`
|
||||
- `reply_to_id`
|
||||
|
||||
Agent events add safe payload summaries:
|
||||
|
||||
| Event | Summary fields |
|
||||
| ----- | -------------- |
|
||||
| `agent.turn.start` | `user_len`, `media_count` |
|
||||
| `agent.turn.end` | `status`, `iterations_total`, `duration_ms`, `final_len` |
|
||||
| `agent.llm.request` | `model`, `messages`, `tools`, `max_tokens` |
|
||||
| `agent.llm.delta` | `content_delta_len`, `reasoning_delta_len` |
|
||||
| `agent.llm.response` | `content_len`, `tool_calls`, `has_reasoning` |
|
||||
| `agent.llm.retry` | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` |
|
||||
| `agent.context.compress` | `reason`, `dropped_messages`, `remaining_messages` |
|
||||
| `agent.session.summarize` | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` |
|
||||
| `agent.tool.exec_start` | `tool`, `args_count` |
|
||||
| `agent.tool.exec_end` | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` |
|
||||
| `agent.tool.exec_skipped` | `tool`, `reason` |
|
||||
| `agent.steering.injected` | `count`, `total_content_len` |
|
||||
| `agent.follow_up.queued` | `source_tool`, `content_len` |
|
||||
| `agent.interrupt.received` | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` |
|
||||
| `agent.subturn.spawn` | `child_agent_id`, `label` |
|
||||
| `agent.subturn.end` | `child_agent_id`, `status` |
|
||||
| `agent.subturn.result_delivered` | `target_channel`, `target_chat_id`, `content_len` |
|
||||
| `agent.subturn.orphan` | `parent_turn_id`, `child_turn_id`, `reason` |
|
||||
| `agent.error` | `stage`, `error` |
|
||||
|
||||
## Event Domains
|
||||
|
||||
Runtime event kinds are defined in `pkg/events/kind.go`. Event logging can select these domains:
|
||||
|
||||
- `agent.*`: agent turn, LLM, tool, context, steering, interrupt, subturn, and error events.
|
||||
- `channel.*`: channel lifecycle, webhook registration, outbound queued/sent/failed, and rate limiting.
|
||||
- `bus.*`: publish failures and close lifecycle.
|
||||
- `gateway.*`: start, ready, shutdown, and reload lifecycle.
|
||||
- `mcp.*`: MCP server connection, tool discovery, and tool call events.
|
||||
|
||||
See [`../../config/config.example.json`](../../config/config.example.json) for the default event logging example.
|
||||
216
docs/architecture/runtime-events.zh.md
Normal file
216
docs/architecture/runtime-events.zh.md
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
# Runtime Events 与事件日志
|
||||
|
||||
PicoClaw 的 runtime event 是运行时观察面,用来描述 agent、channel、gateway、message bus、MCP 等组件发生了什么。事件发布和日志打印是两件事:
|
||||
|
||||
- 事件发布:组件把 `pkg/events.Event` 发布到 runtime event bus,供 hook、测试、调试工具或后续 UI 消费。
|
||||
- 事件日志:内置 runtime event logger 订阅同一个 bus,并按配置把匹配的事件打印到日志。
|
||||
|
||||
这样可以让业务流程继续只负责发布事件,日志策略统一收口到一个地方。
|
||||
|
||||
## 默认行为
|
||||
|
||||
默认配置只打印 `agent.*` 事件:
|
||||
|
||||
```json
|
||||
{
|
||||
"events": {
|
||||
"logging": {
|
||||
"enabled": true,
|
||||
"include": ["agent.*"],
|
||||
"min_severity": "info",
|
||||
"include_payload": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个默认值保持了旧行为:agent turn、LLM、tool、steering、subturn、error 等事件会出现在日志中;channel、gateway、bus、MCP 事件仍会发布到 runtime event bus,但默认不打印,避免网关启动和消息投递日志过于嘈杂。
|
||||
|
||||
## 配置项
|
||||
|
||||
配置位于 `config.json` 的 `events.logging`:
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
| ---- | ---- | ------ | ---- |
|
||||
| `enabled` | bool | `true` | 是否启用内置事件日志订阅器 |
|
||||
| `include` | string[] | `["agent.*"]` | 允许打印的事件 kind,支持精确匹配、`*`、`agent.*` 这类 glob/prefix |
|
||||
| `exclude` | string[] | `[]` | 在 include 命中后排除的事件 kind,匹配规则同 include |
|
||||
| `min_severity` | string | `info` | 最低打印级别:`debug`、`info`、`warn`、`error` |
|
||||
| `include_payload` | bool | `false` | 是否把原始 payload 放进日志字段 |
|
||||
|
||||
`include_payload` 默认关闭。agent 事件日志会输出安全摘要字段,例如 `user_len`、`args_count`、`content_len`,不会默认输出完整用户消息或工具参数。只有在排查问题、并且确认日志存储环境可信时,才建议临时打开 `include_payload`。
|
||||
|
||||
## 匹配规则
|
||||
|
||||
`include` 和 `exclude` 都匹配 `Event.Kind` 字符串:
|
||||
|
||||
```json
|
||||
{
|
||||
"events": {
|
||||
"logging": {
|
||||
"include": ["gateway.*", "channel.lifecycle.*", "agent.error"],
|
||||
"exclude": ["gateway.ready"],
|
||||
"min_severity": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
常用写法:
|
||||
|
||||
- `["agent.*"]`:只打印 agent 事件。
|
||||
- `["*"]`:打印所有 runtime events。
|
||||
- `["gateway.*", "channel.*"]`:只打印 gateway 和 channel 事件。
|
||||
- `exclude: ["agent.llm.delta"]`:排除高频流式 delta 事件。
|
||||
- `min_severity: "warn"`:只打印 warn/error 事件。
|
||||
|
||||
## 环境变量
|
||||
|
||||
同一组配置也可以通过环境变量覆盖,适合临时调试:
|
||||
|
||||
```bash
|
||||
PICOCLAW_EVENTS_LOGGING_ENABLED=true
|
||||
PICOCLAW_EVENTS_LOGGING_INCLUDE="gateway.*,channel.lifecycle.*"
|
||||
PICOCLAW_EVENTS_LOGGING_EXCLUDE="gateway.ready"
|
||||
PICOCLAW_EVENTS_LOGGING_MIN_SEVERITY=info
|
||||
PICOCLAW_EVENTS_LOGGING_INCLUDE_PAYLOAD=false
|
||||
```
|
||||
|
||||
`include` 和 `exclude` 的环境变量使用逗号分隔。
|
||||
|
||||
## 事件名称与触发时机
|
||||
|
||||
下面列出当前 runtime event kind、触发时机和主要事件详情。`Source`、`Scope`、`Correlation` 是所有事件都可能携带的 envelope 字段;表里的“主要详情”指 payload 或日志摘要中最有用的字段。
|
||||
|
||||
### Agent
|
||||
|
||||
| 事件名 | 触发时机 | 主要详情 |
|
||||
| ------ | -------- | -------- |
|
||||
| `agent.turn.start` | agent 开始处理一次用户输入或系统输入,turn scope 已创建时 | `user_len`, `media_count`; scope 通常包含 `agent_id`, `session_key`, `turn_id`, `channel`, `chat_id`, `message_id` |
|
||||
| `agent.turn.end` | 一次 turn 退出时,无论完成、报错还是 hard abort | `status` (`completed`/`error`/`aborted`), `iterations_total`, `duration_ms`, `final_len` |
|
||||
| `agent.llm.request` | 每次调用 LLM provider 前 | `model`, `messages`, `tools`, `max_tokens` |
|
||||
| `agent.llm.delta` | 预留给流式 LLM delta;当前实现已定义但没有自然发送点 | `content_delta_len`, `reasoning_delta_len` |
|
||||
| `agent.llm.response` | LLM provider 返回完整响应后 | `content_len`, `tool_calls`, `has_reasoning` |
|
||||
| `agent.llm.retry` | LLM 请求因上下文、限流、临时错误等原因准备重试前 | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` |
|
||||
| `agent.context.compress` | 上下文历史被压缩时,例如主动预算检查或 LLM retry 处理 | `reason`, `dropped_messages`, `remaining_messages` |
|
||||
| `agent.session.summarize` | 会话历史异步摘要完成时 | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` |
|
||||
| `agent.tool.exec_start` | agent 准备执行一个工具调用前 | `tool`, `args_count`; 默认不打印完整参数 |
|
||||
| `agent.tool.exec_end` | 工具调用完成后,包括成功、工具错误和 async 结果 | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` |
|
||||
| `agent.tool.exec_skipped` | 工具调用被跳过时,例如工具不可用、参数无效或 turn 控制逻辑要求跳过 | `tool`, `reason` |
|
||||
| `agent.steering.injected` | queued steering message 被注入下一轮 LLM 上下文时 | `count`, `total_content_len` |
|
||||
| `agent.follow_up.queued` | async 工具结果被重新排入 inbound/follow-up 流程时 | `source_tool`, `content_len` |
|
||||
| `agent.interrupt.received` | turn 接受 steering、graceful interrupt 或 hard abort 指令时 | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` |
|
||||
| `agent.subturn.spawn` | 父 turn 创建子 turn/subagent 时 | `child_agent_id`, `label`, `parent_turn_id` |
|
||||
| `agent.subturn.end` | 子 turn 结束时 | `child_agent_id`, `status` |
|
||||
| `agent.subturn.result_delivered` | 子 turn 结果成功投递到目标 channel/chat 时 | `target_channel`, `target_chat_id`, `content_len` |
|
||||
| `agent.subturn.orphan` | 子 turn 结果无法投递或无法关联回父 turn 时 | `parent_turn_id`, `child_turn_id`, `reason` |
|
||||
| `agent.error` | agent 执行流程报告错误时 | `stage`, `error` |
|
||||
|
||||
### Channel
|
||||
|
||||
| 事件名 | 触发时机 | 主要详情 |
|
||||
| ------ | -------- | -------- |
|
||||
| `channel.lifecycle.initialized` | channel manager 根据配置创建并注册 channel 实例后 | `type`; scope 包含 `channel` |
|
||||
| `channel.lifecycle.started` | channel `Start()` 成功,worker 已启动时;热重载新增 channel 也会触发 | `type` |
|
||||
| `channel.lifecycle.start_failed` | channel `Start()` 失败时 | `type`, `error`; severity 为 `error` |
|
||||
| `channel.lifecycle.stopped` | channel `Stop()` 成功后 | `type` |
|
||||
| `channel.webhook.registered` | channel 的 webhook handler 被注册到共享 HTTP mux 时 | `type`; scope 包含 `channel` |
|
||||
| `channel.webhook.unregistered` | channel 的 webhook handler 从共享 HTTP mux 移除时 | `type`; scope 包含 `channel` |
|
||||
| `channel.message.outbound_queued` | outbound 文本或媒体消息被放入对应 channel worker 队列时 | `media`, `content_len`, `reply_to_message_id`; scope 来自原 inbound context |
|
||||
| `channel.message.outbound_sent` | outbound 文本或媒体消息成功发送,或 placeholder edit 已处理响应时 | `media`, `content_len`, `message_ids`, `reply_to_message_id` |
|
||||
| `channel.message.outbound_failed` | outbound 文本或媒体消息重试耗尽或遇到永久失败时 | `media`, `content_len`, `retries`, `error`, `reply_to_message_id`; severity 为 `error` |
|
||||
| `channel.rate_limited` | channel worker 等待 rate limiter token 时被 context 取消,导致本次发送被限流/中断 | `media`, `content_len`, `error`, `reply_to_message_id`; severity 为 `warn` |
|
||||
|
||||
### Message Bus
|
||||
|
||||
| 事件名 | 触发时机 | 主要详情 |
|
||||
| ------ | -------- | -------- |
|
||||
| `bus.publish.failed` | inbound、outbound、media、audio 或 voice control 发布失败,或缺少必要 context 时 | `stream`, `error`; scope 尽量来自消息 context |
|
||||
| `bus.close.started` | message bus 开始关闭时 | `drained` 通常为 `0` |
|
||||
| `bus.close.drained` | close 期间等待队列 drain,并且 drain 到至少一条 buffered message 时 | `drained` |
|
||||
| `bus.close.completed` | message bus 完成关闭时 | `drained` |
|
||||
|
||||
### Gateway
|
||||
|
||||
| 事件名 | 触发时机 | 主要详情 |
|
||||
| ------ | -------- | -------- |
|
||||
| `gateway.start` | gateway 完成 agent/runtime event bus/bootstrap 绑定后 | `duration_ms` |
|
||||
| `gateway.ready` | gateway 服务、channel manager、HTTP 等关键服务启动完成后 | `duration_ms` |
|
||||
| `gateway.shutdown` | gateway 开始关闭流程时 | 无固定 payload,可能只有 envelope 字段 |
|
||||
| `gateway.reload.started` | 热重载开始执行时 | `duration_ms` |
|
||||
| `gateway.reload.completed` | 热重载成功完成时 | `duration_ms` |
|
||||
| `gateway.reload.failed` | 热重载失败时 | `duration_ms`, `error`; severity 为 `error` |
|
||||
|
||||
### MCP
|
||||
|
||||
| 事件名 | 触发时机 | 主要详情 |
|
||||
| ------ | -------- | -------- |
|
||||
| `mcp.server.connecting` | MCP manager 准备连接某个 server 前 | `server`, `type`, `url`, `command` |
|
||||
| `mcp.server.connected` | MCP server 连接成功并完成工具列表初始化后 | `server`, `type`, `url`, `command`, `tool_count` |
|
||||
| `mcp.server.failed` | MCP server 连接失败,或 manager 已关闭导致无法连接时 | `server`, `type`, `url`, `command`, `error`; severity 为 `error` |
|
||||
| `mcp.tool.discovered` | MCP server 的某个工具被发现并注册时 | `server`, `type`, `url`, `command`, `tool` |
|
||||
| `mcp.tool.call.start` | MCP tool wrapper 开始执行一次远端工具调用前 | `server`, `tool`; 如果在 agent turn 内触发,scope 会带上对应 turn/chat 信息 |
|
||||
| `mcp.tool.call.end` | MCP tool wrapper 完成一次远端工具调用后,包括失败结果 | `server`, `tool`, `duration_ms`, `is_error`, `error` |
|
||||
|
||||
## 日志字段
|
||||
|
||||
所有事件日志都会尽量包含稳定 envelope 字段:
|
||||
|
||||
- `event_id`
|
||||
- `event_kind`
|
||||
- `severity`
|
||||
- `event_time`
|
||||
- `source_component`
|
||||
- `source_name`
|
||||
- `agent_id`
|
||||
- `session_key`
|
||||
- `turn_id`
|
||||
- `channel`
|
||||
- `account`
|
||||
- `chat_id`
|
||||
- `topic_id`
|
||||
- `space_id`
|
||||
- `space_type`
|
||||
- `chat_type`
|
||||
- `sender_id`
|
||||
- `message_id`
|
||||
- `trace_id`
|
||||
- `parent_turn_id`
|
||||
- `request_id`
|
||||
- `reply_to_id`
|
||||
|
||||
agent 事件还会追加 payload 摘要字段:
|
||||
|
||||
| 事件 | 摘要字段 |
|
||||
| ---- | -------- |
|
||||
| `agent.turn.start` | `user_len`, `media_count` |
|
||||
| `agent.turn.end` | `status`, `iterations_total`, `duration_ms`, `final_len` |
|
||||
| `agent.llm.request` | `model`, `messages`, `tools`, `max_tokens` |
|
||||
| `agent.llm.delta` | `content_delta_len`, `reasoning_delta_len` |
|
||||
| `agent.llm.response` | `content_len`, `tool_calls`, `has_reasoning` |
|
||||
| `agent.llm.retry` | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` |
|
||||
| `agent.context.compress` | `reason`, `dropped_messages`, `remaining_messages` |
|
||||
| `agent.session.summarize` | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` |
|
||||
| `agent.tool.exec_start` | `tool`, `args_count` |
|
||||
| `agent.tool.exec_end` | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` |
|
||||
| `agent.tool.exec_skipped` | `tool`, `reason` |
|
||||
| `agent.steering.injected` | `count`, `total_content_len` |
|
||||
| `agent.follow_up.queued` | `source_tool`, `content_len` |
|
||||
| `agent.interrupt.received` | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` |
|
||||
| `agent.subturn.spawn` | `child_agent_id`, `label` |
|
||||
| `agent.subturn.end` | `child_agent_id`, `status` |
|
||||
| `agent.subturn.result_delivered` | `target_channel`, `target_chat_id`, `content_len` |
|
||||
| `agent.subturn.orphan` | `parent_turn_id`, `child_turn_id`, `reason` |
|
||||
| `agent.error` | `stage`, `error` |
|
||||
|
||||
## 可打印的事件域
|
||||
|
||||
当前 runtime event kind 定义在 `pkg/events/kind.go`。事件日志配置可以选择这些域:
|
||||
|
||||
- `agent.*`:agent turn、LLM、tool、context、steering、interrupt、subturn、error。
|
||||
- `channel.*`:channel lifecycle、webhook 注册、outbound queued/sent/failed、rate limited。
|
||||
- `bus.*`:publish failed、close started/drained/completed。
|
||||
- `gateway.*`:start、ready、shutdown、reload started/completed/failed。
|
||||
- `mcp.*`:server connecting/connected/failed、tool discovered、tool call start/end。
|
||||
|
||||
默认事件日志示例见 [`../../config/config.example.json`](../../config/config.example.json)。
|
||||
|
|
@ -135,16 +135,16 @@ The agent loop polls for async SubTurn results at two points per iteration:
|
|||
|
||||
All active turns are registered in `AgentLoop.activeTurnStates` (`sync.Map`, keyed by session key). A reservation sentinel is stored atomically via `LoadOrStore` before the worker starts, then replaced with the real `*turnState` when `runTurn` registers. This prevents a TOCTOU race where multiple messages for the same session could spawn concurrent workers. The sentinel is cleaned up by the worker's deferred cleanup. This allows `HardAbort` and `/subagents` observability commands to find and operate on active turns.
|
||||
|
||||
## Event Bus Integration
|
||||
## Runtime Event Integration
|
||||
|
||||
SubTurns emit specific events to the PicoClaw `EventBus` for observability and debugging:
|
||||
SubTurns emit runtime events through `pkg/events` for observability and debugging:
|
||||
|
||||
| Event Kind | When Emitted | Payload |
|
||||
|:------|:-------------|:--------|
|
||||
| `subturn_spawn` | Sub-turn successfully initialized | `SubTurnSpawnPayload{AgentID, Label, ParentTurnID}` |
|
||||
| `subturn_end` | Sub-turn finishes (success or error) | `SubTurnEndPayload{AgentID, Status}` |
|
||||
| `subturn_result_delivered` | Async result successfully delivered to parent | `SubTurnResultDeliveredPayload{TargetChannel, TargetChatID, ContentLen}` |
|
||||
| `subturn_orphan` | Result cannot be delivered (parent finished or channel full) | `SubTurnOrphanPayload{ParentTurnID, ChildTurnID, Reason}` |
|
||||
| `agent.subturn.spawn` | Sub-turn successfully initialized | `SubTurnSpawnPayload{AgentID, Label, ParentTurnID}` |
|
||||
| `agent.subturn.end` | Sub-turn finishes (success or error) | `SubTurnEndPayload{AgentID, Status}` |
|
||||
| `agent.subturn.result_delivered` | Async result successfully delivered to parent | `SubTurnResultDeliveredPayload{TargetChannel, TargetChatID, ContentLen}` |
|
||||
| `agent.subturn.orphan` | Result cannot be delivered (parent finished or channel full) | `SubTurnOrphanPayload{ParentTurnID, ChildTurnID, Reason}` |
|
||||
|
||||
## API Reference
|
||||
|
||||
|
|
@ -240,13 +240,13 @@ An orphan result occurs when:
|
|||
2. The `pendingResults` channel is full (buffer size: 16)
|
||||
|
||||
When a result becomes orphan:
|
||||
- `SubTurnOrphanResultEvent` is emitted to EventBus
|
||||
- `agent.subturn.orphan` is emitted to the runtime event bus
|
||||
- The result is **NOT** delivered to the LLM context
|
||||
- External systems can listen to this event for custom handling
|
||||
|
||||
### Preventing Orphan Results
|
||||
- Use `Critical: true` for important SubTurns that must complete
|
||||
- Monitor `SubTurnOrphanResultEvent` for observability
|
||||
- Monitor `agent.subturn.orphan` for observability
|
||||
- Consider the 16-buffer limit when spawning many async SubTurns
|
||||
|
||||
## Tool Inheritance
|
||||
|
|
|
|||
140
docs/channels/mqtt/README.fr.md
Normal file
140
docs/channels/mqtt/README.fr.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# 📡 Canal MQTT
|
||||
|
||||
PicoClaw prend en charge n'importe quel client MQTT comme canal de messagerie. Les appareils ou services publient des requêtes vers un broker ; PicoClaw s'abonne, les traite et publie les réponses en retour.
|
||||
|
||||
## 🚀 Démarrage rapide
|
||||
|
||||
**1. Ajouter le canal dans `~/.picoclaw/config.json` :**
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "tcp://localhost:1883",
|
||||
"agent_id": "assistant"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. Démarrer la passerelle :**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
**3. Envoyer un message depuis n'importe quel client MQTT :**
|
||||
|
||||
```bash
|
||||
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
|
||||
-m '{"text": "Quel est l'\''usage CPU ?"}'
|
||||
```
|
||||
|
||||
**4. S'abonner pour recevoir la réponse :**
|
||||
|
||||
```bash
|
||||
mosquitto_sub -t "/picoclaw/assistant/device1/response"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📨 Structure des topics
|
||||
|
||||
```
|
||||
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
|
||||
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
|
||||
```
|
||||
|
||||
| Segment | Description |
|
||||
|---------|-------------|
|
||||
| `prefix` | Préfixe de topic configuré côté serveur. Défaut : `/picoclaw` |
|
||||
| `agent_id` | Identifiant de l'instance PicoClaw, défini dans le champ `agent_id` |
|
||||
| `client_id` | Identifiant de session défini par le client — utiliser un ID stable par appareil pour maintenir le contexte |
|
||||
|
||||
### Payload du message (JSON)
|
||||
|
||||
```json
|
||||
{ "text": "votre message ici" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### config.json
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "ssl://votre-broker:8883",
|
||||
"agent_id": "assistant",
|
||||
"topic_prefix": "/picoclaw",
|
||||
"client_id": "",
|
||||
"keep_alive": 60,
|
||||
"qos": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### .security.yml (identifiants)
|
||||
|
||||
Le nom d'utilisateur et le mot de passe sont stockés dans `~/.picoclaw/.security.yml`, pas dans `config.json` :
|
||||
|
||||
```yaml
|
||||
channel_list:
|
||||
mqtt:
|
||||
settings:
|
||||
username: votre_utilisateur
|
||||
password: votre_mot_de_passe
|
||||
```
|
||||
|
||||
### Champs de configuration
|
||||
|
||||
| Champ | Emplacement | Requis | Défaut | Description |
|
||||
|-------|-------------|--------|--------|-------------|
|
||||
| `broker` | `settings` | Oui | — | URL du broker MQTT, ex. `tcp://host:1883`, `ssl://host:8883` |
|
||||
| `agent_id` | `settings` | Oui | — | Identifiant de l'agent, utilisé dans le chemin du topic |
|
||||
| `topic_prefix` | `settings` | Non | `/picoclaw` | Préfixe de l'espace de noms des topics |
|
||||
| `username` | `.security.yml` | Non | — | Nom d'utilisateur pour l'authentification au broker |
|
||||
| `password` | `.security.yml` | Non | — | Mot de passe pour l'authentification au broker |
|
||||
| `client_id` | `settings` | Non | auto-généré | ID client paho envoyé au broker. Auto-généré sous la forme `picoclaw-mqtt-{agent_id}-{8 hex}` ; fixe pour la durée du processus, réutilisé à la reconnexion |
|
||||
| `keep_alive` | `settings` | Non | `60` | Intervalle keepalive MQTT en secondes |
|
||||
| `qos` | `settings` | Non | `0` | Niveau QoS pour la publication et l'abonnement : `0`, `1` ou `2` |
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
| Variable | Champ |
|
||||
|----------|-------|
|
||||
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Reconnexion
|
||||
|
||||
PicoClaw se reconnecte automatiquement au broker en cas de perte de connexion, avec un intervalle de 5 secondes. L'abonnement est rétabli automatiquement. L'ID client côté broker reste identique à chaque reconnexion.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Remarques
|
||||
|
||||
- **TLS** : SSL/TLS est supporté (URL broker en `ssl://`). La vérification du certificat est désactivée par défaut.
|
||||
- **Réponses en streaming** : Les réponses en streaming envoient plusieurs messages vers le topic de réponse ; les concaténer dans l'ordre pour obtenir la réponse complète.
|
||||
- **client_id vs ID de session** : Le `client_id` dans le chemin du topic est défini par votre application cliente. Il est distinct de l'ID client paho utilisé par PicoClaw pour se connecter au broker.
|
||||
- **Instances multiples** : Si plusieurs instances PicoClaw utilisent le même `agent_id` sur le même broker, définir des `client_id` distincts pour éviter les conflits.
|
||||
140
docs/channels/mqtt/README.ja.md
Normal file
140
docs/channels/mqtt/README.ja.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# 📡 MQTT チャンネル
|
||||
|
||||
PicoClaw は任意の MQTT クライアントをメッセージチャンネルとして使用できます。デバイスやサービスがブローカーにリクエストをパブリッシュし、PicoClaw がサブスクライブして処理し、レスポンスをパブリッシュして返します。
|
||||
|
||||
## 🚀 クイックスタート
|
||||
|
||||
**1. `~/.picoclaw/config.json` にチャンネルを追加:**
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "tcp://localhost:1883",
|
||||
"agent_id": "assistant"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. ゲートウェイを起動:**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
**3. 任意の MQTT クライアントからメッセージを送信:**
|
||||
|
||||
```bash
|
||||
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
|
||||
-m '{"text": "CPU使用率を確認してください"}'
|
||||
```
|
||||
|
||||
**4. レスポンスを受信するためにサブスクライブ:**
|
||||
|
||||
```bash
|
||||
mosquitto_sub -t "/picoclaw/assistant/device1/response"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📨 トピック構造
|
||||
|
||||
```
|
||||
{prefix}/{agent_id}/{client_id}/request # クライアント → PicoClaw
|
||||
{prefix}/{agent_id}/{client_id}/response # PicoClaw → クライアント
|
||||
```
|
||||
|
||||
| セグメント | 説明 |
|
||||
|-----------|------|
|
||||
| `prefix` | トピックのプレフィックス。サーバー側で設定。デフォルト:`/picoclaw` |
|
||||
| `agent_id` | PicoClaw インスタンスの識別子。`agent_id` フィールドに設定 |
|
||||
| `client_id` | クライアントが定義するセッション識別子。デバイスごとに同一の ID を使用するとコンテキストが維持される |
|
||||
|
||||
### メッセージペイロード(JSON)
|
||||
|
||||
```json
|
||||
{ "text": "メッセージ内容" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 設定
|
||||
|
||||
### config.json
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "ssl://your-broker:8883",
|
||||
"agent_id": "assistant",
|
||||
"topic_prefix": "/picoclaw",
|
||||
"client_id": "",
|
||||
"keep_alive": 60,
|
||||
"qos": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### .security.yml(認証情報)
|
||||
|
||||
ユーザー名とパスワードは `config.json` ではなく `~/.picoclaw/.security.yml` に保存します:
|
||||
|
||||
```yaml
|
||||
channel_list:
|
||||
mqtt:
|
||||
settings:
|
||||
username: your_username
|
||||
password: your_password
|
||||
```
|
||||
|
||||
### 設定フィールド
|
||||
|
||||
| フィールド | 場所 | 必須 | デフォルト | 説明 |
|
||||
|-----------|------|------|-----------|------|
|
||||
| `broker` | `settings` | はい | — | MQTT ブローカー URL。例:`tcp://host:1883`、`ssl://host:8883` |
|
||||
| `agent_id` | `settings` | はい | — | エージェント識別子。トピックパスの一部として使用される |
|
||||
| `topic_prefix` | `settings` | いいえ | `/picoclaw` | トピックの名前空間プレフィックス |
|
||||
| `username` | `.security.yml` | いいえ | — | ブローカー認証のユーザー名 |
|
||||
| `password` | `.security.yml` | いいえ | — | ブローカー認証のパスワード |
|
||||
| `client_id` | `settings` | いいえ | 自動生成 | ブローカーに送信する paho クライアント ID。未設定の場合 `picoclaw-mqtt-{agent_id}-{8桁hex}` で自動生成。プロセスの生存期間中は固定され、再接続時も同じ ID を使用 |
|
||||
| `keep_alive` | `settings` | いいえ | `60` | MQTT キープアライブ間隔(秒) |
|
||||
| `qos` | `settings` | いいえ | `0` | パブリッシュおよびサブスクライブの QoS レベル:`0`、`1`、`2` |
|
||||
|
||||
### 環境変数
|
||||
|
||||
| 変数 | フィールド |
|
||||
|------|----------|
|
||||
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 再接続
|
||||
|
||||
接続が切断された場合、PicoClaw は 5 秒間隔で自動的にブローカーに再接続します。再接続後はサブスクリプションも自動的に再確立されます。再接続時はブローカー側のクライアント ID が同一に保たれるため、ブローカーは同じセッションとして認識します。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事項
|
||||
|
||||
- **TLS**:SSL/TLS をサポートしています(ブローカー URL に `ssl://` を使用)。デフォルトでは証明書検証をスキップします。
|
||||
- **ストリーミングレスポンス**:ストリーミング出力時はレスポンストピックに複数のメッセージが送信されます。順番に結合すると完全なレスポンスになります。
|
||||
- **client_id とセッション ID の違い**:トピックパスの `client_id` はクライアントアプリケーションが設定するセッション識別子です。PicoClaw がブローカーへの接続に使用する paho クライアント ID とは別の概念です。
|
||||
- **複数インスタンス**:同じ `agent_id` で複数の PicoClaw インスタンスを同一ブローカーに接続する場合、ブローカーレベルの競合を避けるために各インスタンスに異なる `client_id` を設定してください。
|
||||
142
docs/channels/mqtt/README.md
Normal file
142
docs/channels/mqtt/README.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# 📡 MQTT Channel
|
||||
|
||||
PicoClaw supports any MQTT client as a chat channel. Devices or services publish requests to a broker; PicoClaw subscribes, processes them, and publishes responses back.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
**1. Add the channel to `~/.picoclaw/config.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "tcp://localhost:1883",
|
||||
"agent_id": "assistant"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. Start the gateway:**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
**3. Send a message from any MQTT client:**
|
||||
|
||||
```bash
|
||||
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
|
||||
-m '{"text": "What is the CPU usage?"}'
|
||||
```
|
||||
|
||||
**4. Subscribe to receive the response:**
|
||||
|
||||
```bash
|
||||
mosquitto_sub -t "/picoclaw/assistant/device1/response"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📨 Topic Structure
|
||||
|
||||
```
|
||||
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
|
||||
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
|
||||
```
|
||||
|
||||
| Segment | Description |
|
||||
|---------|-------------|
|
||||
| `prefix` | Topic prefix, configured server-side. Default: `/picoclaw` |
|
||||
| `agent_id` | PicoClaw instance identifier, set in `agent_id` config field |
|
||||
| `client_id` | Client-defined session identifier — use a stable ID per device to maintain conversation context |
|
||||
|
||||
### Message Payload (JSON)
|
||||
|
||||
```json
|
||||
{ "text": "your message here" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### config.json
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "ssl://your-broker:8883",
|
||||
"agent_id": "assistant",
|
||||
"topic_prefix": "/picoclaw",
|
||||
"client_id": "",
|
||||
"keep_alive": 60,
|
||||
"qos": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### .security.yml (credentials)
|
||||
|
||||
Username and password are stored in `~/.picoclaw/.security.yml`, not in `config.json`:
|
||||
|
||||
```yaml
|
||||
channel_list:
|
||||
mqtt:
|
||||
settings:
|
||||
username: your_username
|
||||
password: your_password
|
||||
```
|
||||
|
||||
### Configuration Fields
|
||||
|
||||
| Field | Location | Required | Default | Description |
|
||||
|-------|----------|----------|---------|-------------|
|
||||
| `broker` | `settings` | Yes | — | MQTT broker URL, e.g. `tcp://host:1883`, `ssl://host:8883` |
|
||||
| `agent_id` | `settings` | Yes | — | Agent identifier, used as part of the topic path |
|
||||
| `topic_prefix` | `settings` | No | `/picoclaw` | Topic namespace prefix |
|
||||
| `username` | `.security.yml` | No | — | Broker authentication username |
|
||||
| `password` | `.security.yml` | No | — | Broker authentication password |
|
||||
| `client_id` | `settings` | No | auto-generated | Paho client ID sent to the broker. Auto-generated as `picoclaw-mqtt-{agent_id}-{8-char hex}` if not set; stays fixed for the process lifetime so reconnects reuse the same ID |
|
||||
| `keep_alive` | `settings` | No | `60` | MQTT keepalive interval in seconds |
|
||||
| `qos` | `settings` | No | `0` | QoS level for publish and subscribe: `0`, `1`, or `2` |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
All fields can be set via environment variables:
|
||||
|
||||
| Variable | Field |
|
||||
|----------|-------|
|
||||
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Reconnection
|
||||
|
||||
PicoClaw automatically reconnects to the broker if the connection is lost, with a 5-second retry interval. On reconnect, the subscription is re-established automatically. The broker-side client ID stays the same across reconnects so the broker correctly identifies it as the same session.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Notes
|
||||
|
||||
- **TLS**: SSL/TLS is supported (`ssl://` broker URL). Certificate verification is skipped by default.
|
||||
- **Streaming**: Streaming responses send multiple messages to the response topic; concatenate them in order.
|
||||
- **client_id vs session ID**: The `client_id` in the topic path is set by your client application and identifies the conversation session. It is separate from the broker-level client ID used by PicoClaw's paho connection.
|
||||
- **Multiple instances**: If you run multiple PicoClaw instances against the same broker with the same `agent_id`, set distinct `client_id` values to avoid broker-level conflicts.
|
||||
140
docs/channels/mqtt/README.pt-br.md
Normal file
140
docs/channels/mqtt/README.pt-br.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# 📡 Canal MQTT
|
||||
|
||||
O PicoClaw suporta qualquer cliente MQTT como canal de mensagens. Dispositivos ou serviços publicam requisições para um broker; o PicoClaw assina, processa e publica as respostas de volta.
|
||||
|
||||
## 🚀 Início rápido
|
||||
|
||||
**1. Adicione o canal ao `~/.picoclaw/config.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "tcp://localhost:1883",
|
||||
"agent_id": "assistant"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. Inicie o gateway:**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
**3. Envie uma mensagem de qualquer cliente MQTT:**
|
||||
|
||||
```bash
|
||||
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
|
||||
-m '{"text": "Qual é o uso de CPU?"}'
|
||||
```
|
||||
|
||||
**4. Assine para receber a resposta:**
|
||||
|
||||
```bash
|
||||
mosquitto_sub -t "/picoclaw/assistant/device1/response"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📨 Estrutura de tópicos
|
||||
|
||||
```
|
||||
{prefix}/{agent_id}/{client_id}/request # Cliente → PicoClaw
|
||||
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Cliente
|
||||
```
|
||||
|
||||
| Segmento | Descrição |
|
||||
|----------|-----------|
|
||||
| `prefix` | Prefixo do tópico configurado no servidor. Padrão: `/picoclaw` |
|
||||
| `agent_id` | Identificador da instância do PicoClaw, definido no campo `agent_id` |
|
||||
| `client_id` | Identificador de sessão definido pelo cliente — use um ID estável por dispositivo para manter o contexto da conversa |
|
||||
|
||||
### Payload da mensagem (JSON)
|
||||
|
||||
```json
|
||||
{ "text": "sua mensagem aqui" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuração
|
||||
|
||||
### config.json
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "ssl://seu-broker:8883",
|
||||
"agent_id": "assistant",
|
||||
"topic_prefix": "/picoclaw",
|
||||
"client_id": "",
|
||||
"keep_alive": 60,
|
||||
"qos": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### .security.yml (credenciais)
|
||||
|
||||
O nome de usuário e a senha são armazenados em `~/.picoclaw/.security.yml`, não no `config.json`:
|
||||
|
||||
```yaml
|
||||
channel_list:
|
||||
mqtt:
|
||||
settings:
|
||||
username: seu_usuario
|
||||
password: sua_senha
|
||||
```
|
||||
|
||||
### Campos de configuração
|
||||
|
||||
| Campo | Local | Obrigatório | Padrão | Descrição |
|
||||
|-------|-------|-------------|--------|-----------|
|
||||
| `broker` | `settings` | Sim | — | URL do broker MQTT, ex. `tcp://host:1883`, `ssl://host:8883` |
|
||||
| `agent_id` | `settings` | Sim | — | Identificador do agente, usado como parte do caminho do tópico |
|
||||
| `topic_prefix` | `settings` | Não | `/picoclaw` | Prefixo do namespace dos tópicos |
|
||||
| `username` | `.security.yml` | Não | — | Nome de usuário para autenticação no broker |
|
||||
| `password` | `.security.yml` | Não | — | Senha para autenticação no broker |
|
||||
| `client_id` | `settings` | Não | gerado automaticamente | ID de cliente paho enviado ao broker. Gerado automaticamente como `picoclaw-mqtt-{agent_id}-{8 hex}` se não definido; fixo durante o tempo de vida do processo e reutilizado nas reconexões |
|
||||
| `keep_alive` | `settings` | Não | `60` | Intervalo de keepalive MQTT em segundos |
|
||||
| `qos` | `settings` | Não | `0` | Nível de QoS para publicação e assinatura: `0`, `1` ou `2` |
|
||||
|
||||
### Variáveis de ambiente
|
||||
|
||||
| Variável | Campo |
|
||||
|----------|-------|
|
||||
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Reconexão
|
||||
|
||||
O PicoClaw reconecta automaticamente ao broker se a conexão for perdida, com intervalo de 5 segundos. Após a reconexão, a assinatura é restabelecida automaticamente. O ID de cliente no broker permanece o mesmo nas reconexões, permitindo que o broker identifique corretamente a mesma sessão.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Observações
|
||||
|
||||
- **TLS**: SSL/TLS é suportado (URL do broker com `ssl://`). A verificação de certificado é ignorada por padrão.
|
||||
- **Respostas em streaming**: Respostas em streaming enviam múltiplas mensagens para o tópico de resposta; concatene-as na ordem recebida para obter a resposta completa.
|
||||
- **client_id vs ID de sessão**: O `client_id` no caminho do tópico é definido pela sua aplicação cliente e identifica a sessão. É separado do ID de cliente paho usado pelo PicoClaw para se conectar ao broker.
|
||||
- **Múltiplas instâncias**: Se várias instâncias do PicoClaw usarem o mesmo `agent_id` no mesmo broker, defina `client_id` distintos para evitar conflitos no nível do broker.
|
||||
140
docs/channels/mqtt/README.vi.md
Normal file
140
docs/channels/mqtt/README.vi.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# 📡 Kênh MQTT
|
||||
|
||||
PicoClaw hỗ trợ bất kỳ client MQTT nào làm kênh nhắn tin. Thiết bị hoặc dịch vụ publish yêu cầu lên broker; PicoClaw subscribe, xử lý và publish phản hồi trở lại.
|
||||
|
||||
## 🚀 Bắt đầu nhanh
|
||||
|
||||
**1. Thêm kênh vào `~/.picoclaw/config.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "tcp://localhost:1883",
|
||||
"agent_id": "assistant"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. Khởi động gateway:**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
**3. Gửi tin nhắn từ bất kỳ client MQTT nào:**
|
||||
|
||||
```bash
|
||||
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
|
||||
-m '{"text": "CPU đang dùng bao nhiêu phần trăm?"}'
|
||||
```
|
||||
|
||||
**4. Subscribe để nhận phản hồi:**
|
||||
|
||||
```bash
|
||||
mosquitto_sub -t "/picoclaw/assistant/device1/response"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📨 Cấu trúc topic
|
||||
|
||||
```
|
||||
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
|
||||
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
|
||||
```
|
||||
|
||||
| Phân đoạn | Mô tả |
|
||||
|-----------|-------|
|
||||
| `prefix` | Tiền tố topic, cấu hình phía server. Mặc định: `/picoclaw` |
|
||||
| `agent_id` | Định danh instance PicoClaw, đặt trong trường `agent_id` |
|
||||
| `client_id` | Định danh phiên do client xác định — dùng ID ổn định cho mỗi thiết bị để duy trì ngữ cảnh hội thoại |
|
||||
|
||||
### Payload tin nhắn (JSON)
|
||||
|
||||
```json
|
||||
{ "text": "nội dung tin nhắn" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Cấu hình
|
||||
|
||||
### config.json
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "ssl://your-broker:8883",
|
||||
"agent_id": "assistant",
|
||||
"topic_prefix": "/picoclaw",
|
||||
"client_id": "",
|
||||
"keep_alive": 60,
|
||||
"qos": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### .security.yml (thông tin xác thực)
|
||||
|
||||
Tên người dùng và mật khẩu được lưu trong `~/.picoclaw/.security.yml`, không phải trong `config.json`:
|
||||
|
||||
```yaml
|
||||
channel_list:
|
||||
mqtt:
|
||||
settings:
|
||||
username: ten_nguoi_dung
|
||||
password: mat_khau
|
||||
```
|
||||
|
||||
### Các trường cấu hình
|
||||
|
||||
| Trường | Vị trí | Bắt buộc | Mặc định | Mô tả |
|
||||
|--------|--------|----------|----------|-------|
|
||||
| `broker` | `settings` | Có | — | URL của MQTT broker, ví dụ `tcp://host:1883`, `ssl://host:8883` |
|
||||
| `agent_id` | `settings` | Có | — | Định danh agent, dùng làm một phần của đường dẫn topic |
|
||||
| `topic_prefix` | `settings` | Không | `/picoclaw` | Tiền tố không gian tên topic |
|
||||
| `username` | `.security.yml` | Không | — | Tên người dùng xác thực với broker |
|
||||
| `password` | `.security.yml` | Không | — | Mật khẩu xác thực với broker |
|
||||
| `client_id` | `settings` | Không | tự động tạo | Client ID paho gửi đến broker. Tự động tạo dạng `picoclaw-mqtt-{agent_id}-{8 hex}` nếu không đặt; cố định trong suốt vòng đời tiến trình, tái sử dụng khi kết nối lại |
|
||||
| `keep_alive` | `settings` | Không | `60` | Khoảng thời gian keepalive MQTT (giây) |
|
||||
| `qos` | `settings` | Không | `0` | Mức QoS cho publish và subscribe: `0`, `1` hoặc `2` |
|
||||
|
||||
### Biến môi trường
|
||||
|
||||
| Biến | Trường |
|
||||
|------|--------|
|
||||
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Kết nối lại
|
||||
|
||||
PicoClaw tự động kết nối lại với broker nếu mất kết nối, với khoảng thời gian thử lại 5 giây. Sau khi kết nối lại, subscription được tái thiết lập tự động. Client ID phía broker giữ nguyên qua các lần kết nối lại, giúp broker nhận diện chính xác cùng một phiên.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Lưu ý
|
||||
|
||||
- **TLS**: Hỗ trợ SSL/TLS (URL broker dùng `ssl://`). Mặc định bỏ qua xác minh chứng chỉ.
|
||||
- **Phản hồi streaming**: Phản hồi streaming gửi nhiều tin nhắn đến topic response; ghép nối chúng theo thứ tự để có phản hồi đầy đủ.
|
||||
- **client_id và ID phiên**: `client_id` trong đường dẫn topic được đặt bởi ứng dụng client của bạn và xác định phiên hội thoại. Nó khác với client ID paho mà PicoClaw dùng để kết nối broker.
|
||||
- **Nhiều instance**: Nếu nhiều instance PicoClaw dùng cùng `agent_id` trên cùng broker, hãy đặt `client_id` riêng biệt cho từng instance để tránh xung đột ở tầng broker.
|
||||
142
docs/channels/mqtt/README.zh.md
Normal file
142
docs/channels/mqtt/README.zh.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# 📡 MQTT 渠道
|
||||
|
||||
PicoClaw 支持将任意 MQTT 客户端作为消息渠道。设备或服务向 Broker 发布请求,PicoClaw 订阅后处理并将响应发布回去。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
**1. 在 `~/.picoclaw/config.json` 中添加渠道:**
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "tcp://localhost:1883",
|
||||
"agent_id": "assistant"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. 启动网关:**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
**3. 用任意 MQTT 客户端发送消息:**
|
||||
|
||||
```bash
|
||||
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
|
||||
-m '{"text": "查一下CPU使用率"}'
|
||||
```
|
||||
|
||||
**4. 订阅响应:**
|
||||
|
||||
```bash
|
||||
mosquitto_sub -t "/picoclaw/assistant/device1/response"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📨 Topic 结构
|
||||
|
||||
```
|
||||
{prefix}/{agent_id}/{client_id}/request # 客户端 → PicoClaw
|
||||
{prefix}/{agent_id}/{client_id}/response # PicoClaw → 客户端
|
||||
```
|
||||
|
||||
| 段 | 说明 |
|
||||
|----|------|
|
||||
| `prefix` | Topic 前缀,由服务端配置,默认 `/picoclaw` |
|
||||
| `agent_id` | PicoClaw 实例标识,对应配置中的 `agent_id` 字段 |
|
||||
| `client_id` | 客户端自定义会话标识——同一设备保持相同 ID 可维持上下文连续性 |
|
||||
|
||||
### 消息体(JSON)
|
||||
|
||||
```json
|
||||
{ "text": "你的消息内容" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
### config.json
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "ssl://your-broker:8883",
|
||||
"agent_id": "assistant",
|
||||
"topic_prefix": "/picoclaw",
|
||||
"client_id": "",
|
||||
"keep_alive": 60,
|
||||
"qos": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### .security.yml(用户名和密码)
|
||||
|
||||
用户名和密码存储于 `~/.picoclaw/.security.yml`,不写入 `config.json`:
|
||||
|
||||
```yaml
|
||||
channel_list:
|
||||
mqtt:
|
||||
settings:
|
||||
username: your_username
|
||||
password: your_password
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 位置 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `broker` | `settings` | 是 | — | MQTT Broker 地址,如 `tcp://host:1883`、`ssl://host:8883` |
|
||||
| `agent_id` | `settings` | 是 | — | Agent 标识,作为 topic 路径的一部分 |
|
||||
| `topic_prefix` | `settings` | 否 | `/picoclaw` | Topic 命名空间前缀 |
|
||||
| `username` | `.security.yml` | 否 | — | Broker 认证用户名 |
|
||||
| `password` | `.security.yml` | 否 | — | Broker 认证密码 |
|
||||
| `client_id` | `settings` | 否 | 自动生成 | 发送给 Broker 的 paho 客户端 ID。未配置时自动生成为 `picoclaw-mqtt-{agent_id}-{8位hex}`,进程生命周期内固定不变,断线重连时复用同一 ID |
|
||||
| `keep_alive` | `settings` | 否 | `60` | MQTT 心跳间隔(秒) |
|
||||
| `qos` | `settings` | 否 | `0` | 发布和订阅的 QoS 级别:`0`、`1` 或 `2` |
|
||||
|
||||
### 环境变量
|
||||
|
||||
所有字段均可通过环境变量配置:
|
||||
|
||||
| 环境变量 | 对应字段 |
|
||||
|----------|----------|
|
||||
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
|
||||
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 断线重连
|
||||
|
||||
连接断开后 PicoClaw 会自动以 5 秒间隔重连 Broker,重连成功后自动重新订阅。断线重连时复用相同的 Broker 客户端 ID,Broker 能正确识别为同一连接。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
- **TLS**:支持 SSL/TLS(Broker 地址使用 `ssl://`),默认跳过证书验证。
|
||||
- **流式响应**:流式输出时会向 response topic 发送多条消息,客户端按顺序拼接即为完整回复。
|
||||
- **client_id 与会话 ID 的区别**:topic 路径中的 `client_id` 由客户端应用自行设置,用于区分会话;它与 PicoClaw paho 连接 Broker 时使用的客户端 ID 是两个独立的概念。
|
||||
- **多实例部署**:若多个 PicoClaw 实例使用相同 `agent_id` 连接同一 Broker,需为每个实例配置不同的 `client_id` 以避免 Broker 层面的冲突。
|
||||
|
|
@ -15,7 +15,8 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
|
|||
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||
"allow_from": ["123456789"],
|
||||
"proxy": "",
|
||||
"use_markdown_v2": false
|
||||
"use_markdown_v2": false,
|
||||
"media_group_delay_ms": 500
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +29,7 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
|
|||
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
|
||||
| proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) |
|
||||
| use_markdown_v2 | bool | No | Enable Telegram MarkdownV2 formatting |
|
||||
| media_group_delay_ms | int | No | Idle delay before processing Telegram media groups/albums. Defaults to 500 ms |
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
# PicoClaw Hook 系统设计(基于 `refactor/agent`)
|
||||
|
||||
> 当前状态:本文是 hook 系统的早期设计记录。事件系统升级后,观察型 hook 的主路径已经切到
|
||||
> `pkg/events.Event`、`RuntimeEventObserver` 和进程 hook 的 `hook.runtime_event`。
|
||||
> 旧 `agent.Event`、`EventKind`、`hook.event` 兼容层已经删除。
|
||||
|
||||
## 背景
|
||||
|
||||
本设计围绕两个议题展开:
|
||||
|
||||
- `#1316`:把 agent loop 重构为事件驱动、可中断、可追加、可观测
|
||||
- `#1796`:在 EventBus 稳定后,把 hooks 设计为 EventBus 的 consumer,而不是重新发明一套事件模型
|
||||
- `#1796`:在 runtime event bus 稳定后,把 hooks 设计为事件 consumer,而不是重新发明一套事件模型
|
||||
|
||||
当前分支已经完成了第一步里的“事件系统基础”,但还没有真正的 hook 挂载层。因此这里的目标不是重新设计 event,而是在已有实现上补出一层可扩展、可拦截、可外挂的 HookManager。
|
||||
|
||||
|
|
@ -52,20 +56,18 @@ pi-mono 的核心思路更接近当前分支:
|
|||
|
||||
当前分支已经具备 hook 系统的地基:
|
||||
|
||||
- `pkg/agent/events.go` 定义了稳定的 `EventKind`、`EventMeta` 和 payload
|
||||
- `pkg/agent/eventbus.go` 提供了非阻塞 fan-out 的 `EventBus`
|
||||
- `pkg/events` 定义 runtime event envelope、kind、scope、source、severity 和 fan-out bus
|
||||
- `pkg/agent/event_payloads.go` 保留 agent domain payload
|
||||
- agent domain payload 保留在 `pkg/agent/event_payloads.go`
|
||||
- `pkg/agent/loop.go` 中的 `runTurn()` 已在 turn、llm、tool、interrupt、follow-up、summary 等节点发射事件
|
||||
- `pkg/agent/steering.go` 已支持 steering、graceful interrupt、hard abort
|
||||
- `pkg/agent/turn.go` 已维护 turn phase、恢复点、active turn、abort 状态
|
||||
|
||||
### 现有缺口
|
||||
|
||||
当前分支还缺四件事:
|
||||
|
||||
- 没有 HookManager,只有 EventBus
|
||||
- 没有 Before/After LLM、Before/After Tool 这种同步拦截点
|
||||
- 没有审批型 hook
|
||||
- 子 agent 仍走 `pkg/tools/SubagentManager + RunToolLoop`,没有接入 `pkg/agent` 的 turn tree 和事件流
|
||||
早期设计时的缺口包括 HookManager、Before/After LLM、Before/After Tool、审批型 hook
|
||||
以及 sub-turn 接入。当前实现已经覆盖主 turn 的 HookManager、LLM/Tool 拦截和审批;
|
||||
sub-turn 事件已接入 runtime event bus。
|
||||
|
||||
### 一个关键现实
|
||||
|
||||
|
|
@ -73,19 +75,19 @@ pi-mono 的核心思路更接近当前分支:
|
|||
|
||||
## 设计原则
|
||||
|
||||
- Hook 必须建立在 `pkg/agent` 的 EventBus 和 turn 上下文之上
|
||||
- EventBus 负责广播,HookManager 负责拦截,两者职责分离
|
||||
- Hook 必须建立在 `pkg/events` runtime event bus 和 turn 上下文之上
|
||||
- runtime event bus 负责广播,HookManager 负责拦截,两者职责分离
|
||||
- 项目内挂载要简单,项目外挂载必须走 IPC
|
||||
- 观察型 hook 不能阻塞 loop;拦截型 hook 必须有超时
|
||||
- 先覆盖主 turn,不把 sub-turn 一次做满
|
||||
- 不新增第二套用户事件命名系统,优先复用 `EventKind.String()`
|
||||
- 不新增第二套用户事件命名系统,新观察点统一使用 `pkg/events.Kind`
|
||||
|
||||
## 总体架构
|
||||
|
||||
分成三层:
|
||||
|
||||
1. `EventBus`
|
||||
负责广播只读事件,现有实现直接复用
|
||||
1. `pkg/events` runtime event bus
|
||||
负责广播只读事件,覆盖 agent、channel、gateway、bus、MCP 等运行时组件
|
||||
|
||||
2. `HookManager`
|
||||
负责管理 hook、排序、超时、错误隔离,并在 `runTurn()` 的明确检查点执行同步拦截
|
||||
|
|
@ -97,7 +99,7 @@ pi-mono 的核心思路更接近当前分支:
|
|||
|
||||
换句话说:
|
||||
|
||||
- EventBus 是“发生了什么”
|
||||
- runtime event bus 是“发生了什么”
|
||||
- HookManager 是“谁能介入”
|
||||
- HookMount 是“这些 hook 从哪里来”
|
||||
|
||||
|
|
@ -113,11 +115,11 @@ pi-mono 的核心思路更接近当前分支:
|
|||
|
||||
```go
|
||||
type EventObserver interface {
|
||||
OnEvent(ctx context.Context, evt agent.Event) error
|
||||
OnRuntimeEvent(ctx context.Context, evt events.Event) error
|
||||
}
|
||||
```
|
||||
|
||||
这类 hook 直接订阅 EventBus 即可。
|
||||
这类 hook 直接订阅 runtime event bus 即可。
|
||||
|
||||
适用场景:
|
||||
|
||||
|
|
@ -156,7 +158,7 @@ type ToolApprover interface {
|
|||
|
||||
## 对外暴露的最小 hook 面
|
||||
|
||||
V1 不需要把所有 EventKind 都变成可拦截点。
|
||||
V1 不需要把所有 runtime event kind 都变成可拦截点。
|
||||
|
||||
建议只开放这些同步 hook:
|
||||
|
||||
|
|
@ -168,19 +170,19 @@ V1 不需要把所有 EventKind 都变成可拦截点。
|
|||
|
||||
其余节点继续作为只读事件暴露:
|
||||
|
||||
- `turn_start`
|
||||
- `turn_end`
|
||||
- `llm_request`
|
||||
- `llm_response`
|
||||
- `tool_exec_start`
|
||||
- `tool_exec_end`
|
||||
- `tool_exec_skipped`
|
||||
- `steering_injected`
|
||||
- `follow_up_queued`
|
||||
- `interrupt_received`
|
||||
- `context_compress`
|
||||
- `session_summarize`
|
||||
- `error`
|
||||
- `agent.turn.start`
|
||||
- `agent.turn.end`
|
||||
- `agent.llm.request`
|
||||
- `agent.llm.response`
|
||||
- `agent.tool.exec_start`
|
||||
- `agent.tool.exec_end`
|
||||
- `agent.tool.exec_skipped`
|
||||
- `agent.steering.injected`
|
||||
- `agent.follow_up.queued`
|
||||
- `agent.interrupt.received`
|
||||
- `agent.context.compress`
|
||||
- `agent.session.summarize`
|
||||
- `agent.error`
|
||||
|
||||
`subturn_*` 在 V1 中保留名字,但不承诺一定触发,直到子 turn 迁移完成。
|
||||
|
||||
|
|
@ -369,7 +371,7 @@ PicoClaw 启动外部进程,并在其 stdin/stdout 上跑协议。
|
|||
### 观察链路
|
||||
|
||||
```text
|
||||
runTurn() -> emitEvent() -> EventBus -> observers
|
||||
runTurn() -> emitEvent() -> runtime event bus -> observers
|
||||
```
|
||||
|
||||
### 拦截链路
|
||||
|
|
@ -453,7 +455,7 @@ V1 不做复杂自动发现。
|
|||
### Phase 3
|
||||
|
||||
- 把 `SubagentManager` 迁移到 `runTurn/sub-turn`
|
||||
- 接通 `subturn_spawn` / `subturn_end` / `subturn_result_delivered`
|
||||
- 接通 `agent.subturn.spawn` / `agent.subturn.end` / `agent.subturn.result_delivered`
|
||||
|
||||
### Phase 4
|
||||
|
||||
|
|
@ -464,13 +466,13 @@ V1 不做复杂自动发现。
|
|||
|
||||
最适合 PicoClaw 当前分支的方案,不是直接复制 OpenClaw 的 hooks,也不是完整照搬 pi-mono 的 extension system,而是:
|
||||
|
||||
- 以现有 `EventBus` 为只读观察面
|
||||
- 以 `pkg/events` runtime event bus 为只读观察面
|
||||
- 以新增 `HookManager` 为同步拦截面
|
||||
- 项目内通过 Go 对象直接挂载
|
||||
- 项目外通过 `stdio JSON-RPC` 进程通信挂载
|
||||
|
||||
这样做有三个好处:
|
||||
|
||||
- 和 `#1796` 一致,hooks 只是 EventBus 之上的消费层
|
||||
- 和 `#1796` 一致,hooks 只是 runtime event bus 之上的消费层
|
||||
- 和当前 `refactor/agent` 实现一致,不需要推翻已有事件系统
|
||||
- 同时满足“仓内简单挂载”和“仓外进程通信挂载”两个硬需求
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
## 💬 Applications de Chat
|
||||
|
||||
Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot ou MaixCam.
|
||||
Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT ou MaixCam.
|
||||
|
||||
> **Note** : Tous les canaux basés sur les webhooks (LINE, WeCom, etc.) sont servis sur un seul serveur HTTP Gateway partagé (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Il n'y a pas de ports par canal à configurer. Note : Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP webhook partagé.
|
||||
|
||||
|
|
@ -23,6 +23,7 @@ Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, Din
|
|||
| **Feishu (飞书)** | ⭐⭐⭐ Avancé | Collaboration entreprise, fonctionnalités riches | [Documentation](../channels/feishu/README.fr.md) |
|
||||
| **IRC** | ⭐⭐ Moyen | Serveur + configuration TLS | [Documentation](#irc) |
|
||||
| **OneBot** | ⭐⭐ Moyen | Compatible NapCat/Go-CQHTTP, écosystème communautaire | [Documentation](../channels/onebot/README.fr.md) |
|
||||
| **MQTT** | ⭐ Facile | N'importe quel client MQTT via broker pub/sub | [Documentation](../channels/mqtt/README.fr.md) |
|
||||
| **MaixCam** | ⭐ Facile | Canal d'intégration matérielle pour caméras AI Sipeed | [Documentation](../channels/maixcam/README.fr.md) |
|
||||
| **Pico** | ⭐ Facile | Canal protocole natif PicoClaw | |
|
||||
|
||||
|
|
@ -681,3 +682,67 @@ picoclaw gateway
|
|||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="mqtt"></a>
|
||||
<details>
|
||||
<summary><b>MQTT</b></summary>
|
||||
|
||||
N'importe quel client MQTT peut communiquer avec PicoClaw via un broker. Les appareils ou services publient des requêtes vers le broker ; PicoClaw s'abonne, les traite et publie les réponses en retour.
|
||||
|
||||
**1. Configurer**
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "ssl://votre-broker:8883",
|
||||
"agent_id": "assistant",
|
||||
"topic_prefix": "/picoclaw",
|
||||
"keep_alive": 60,
|
||||
"qos": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Nom d'utilisateur et mot de passe dans `~/.picoclaw/.security.yml` :
|
||||
|
||||
```yaml
|
||||
channel_list:
|
||||
mqtt:
|
||||
settings:
|
||||
username: votre_utilisateur
|
||||
password: votre_mot_de_passe
|
||||
```
|
||||
|
||||
**Format des topics**
|
||||
|
||||
```
|
||||
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
|
||||
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
|
||||
```
|
||||
|
||||
Le `client_id` est défini par votre application cliente pour identifier les appareils ou sessions.
|
||||
|
||||
**2. Lancer**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
**3. Tester**
|
||||
|
||||
```bash
|
||||
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
|
||||
-m '{"text": "Bonjour"}'
|
||||
|
||||
mosquitto_sub -t "/picoclaw/assistant/device1/response"
|
||||
```
|
||||
|
||||
Pour les options complètes, voir [Documentation du canal MQTT](../channels/mqtt/README.fr.md).
|
||||
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ PicoClaw は複数のチャットプラットフォームをサポートして
|
|||
| **Feishu (飛書)** | ⭐⭐⭐ やや難 | エンタープライズコラボレーション、機能豊富 | [ドキュメント](../channels/feishu/README.ja.md) |
|
||||
| **IRC** | ⭐⭐ 中程度 | サーバー + TLS 設定 | [ドキュメント](#irc) |
|
||||
| **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.ja.md) |
|
||||
| **MQTT** | ⭐ 簡単 | ブローカー経由で任意の MQTT クライアントと通信 | [ドキュメント](../channels/mqtt/README.ja.md) |
|
||||
| **MaixCam** | ⭐ 簡単 | Sipeed AI カメラハードウェア統合チャネル | [ドキュメント](../channels/maixcam/README.ja.md) |
|
||||
| **Pico** | ⭐ 簡単 | PicoClaw ネイティブプロトコルチャネル | |
|
||||
|
||||
|
|
@ -670,3 +671,67 @@ picoclaw gateway
|
|||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="mqtt"></a>
|
||||
<details>
|
||||
<summary><b>MQTT</b></summary>
|
||||
|
||||
任意の MQTT クライアントがブローカーを介して PicoClaw と通信できます。デバイスやサービスがブローカーにリクエストをパブリッシュし、PicoClaw がサブスクライブして処理し、レスポンスをパブリッシュして返します。
|
||||
|
||||
**1. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "ssl://your-broker:8883",
|
||||
"agent_id": "assistant",
|
||||
"topic_prefix": "/picoclaw",
|
||||
"keep_alive": 60,
|
||||
"qos": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
ユーザー名とパスワードは `~/.picoclaw/.security.yml` に記載します:
|
||||
|
||||
```yaml
|
||||
channel_list:
|
||||
mqtt:
|
||||
settings:
|
||||
username: your_username
|
||||
password: your_password
|
||||
```
|
||||
|
||||
**トピック形式**
|
||||
|
||||
```
|
||||
{prefix}/{agent_id}/{client_id}/request # クライアント → PicoClaw
|
||||
{prefix}/{agent_id}/{client_id}/response # PicoClaw → クライアント
|
||||
```
|
||||
|
||||
`client_id` はクライアントアプリケーションがデバイスやセッションを識別するために設定します。
|
||||
|
||||
**2. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
**3. テスト**
|
||||
|
||||
```bash
|
||||
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
|
||||
-m '{"text": "こんにちは"}'
|
||||
|
||||
mosquitto_sub -t "/picoclaw/assistant/device1/response"
|
||||
```
|
||||
|
||||
完全な設定オプションは [MQTT チャンネルドキュメント](../channels/mqtt/README.ja.md) を参照してください。
|
||||
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
## 💬 Chat Apps
|
||||
|
||||
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, or Pico (native protocol)
|
||||
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT, MaixCam, or Pico (native protocol)
|
||||
|
||||
> **Note**: Channels that rely on HTTP callbacks share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Socket/stream-based channels such as Feishu, DingTalk, and WeCom do not rely on the shared webhook server for inbound delivery.
|
||||
|
||||
|
|
@ -23,6 +23,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
|
|||
| **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](../channels/feishu/README.md) |
|
||||
| **IRC** | ⭐⭐ Medium | Server + TLS configuration | [Docs](#irc) |
|
||||
| **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](../channels/onebot/README.md) |
|
||||
| **MQTT** | ⭐ Easy | Any MQTT client via broker pub/sub | [Docs](../channels/mqtt/README.md) |
|
||||
| **MaixCam** | ⭐ Easy | Hardware integration channel for Sipeed AI cameras | [Docs](../channels/maixcam/README.md) |
|
||||
| **Pico** | ⭐ Easy | Native PicoClaw protocol channel | |
|
||||
|
||||
|
|
@ -587,3 +588,69 @@ picoclaw gateway
|
|||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="mqtt"></a>
|
||||
<details>
|
||||
<summary><b>MQTT</b></summary>
|
||||
|
||||
Any MQTT client can communicate with PicoClaw via a broker. Devices or services publish requests to the broker; PicoClaw subscribes, processes them, and publishes responses back.
|
||||
|
||||
**1. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "ssl://your-broker:8883",
|
||||
"agent_id": "assistant",
|
||||
"topic_prefix": "/picoclaw",
|
||||
"keep_alive": 60,
|
||||
"qos": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Username and password go in `~/.picoclaw/.security.yml`:
|
||||
|
||||
```yaml
|
||||
channel_list:
|
||||
mqtt:
|
||||
settings:
|
||||
username: your_username
|
||||
password: your_password
|
||||
```
|
||||
|
||||
**Topic format**
|
||||
|
||||
```
|
||||
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
|
||||
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
|
||||
```
|
||||
|
||||
`client_id` is set by your client application to identify different devices or sessions.
|
||||
|
||||
**2. Run**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
**3. Test**
|
||||
|
||||
```bash
|
||||
# Send a message
|
||||
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
|
||||
-m '{"text": "Hello"}'
|
||||
|
||||
# Subscribe to responses
|
||||
mosquitto_sub -t "/picoclaw/assistant/device1/response"
|
||||
```
|
||||
|
||||
For full configuration options see [MQTT Channel Docs](../channels/mqtt/README.md).
|
||||
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
## 💬 Aplikasi Sembang
|
||||
|
||||
Berbual dengan picoclaw anda melalui Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, atau Pico (protokol asli)
|
||||
Berbual dengan picoclaw anda melalui Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT, MaixCam, atau Pico (protokol asli)
|
||||
|
||||
> **Nota**: Semua saluran berasaskan webhook (LINE, WeCom, dan sebagainya) diservis pada satu pelayan HTTP Gateway yang dikongsi (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Tiada port khusus per saluran untuk dikonfigurasikan. Nota: Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP webhook yang dikongsi.
|
||||
|
||||
|
|
@ -22,6 +22,7 @@ Berbual dengan picoclaw anda melalui Telegram, Discord, WhatsApp, Matrix, QQ, Di
|
|||
| **Slack** | Sederhana (Bot token + App token) |
|
||||
| **IRC** | Sederhana (pelayan + konfigurasi TLS) |
|
||||
| **OneBot** | Sederhana (QQ melalui protokol OneBot) |
|
||||
| **MQTT** | Mudah (broker + agent_id) |
|
||||
| **MaixCam** | Mudah (integrasi perkakasan Sipeed) |
|
||||
| **Pico** | Protokol PicoClaw asli |
|
||||
|
||||
|
|
@ -445,3 +446,67 @@ picoclaw gateway
|
|||
> **Nota**: WeCom AI Bot menggunakan protokol streaming pull — tiada isu timeout balasan. Tugasan panjang (>30 saat) akan bertukar secara automatik kepada penghantaran push `response_url`.
|
||||
|
||||
</details>
|
||||
|
||||
<a id="mqtt"></a>
|
||||
<details>
|
||||
<summary><b>MQTT</b></summary>
|
||||
|
||||
Mana-mana client MQTT boleh berkomunikasi dengan PicoClaw melalui broker. Peranti atau perkhidmatan menerbitkan permintaan ke broker; PicoClaw melanggan, memproses dan menerbitkan respons kembali.
|
||||
|
||||
**1. Konfigurasi**
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "ssl://your-broker:8883",
|
||||
"agent_id": "assistant",
|
||||
"topic_prefix": "/picoclaw",
|
||||
"keep_alive": 60,
|
||||
"qos": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Nama pengguna dan kata laluan dalam `~/.picoclaw/.security.yml`:
|
||||
|
||||
```yaml
|
||||
channel_list:
|
||||
mqtt:
|
||||
settings:
|
||||
username: nama_pengguna
|
||||
password: kata_laluan
|
||||
```
|
||||
|
||||
**Format topik**
|
||||
|
||||
```
|
||||
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
|
||||
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
|
||||
```
|
||||
|
||||
`client_id` ditetapkan oleh aplikasi client anda untuk mengenal pasti peranti atau sesi.
|
||||
|
||||
**2. Jalankan**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
**3. Uji**
|
||||
|
||||
```bash
|
||||
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
|
||||
-m '{"text": "Helo"}'
|
||||
|
||||
mosquitto_sub -t "/picoclaw/assistant/device1/response"
|
||||
```
|
||||
|
||||
Untuk semua pilihan konfigurasi, lihat [Dokumentasi Saluran MQTT](../channels/mqtt/README.md).
|
||||
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
## 💬 Aplicativos de Chat
|
||||
|
||||
Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot ou MaixCam
|
||||
Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT ou MaixCam
|
||||
|
||||
> **Nota**: Todos os canais baseados em webhook (LINE, WeCom, etc.) são servidos em um único servidor HTTP Gateway compartilhado (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). Não há portas por canal para configurar. Nota: Feishu usa o modo WebSocket/SDK e não utiliza o servidor HTTP webhook compartilhado.
|
||||
|
||||
|
|
@ -23,6 +23,7 @@ Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, D
|
|||
| **Feishu (飞书)** | ⭐⭐⭐ Avançado | Colaboração empresarial, rico em recursos | [Documentação](../channels/feishu/README.pt-br.md) |
|
||||
| **IRC** | ⭐⭐ Médio | Servidor + configuração TLS | [Documentação](#irc) |
|
||||
| **OneBot** | ⭐⭐ Médio | Compatível com NapCat/Go-CQHTTP, ecossistema comunitário | [Documentação](../channels/onebot/README.pt-br.md) |
|
||||
| **MQTT** | ⭐ Fácil | Qualquer cliente MQTT via broker pub/sub | [Documentação](../channels/mqtt/README.pt-br.md) |
|
||||
| **MaixCam** | ⭐ Fácil | Canal de integração de hardware para câmeras AI Sipeed | [Documentação](../channels/maixcam/README.pt-br.md) |
|
||||
| **Pico** | ⭐ Fácil | Canal de protocolo nativo PicoClaw | |
|
||||
|
||||
|
|
@ -695,3 +696,67 @@ picoclaw gateway
|
|||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="mqtt"></a>
|
||||
<details>
|
||||
<summary><b>MQTT</b></summary>
|
||||
|
||||
Qualquer cliente MQTT pode se comunicar com o PicoClaw via broker. Dispositivos ou serviços publicam requisições para o broker; o PicoClaw assina, processa e publica as respostas de volta.
|
||||
|
||||
**1. Configurar**
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "ssl://seu-broker:8883",
|
||||
"agent_id": "assistant",
|
||||
"topic_prefix": "/picoclaw",
|
||||
"keep_alive": 60,
|
||||
"qos": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Nome de usuário e senha em `~/.picoclaw/.security.yml`:
|
||||
|
||||
```yaml
|
||||
channel_list:
|
||||
mqtt:
|
||||
settings:
|
||||
username: seu_usuario
|
||||
password: sua_senha
|
||||
```
|
||||
|
||||
**Formato dos tópicos**
|
||||
|
||||
```
|
||||
{prefix}/{agent_id}/{client_id}/request # Cliente → PicoClaw
|
||||
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Cliente
|
||||
```
|
||||
|
||||
O `client_id` é definido pela sua aplicação cliente para identificar dispositivos ou sessões.
|
||||
|
||||
**2. Iniciar**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
**3. Testar**
|
||||
|
||||
```bash
|
||||
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
|
||||
-m '{"text": "Olá"}'
|
||||
|
||||
mosquitto_sub -t "/picoclaw/assistant/device1/response"
|
||||
```
|
||||
|
||||
Para todas as opções de configuração, veja a [Documentação do Canal MQTT](../channels/mqtt/README.pt-br.md).
|
||||
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
## 💬 Ứng Dụng Chat
|
||||
|
||||
Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot hoặc MaixCam
|
||||
Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT hoặc MaixCam
|
||||
|
||||
> **Lưu ý**: Tất cả các kênh dựa trên webhook (LINE, WeCom, v.v.) được phục vụ trên một máy chủ HTTP Gateway chung (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Không có port riêng cho từng kênh. Lưu ý: Feishu sử dụng chế độ WebSocket/SDK và không sử dụng máy chủ HTTP webhook chung.
|
||||
|
||||
|
|
@ -23,6 +23,7 @@ Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix
|
|||
| **Feishu (飞书)** | ⭐⭐⭐ Nâng cao | Cộng tác doanh nghiệp, nhiều tính năng | [Tài liệu](../channels/feishu/README.vi.md) |
|
||||
| **IRC** | ⭐⭐ Trung bình | Máy chủ + cấu hình TLS | [Tài liệu](#irc) |
|
||||
| **OneBot** | ⭐⭐ Trung bình | Tương thích NapCat/Go-CQHTTP, hệ sinh thái cộng đồng | [Tài liệu](../channels/onebot/README.vi.md) |
|
||||
| **MQTT** | ⭐ Dễ | Bất kỳ client MQTT nào qua broker pub/sub | [Tài liệu](../channels/mqtt/README.vi.md) |
|
||||
| **MaixCam** | ⭐ Dễ | Kênh tích hợp phần cứng cho camera AI Sipeed | [Tài liệu](../channels/maixcam/README.vi.md) |
|
||||
| **Pico** | ⭐ Dễ | Kênh giao thức bản địa PicoClaw | |
|
||||
|
||||
|
|
@ -696,3 +697,67 @@ picoclaw gateway
|
|||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="mqtt"></a>
|
||||
<details>
|
||||
<summary><b>MQTT</b></summary>
|
||||
|
||||
Bất kỳ client MQTT nào đều có thể giao tiếp với PicoClaw qua broker. Thiết bị hoặc dịch vụ publish yêu cầu lên broker; PicoClaw subscribe, xử lý và publish phản hồi trở lại.
|
||||
|
||||
**1. Cấu hình**
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "ssl://your-broker:8883",
|
||||
"agent_id": "assistant",
|
||||
"topic_prefix": "/picoclaw",
|
||||
"keep_alive": 60,
|
||||
"qos": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Tên người dùng và mật khẩu trong `~/.picoclaw/.security.yml`:
|
||||
|
||||
```yaml
|
||||
channel_list:
|
||||
mqtt:
|
||||
settings:
|
||||
username: ten_nguoi_dung
|
||||
password: mat_khau
|
||||
```
|
||||
|
||||
**Định dạng topic**
|
||||
|
||||
```
|
||||
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
|
||||
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
|
||||
```
|
||||
|
||||
`client_id` do ứng dụng client đặt để phân biệt thiết bị hoặc phiên.
|
||||
|
||||
**2. Khởi động**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
**3. Kiểm tra**
|
||||
|
||||
```bash
|
||||
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
|
||||
-m '{"text": "Xin chào"}'
|
||||
|
||||
mosquitto_sub -t "/picoclaw/assistant/device1/response"
|
||||
```
|
||||
|
||||
Xem đầy đủ tùy chọn cấu hình tại [Tài liệu Kênh MQTT](../channels/mqtt/README.vi.md).
|
||||
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
## 💬 聊天应用集成 (Chat Apps)
|
||||
|
||||
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
|
||||
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方,包括 Telegram、Discord、WhatsApp、微信、QQ、钉钉、LINE、企业微信、飞书、Slack、IRC、OneBot、MQTT、MaixCam 等。
|
||||
|
||||
> **注意**: 依赖 HTTP 回调的渠道共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。飞书、钉钉、企业微信这类 Socket/Stream 模式渠道不依赖共享 webhook 服务器来接收入站消息。
|
||||
|
||||
|
|
@ -25,6 +25,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
|||
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](../channels/feishu/README.zh.md) |
|
||||
| **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | [查看文档](#irc) |
|
||||
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) |
|
||||
| **MQTT** | ⭐ 简单 | 任意 MQTT 客户端通过 Broker 收发消息 | [查看文档](../channels/mqtt/README.zh.md) |
|
||||
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](../channels/maixcam/README.zh.md) |
|
||||
| **Pico** | ⭐ 简单 | PicoClaw 原生协议通道 | |
|
||||
|
||||
|
|
@ -610,3 +611,69 @@ picoclaw gateway
|
|||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="mqtt"></a>
|
||||
<details>
|
||||
<summary><b>MQTT</b></summary>
|
||||
|
||||
任意 MQTT 客户端均可通过 Broker 与 PicoClaw 通信。设备或服务向 Broker 发布请求,PicoClaw 订阅后处理并将响应发布回去。
|
||||
|
||||
**1. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"mqtt": {
|
||||
"enabled": true,
|
||||
"type": "mqtt",
|
||||
"settings": {
|
||||
"broker": "ssl://your-broker:8883",
|
||||
"agent_id": "assistant",
|
||||
"topic_prefix": "/picoclaw",
|
||||
"keep_alive": 60,
|
||||
"qos": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
用户名和密码存储于 `~/.picoclaw/.security.yml`:
|
||||
|
||||
```yaml
|
||||
channel_list:
|
||||
mqtt:
|
||||
settings:
|
||||
username: your_username
|
||||
password: your_password
|
||||
```
|
||||
|
||||
**Topic 格式**
|
||||
|
||||
```
|
||||
{prefix}/{agent_id}/{client_id}/request # 客户端 → PicoClaw
|
||||
{prefix}/{agent_id}/{client_id}/response # PicoClaw → 客户端
|
||||
```
|
||||
|
||||
`client_id` 由客户端自行指定,用于区分不同设备或会话。
|
||||
|
||||
**2. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
**3. 测试**
|
||||
|
||||
```bash
|
||||
# 发送消息
|
||||
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
|
||||
-m '{"text": "你好"}'
|
||||
|
||||
# 订阅响应
|
||||
mosquitto_sub -t "/picoclaw/assistant/device1/response"
|
||||
```
|
||||
|
||||
完整配置选项请参考 [MQTT 渠道文档](../channels/mqtt/README.zh.md)。
|
||||
|
||||
</details>
|
||||
|
|
|
|||
281
docs/guides/configuration.it.md
Normal file
281
docs/guides/configuration.it.md
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
# ⚙️ Guida alla Configurazione
|
||||
|
||||
> Torna al [README](../../README.md)
|
||||
|
||||
## ⚙️ Configurazione
|
||||
|
||||
File di configurazione: `~/.picoclaw/config.json`
|
||||
|
||||
### Variabili d'Ambiente
|
||||
|
||||
Puoi sovrascrivere i percorsi predefiniti usando variabili d'ambiente. Questo è utile per installazioni portatili, distribuzioni containerizzate, o per eseguire picoclaw come servizio di sistema. Queste variabili sono indipendenti e controllano percorsi diversi.
|
||||
|
||||
| Variabile | Descrizione | Percorso Predefinito |
|
||||
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PICOCLAW_CONFIG` | Sovrascrive il percorso al file di configurazione. Indica direttamente a picoclaw quale `config.json` caricare, ignorando tutte le altre posizioni. | `~/.picoclaw/config.json` |
|
||||
| `PICOCLAW_HOME` | Sovrascrive la directory radice per i dati di picoclaw. Modifica la posizione predefinita del `workspace` e delle altre directory dati. | `~/.picoclaw` |
|
||||
|
||||
**Esempi:**
|
||||
|
||||
```bash
|
||||
# Esegui picoclaw usando un file di configurazione specifico
|
||||
# Il percorso del workspace verrà letto da quel file di configurazione
|
||||
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||
|
||||
# Esegui picoclaw con tutti i dati salvati in /opt/picoclaw
|
||||
# La configurazione verrà caricata dal percorso predefinito ~/.picoclaw/config.json
|
||||
# Il workspace verrà creato in /opt/picoclaw/workspace
|
||||
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||
|
||||
# Usa entrambi per un setup completamente personalizzato
|
||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||
```
|
||||
|
||||
### Struttura del Workspace
|
||||
|
||||
PicoClaw salva i dati nel workspace configurato (predefinito: `~/.picoclaw/workspace`):
|
||||
|
||||
```
|
||||
~/.picoclaw/workspace/
|
||||
├── sessions/ # Sessioni di conversazione e cronologia
|
||||
├── memory/ # Memoria a lungo termine (MEMORY.md)
|
||||
├── state/ # Stato persistente (ultimo canale, ecc.)
|
||||
├── cron/ # Database dei job pianificati
|
||||
├── skills/ # Skill personalizzate
|
||||
├── AGENT.md # Guida al comportamento dell'agent
|
||||
├── HEARTBEAT.md # Prompt per task periodici (controllato ogni 30 min)
|
||||
├── SOUL.md # Anima dell'agent
|
||||
└── USER.md # Preferenze dell'utente
|
||||
```
|
||||
|
||||
> **Nota:** Le modifiche a `AGENT.md`, `SOUL.md`, `USER.md` e `memory/MEMORY.md` vengono rilevate automaticamente a runtime tramite il tracciamento della data di modifica (mtime). **Non è necessario riavviare il gateway** dopo aver modificato questi file — l'agent caricherà il nuovo contenuto alla prossima richiesta.
|
||||
|
||||
### Sorgenti delle Skill
|
||||
|
||||
Per impostazione predefinita, le skill vengono caricate da:
|
||||
|
||||
1. `~/.picoclaw/workspace/skills` (workspace)
|
||||
2. `~/.picoclaw/skills` (globale)
|
||||
3. `<current-working-directory>/skills` (builtin)
|
||||
|
||||
Per configurazioni avanzate/di test, puoi sovrascrivere la directory radice delle skill builtin con:
|
||||
|
||||
```bash
|
||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||
```
|
||||
|
||||
### Politica Unificata di Esecuzione dei Comandi
|
||||
|
||||
- I comandi slash generici vengono eseguiti tramite un unico percorso in `pkg/agent/loop.go` via `commands.Executor`.
|
||||
- Gli adattatori dei canali non consumano più localmente i comandi generici; inoltrano il testo in entrata al percorso bus/agent. Telegram registra ancora automaticamente i comandi supportati all'avvio.
|
||||
- Un comando slash sconosciuto (ad esempio `/foo`) viene passato all'elaborazione LLM come se fosse un messaggio dell'utente.
|
||||
- Un comando registrato ma non supportato sul canale corrente (ad esempio `/show` su WhatsApp) restituisce un errore esplicito all'utente e interrompe l'elaborazione.
|
||||
|
||||
### Allowlist dei Tool per Agent
|
||||
|
||||
La dichiarazione dei tool per-agent vive nel frontmatter di `AGENT.md`, non in `config.json`.
|
||||
|
||||
Se `tools` è omesso nel frontmatter, l'agent riceve il normale set globale dei tool abilitati. Se `tools` è presente, PicoClaw registra per quell'agent solo i tool runtime elencati.
|
||||
|
||||
```md
|
||||
---
|
||||
name: Research Agent
|
||||
description: Specialista per ricerca web e analisi approfondita.
|
||||
tools: [read_file, write_file, web_search, web_fetch, message]
|
||||
skills: [deep-research]
|
||||
mcpServers: [web-index]
|
||||
---
|
||||
|
||||
Sei l'agent di ricerca.
|
||||
```
|
||||
|
||||
Note:
|
||||
|
||||
- È una allowlist reale, non un suggerimento per l'LLM.
|
||||
- I nomi dei tool fanno match 1:1 con il nome runtime del tool.
|
||||
- Se ti serve controllo preciso, usa i nomi runtime effettivi come `web_search`, `web_fetch`, `spawn`, `subagent`, `send_file`.
|
||||
- Le dichiarazioni dei tool in `AGENT.md` sono usate dal runtime e dai tool, ma non vengono iniettate nel prompt di discovery.
|
||||
|
||||
### Discovery Multi-Agent (Automatica)
|
||||
|
||||
Quando un agent ha peer spawnabili, PicoClaw inietta automaticamente nel suo system prompt un registry strutturato dei peer. Non serve una chiamata aggiuntiva a un tool `list_agents`.
|
||||
|
||||
Questa discovery serve soprattutto a rendere affidabile la delega tramite `spawn` con `agent_id` esplicito.
|
||||
|
||||
Ogni entry include:
|
||||
|
||||
| Campo | Significato |
|
||||
|-------|-------------|
|
||||
| `id` | ID stabile dell'agent |
|
||||
| `name` | Nome identitario da `AGENT.md` frontmatter |
|
||||
| `description` | Descrizione identitaria da `AGENT.md` frontmatter |
|
||||
|
||||
Dettagli importanti:
|
||||
|
||||
- La sezione include solo i peer che l'agent corrente può spawnare tramite `subagents.allow_agents`.
|
||||
- L'agent corrente e i peer non spawnabili vengono omessi, così il modello non pianifica contro agent non disponibili.
|
||||
- La discovery è volutamente leggera. Fornisce al modello solo l'identità necessaria per scegliere un peer: `id`, `name`, `description`.
|
||||
- `config.json` resta il layer infrastrutturale: workspace, agent di default, routing e permessi di subagent. Questi permessi controllano anche la visibilità nella discovery.
|
||||
- `AGENT.md` resta il layer di identità. Il codice runtime e i tool possono comunque usare `tools`, `skills`, `mcpServers` e `model` quando avviene la delega.
|
||||
|
||||
Forma dell'oggetto iniettato:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"id": "research",
|
||||
"name": "Research Agent",
|
||||
"description": "Specialista per investigazioni e lavoro web."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
In pratica, un agent generalista sceglie un peer in base alla descrizione del suo ruolo, poi chiama `spawn` con l'`agent_id` del peer. Il runtime risolve il resto.
|
||||
|
||||
### 🔒 Sandbox di Sicurezza
|
||||
|
||||
PicoClaw esegue in un ambiente sandboxed per impostazione predefinita. L'agent può accedere solo ai file ed eseguire comandi all'interno del workspace configurato.
|
||||
|
||||
#### Configurazione Predefinita
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Opzione | Predefinito | Descrizione |
|
||||
| ----------------------- | ----------------------- | ---------------------------------------------------- |
|
||||
| `workspace` | `~/.picoclaw/workspace` | Directory di lavoro dell'agent |
|
||||
| `restrict_to_workspace` | `true` | Limita l'accesso a file/comandi al workspace |
|
||||
|
||||
#### Strumenti Protetti
|
||||
|
||||
Quando `restrict_to_workspace: true`, i seguenti strumenti sono in sandbox:
|
||||
|
||||
| Strumento | Funzione | Restrizione |
|
||||
| ------------- | ------------------------- | ---------------------------------------------------- |
|
||||
| `read_file` | Legge file | Solo file all'interno del workspace |
|
||||
| `write_file` | Scrive file | Solo file all'interno del workspace |
|
||||
| `list_dir` | Elenca directory | Solo directory all'interno del workspace |
|
||||
| `edit_file` | Modifica file | Solo file all'interno del workspace |
|
||||
| `append_file` | Aggiunge ai file | Solo file all'interno del workspace |
|
||||
| `exec` | Esegue comandi | I percorsi dei comandi devono essere nel workspace |
|
||||
|
||||
#### Protezione Exec Aggiuntiva
|
||||
|
||||
Anche con `restrict_to_workspace: false`, lo strumento `exec` blocca questi comandi pericolosi:
|
||||
|
||||
* `rm -rf`, `del /f`, `rmdir /s` — Cancellazione di massa
|
||||
* `format`, `mkfs`, `diskpart` — Formattazione del disco
|
||||
* `dd if=` — Imaging del disco
|
||||
* Scrittura su `/dev/sd[a-z]` — Scritture dirette su disco
|
||||
* `shutdown`, `reboot`, `poweroff` — Spegnimento del sistema
|
||||
* Fork bomb `:(){ :|:& };:`
|
||||
|
||||
### Controllo Accesso ai File
|
||||
|
||||
| Chiave di configurazione | Tipo | Predefinito | Descrizione |
|
||||
|--------------------------|------|-------------|-------------|
|
||||
| `tools.allow_read_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la lettura al di fuori del workspace |
|
||||
| `tools.allow_write_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la scrittura al di fuori del workspace |
|
||||
|
||||
### Sicurezza Exec
|
||||
|
||||
| Chiave di configurazione | Tipo | Predefinito | Descrizione |
|
||||
|--------------------------|------|-------------|-------------|
|
||||
| `tools.exec.allow_remote` | bool | `false` | Consente lo strumento exec da canali remoti (Telegram/Discord ecc.) |
|
||||
| `tools.exec.enable_deny_patterns` | bool | `true` | Abilita l'intercettazione dei comandi pericolosi |
|
||||
| `tools.exec.custom_deny_patterns` | string[] | `[]` | Pattern regex personalizzati da bloccare |
|
||||
| `tools.exec.custom_allow_patterns` | string[] | `[]` | Pattern regex personalizzati da consentire |
|
||||
|
||||
> **Nota di sicurezza:** La protezione dei symlink è abilitata per impostazione predefinita — tutti i percorsi file vengono risolti tramite `filepath.EvalSymlinks` prima del confronto con la whitelist, prevenendo attacchi di escape tramite symlink.
|
||||
|
||||
#### Limitazione Nota: Processi Figlio degli Strumenti di Build
|
||||
|
||||
Il controllo di sicurezza exec ispeziona solo la riga di comando avviata direttamente da PicoClaw. Non ispeziona ricorsivamente i processi figlio generati da strumenti di sviluppo consentiti come `make`, `go run`, `cargo`, `npm run` o script di build personalizzati.
|
||||
|
||||
Ciò significa che un comando di primo livello può comunque compilare o avviare altri binari dopo aver superato il controllo iniziale. In pratica, tratta gli script di build, i Makefile, gli script di pacchetti e i binari generati come codice eseguibile che richiede lo stesso livello di revisione di un comando shell diretto.
|
||||
|
||||
Per ambienti ad alto rischio:
|
||||
|
||||
* Esamina gli script di build prima dell'esecuzione.
|
||||
* Preferisci l'approvazione/revisione manuale per i workflow di compilazione ed esecuzione.
|
||||
* Esegui PicoClaw in un container o VM se hai bisogno di un isolamento più forte di quello fornito dal controllo integrato.
|
||||
|
||||
#### Esempi di Errore
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (path outside working dir)}
|
||||
```
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
|
||||
```
|
||||
|
||||
#### Disabilitare le Restrizioni (Rischio di Sicurezza)
|
||||
|
||||
Se hai bisogno che l'agent acceda a percorsi al di fuori del workspace:
|
||||
|
||||
**Metodo 1: File di configurazione**
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"restrict_to_workspace": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Metodo 2: Variabile d'ambiente**
|
||||
|
||||
```bash
|
||||
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||
```
|
||||
|
||||
> ⚠️ **Attenzione**: Disabilitare questa restrizione consente all'agent di accedere a qualsiasi percorso sul tuo sistema. Usare con cautela solo in ambienti controllati.
|
||||
|
||||
#### Coerenza dei Confini di Sicurezza
|
||||
|
||||
L'impostazione `restrict_to_workspace` si applica in modo coerente a tutti i percorsi di esecuzione:
|
||||
|
||||
| Percorso di esecuzione | Confine di sicurezza |
|
||||
| ---------------------- | --------------------------------- |
|
||||
| Main Agent | `restrict_to_workspace` ✅ |
|
||||
| Subagent / Spawn | Eredita la stessa restrizione ✅ |
|
||||
| Heartbeat tasks | Eredita la stessa restrizione ✅ |
|
||||
|
||||
Tutti i percorsi condividono la stessa restrizione del workspace — non è possibile aggirare il confine di sicurezza tramite subagent o task pianificati.
|
||||
|
||||
### Heartbeat (Task Periodici)
|
||||
|
||||
PicoClaw può eseguire task periodici automaticamente. Crea un file `HEARTBEAT.md` nel tuo workspace:
|
||||
|
||||
```markdown
|
||||
# Periodic Tasks
|
||||
|
||||
- Check my email for important messages
|
||||
- Review my calendar for upcoming events
|
||||
- Check the weather forecast
|
||||
```
|
||||
|
||||
L'agent leggerà questo file ogni 30 minuti (configurabile) ed eseguirà tutti i task usando gli strumenti disponibili.
|
||||
|
||||
#### Task Asincroni con Spawn
|
||||
|
||||
Per task di lunga durata (ricerca web, chiamate API), usa lo strumento `spawn` per creare un **subagent**:
|
||||
|
||||
```markdown
|
||||
# Periodic Tasks
|
||||
```
|
||||
|
|
@ -69,6 +69,36 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
|
|||
|
||||
> **Note:** Changes to `AGENT.md`, `SOUL.md`, `USER.md` and `memory/MEMORY.md` are automatically detected at runtime via file modification time (mtime) tracking. You do **not** need to restart the gateway after editing these files — the agent picks up the new content on the next request.
|
||||
|
||||
### Agent Self-Evolution
|
||||
|
||||
The `evolution` block controls PicoClaw's self-evolution runtime. When enabled, the agent records completed turns as learning records. In higher modes it can group repeated successful patterns, generate skill drafts, and optionally apply accepted drafts into workspace skills.
|
||||
|
||||
```json
|
||||
{
|
||||
"evolution": {
|
||||
"enabled": false,
|
||||
"mode": "observe",
|
||||
"state_dir": "",
|
||||
"min_task_count": 2,
|
||||
"min_success_ratio": 0.7,
|
||||
"cold_path_trigger": "after_turn",
|
||||
"cold_path_times": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `enabled` | `false` | Enables learning-record capture for completed agent turns. Heartbeat turns are ignored. |
|
||||
| `mode` | `observe` | `observe` records data only. `draft` can generate candidate skill drafts. `apply` can apply accepted drafts to workspace skills. |
|
||||
| `state_dir` | `""` | Optional directory for evolution state. Leave empty to use the default under the workspace. |
|
||||
| `min_task_count` | `2` | Minimum related task records required before a pattern is eligible for draft generation. |
|
||||
| `min_success_ratio` | `0.7` | Minimum success ratio for a task cluster. Use a value greater than `0` and up to `1`. |
|
||||
| `cold_path_trigger` | `after_turn` | Runs draft generation `after_turn`, on a `scheduled` cadence, or disables automatic cold-path runs when set to `manual`. There is no user-facing manual trigger yet. Applies only in `draft` and `apply` modes. |
|
||||
| `cold_path_times` | `[]` | Scheduled run times used when `cold_path_trigger` is `scheduled`, written as `HH:MM` strings. |
|
||||
|
||||
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.
|
||||
|
||||
### 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`.
|
||||
|
|
@ -211,6 +241,69 @@ earlier and broader fallback rules later.
|
|||
|
||||
For more complete routing and model-tier examples, see the [Routing Guide](routing-guide.md).
|
||||
|
||||
### Agent Tool Allowlist
|
||||
|
||||
Per-agent tool declarations live in `AGENT.md` frontmatter, not in `config.json`.
|
||||
|
||||
If `tools` is omitted from frontmatter, the agent gets the normal globally enabled tool set. If `tools` is present, PicoClaw registers only the listed runtime tools for that agent.
|
||||
|
||||
```md
|
||||
---
|
||||
name: Research Agent
|
||||
description: Specialist for web research and in-depth analysis.
|
||||
tools: [read_file, write_file, web_search, web_fetch, message]
|
||||
skills: [deep-research]
|
||||
mcpServers: [web-index]
|
||||
---
|
||||
|
||||
You are the research agent.
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- This is an allowlist, not a preference hint.
|
||||
- Tool names are matched against the runtime tool name 1:1.
|
||||
- Use runtime tool names such as `web_search`, `web_fetch`, `spawn`, `subagent`, `send_file`.
|
||||
- Tool declarations in `AGENT.md` are used by runtime/tooling, but they are not injected into the discovery prompt.
|
||||
|
||||
### Agent Discovery (Automatic)
|
||||
|
||||
When an agent has spawnable peers and can call `spawn`, PicoClaw injects a structured agent registry into that agent's system prompt on every turn. No extra `list_agents` tool call is required.
|
||||
|
||||
This registry is intended to make delegation concrete and reliable, especially when using `spawn` with a target `agent_id`.
|
||||
|
||||
Each entry includes:
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `id` | Stable agent id |
|
||||
| `name` | Agent identity name from `AGENT.md` frontmatter |
|
||||
| `description` | Agent identity description from `AGENT.md` frontmatter |
|
||||
|
||||
Important behavior:
|
||||
|
||||
- The discovery section appears only when the current agent has the `spawn` tool and includes only peer agents it is permitted to spawn via `subagents.allow_agents`.
|
||||
- The current agent and non-spawnable peers are omitted, so the model does not plan against unavailable agents.
|
||||
- Discovery is intentionally lightweight. It gives the model only the identity it needs to choose a peer: `id`, `name`, and `description`.
|
||||
- `config.json` remains the infrastructure layer: workspace, default agent selection, routing, and subagent permissions. Those permissions also gate discovery visibility.
|
||||
- `AGENT.md` remains the identity layer. Runtime/tool code can still use its `tools`, `skills`, `mcpServers`, and `model` fields when delegation happens.
|
||||
|
||||
Example injected shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"id": "research",
|
||||
"name": "Research Agent",
|
||||
"description": "Specialist for long-form investigation and web work."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
In practice, this means a generalist agent can choose a peer based on its role description, then call `spawn` with the peer's `agent_id`. The runtime resolves the rest.
|
||||
|
||||
### 🔒 Security Sandbox
|
||||
|
||||
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
||||
|
|
|
|||
|
|
@ -67,6 +67,36 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
|||
|
||||
> **提示:** 对 `AGENT.md`、`SOUL.md`、`USER.md` 和 `memory/MEMORY.md` 的修改会通过文件修改时间(mtime)在运行时自动检测。**无需重启 gateway**,Agent 将在下一次请求时自动加载最新内容。
|
||||
|
||||
### Agent 自进化
|
||||
|
||||
`evolution` 配置块控制 PicoClaw 的自进化运行时。启用后,Agent 会把已完成的回合记录为学习记录。在更高模式下,它可以聚类重复出现的成功模式、生成技能草稿,并可选择把已接受的草稿应用到工作区技能中。
|
||||
|
||||
```json
|
||||
{
|
||||
"evolution": {
|
||||
"enabled": false,
|
||||
"mode": "observe",
|
||||
"state_dir": "",
|
||||
"min_task_count": 2,
|
||||
"min_success_ratio": 0.7,
|
||||
"cold_path_trigger": "after_turn",
|
||||
"cold_path_times": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `enabled` | `false` | 启用已完成 Agent 回合的学习记录采集。Heartbeat 回合会被忽略。 |
|
||||
| `mode` | `observe` | `observe` 只记录数据;`draft` 可生成候选技能草稿;`apply` 可将已接受草稿应用到工作区技能。 |
|
||||
| `state_dir` | `""` | 自进化状态的可选目录。留空时使用工作区下的默认位置。 |
|
||||
| `min_task_count` | `2` | 一个模式具备生成草稿资格前所需的最小相关任务记录数。 |
|
||||
| `min_success_ratio` | `0.7` | 任务聚类所需的最小成功率,取值需大于 `0`,且不超过 `1`。 |
|
||||
| `cold_path_trigger` | `after_turn` | 草稿生成可在 `after_turn` 后运行、按 `scheduled` 定时运行;设置为 `manual` 时会关闭自动冷路径运行。目前还没有用户可用的手动触发入口。仅在 `draft` 和 `apply` 模式下生效。 |
|
||||
| `cold_path_times` | `[]` | 当 `cold_path_trigger` 为 `scheduled` 时使用的运行时间,格式为 `HH:MM` 字符串。 |
|
||||
|
||||
如果你只想先检查学习记录,建议从 `observe` 开始。需要生成可审查改进时使用 `draft`。只有在你接受让已通过的草稿更新工作区技能时,才使用 `apply`。
|
||||
|
||||
### Web 启动器控制台
|
||||
|
||||
用 **picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`。
|
||||
|
|
@ -753,6 +783,42 @@ PicoClaw 按协议族路由提供商:
|
|||
|
||||
</details>
|
||||
|
||||
### 事件日志
|
||||
|
||||
PicoClaw 的 runtime events 会覆盖 agent、channel、gateway、message bus 和 MCP 等运行时组件。默认只打印 `agent.*` 事件,其他事件仍会发布到 runtime event bus,但不会进入日志。
|
||||
|
||||
```json
|
||||
{
|
||||
"events": {
|
||||
"logging": {
|
||||
"enabled": true,
|
||||
"include": ["agent.*"],
|
||||
"exclude": [],
|
||||
"min_severity": "info",
|
||||
"include_payload": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
常用配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"events": {
|
||||
"logging": {
|
||||
"include": ["*"],
|
||||
"exclude": ["agent.llm.delta"],
|
||||
"min_severity": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`include` / `exclude` 支持精确事件名和 `gateway.*`、`channel.lifecycle.*` 这类模式。`include_payload` 默认关闭,避免把完整用户消息或工具参数写入日志;agent 事件会默认输出长度、计数、状态等摘要字段。
|
||||
|
||||
更多字段说明和示例见 [Runtime Events 与事件日志](../architecture/runtime-events.zh.md)。
|
||||
|
||||
### 定时任务 / 提醒
|
||||
|
||||
PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设置、列出和取消在指定时间触发的提醒或周期性任务。
|
||||
|
|
@ -775,6 +841,7 @@ PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设
|
|||
| 主题 | 说明 |
|
||||
| ---- | ---- |
|
||||
| [敏感数据过滤](../security/sensitive_data_filtering.zh.md) | 在发送给 LLM 前,从工具结果中过滤 API 密钥和令牌 |
|
||||
| [Runtime Events 与事件日志](../architecture/runtime-events.zh.md) | 统一运行时事件、日志过滤和调试配置 |
|
||||
| [Hook 系统](../architecture/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook |
|
||||
| [Steering](../architecture/steering.md) | 在工具调用间向运行中的 Agent 注入消息 |
|
||||
| [SubTurn](../architecture/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 |
|
||||
|
|
|
|||
|
|
@ -116,23 +116,47 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
|
||||
#### `model_list` Entry Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `model_name` | string | Yes | Unique name used to reference this model in agent config |
|
||||
| `provider` | string | No | Preferred provider identifier. When present, PicoClaw sends `model` unchanged to that provider |
|
||||
| `model` | string | Yes | Native model ID when `provider` is set. If `provider` is omitted, the legacy `provider/model` form is still supported |
|
||||
| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) |
|
||||
| `api_base` | string | No | Override the default API endpoint URL |
|
||||
| `proxy` | string | No | HTTP proxy URL for this model entry |
|
||||
| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Gemini, Anthropic, and Azure providers) |
|
||||
| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) |
|
||||
| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) |
|
||||
| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` |
|
||||
| `extra_body` | object | No | Additional fields to inject into every request body |
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `model_name` | string | Yes | Unique name used to reference this model in agent config |
|
||||
| `provider` | string | No | Preferred provider identifier. When present, PicoClaw sends `model` unchanged to that provider |
|
||||
| `model` | string | Yes | Native model ID when `provider` is set. If `provider` is omitted, the legacy `provider/model` form is still supported |
|
||||
| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) |
|
||||
| `api_base` | string | No | Override the default API endpoint URL |
|
||||
| `proxy` | string | No | HTTP proxy URL for this model entry |
|
||||
| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Gemini, Anthropic, and Azure providers) |
|
||||
| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) |
|
||||
| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) |
|
||||
| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` |
|
||||
| `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`). |
|
||||
| `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`) |
|
||||
| `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`) |
|
||||
|
||||
#### Tool Schema Compatibility
|
||||
|
||||
By default, PicoClaw now forwards tool JSON Schemas unchanged.
|
||||
|
||||
Some providers reject advanced JSON Schema features such as `$ref`, `$defs`, `anyOf`, `oneOf`, `allOf`, `pattern`, or numeric/string constraints inside tool declarations. For those models, you can opt into a compatibility transform per model entry with `tool_schema_transform`.
|
||||
|
||||
Use `simple` when the upstream provider expects the conservative style function schema subset:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "gemini-2.5-flash-safe-tools",
|
||||
"provider": "gemini",
|
||||
"model": "gemini-2.5-flash",
|
||||
"api_keys": ["your-gemini-key"],
|
||||
"tool_schema_transform": "simple"
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Default behavior is disabled. If you omit `tool_schema_transform`, PicoClaw sends the original tool schema.
|
||||
- The setting is per model entry, so you can enable it only for the providers that need it.
|
||||
|
||||
#### Provider / Model Resolution
|
||||
|
||||
|
|
@ -393,10 +417,8 @@ It also applies cooldown tracking per candidate to avoid immediately retrying a
|
|||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": {
|
||||
"primary": "qwen-main",
|
||||
"fallbacks": ["deepseek-backup", "gemini-backup"]
|
||||
}
|
||||
"model_name": "qwen-main",
|
||||
"model_fallbacks": ["deepseek-backup", "gemini-backup"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -362,10 +362,8 @@ PicoClaw 按下面的规则解析 `provider` 和最终发给上游的模型 ID
|
|||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": {
|
||||
"primary": "qwen-main",
|
||||
"fallbacks": ["deepseek-backup", "gemini-backup"]
|
||||
}
|
||||
"model_name": "qwen-main",
|
||||
"model_fallbacks": ["deepseek-backup", "gemini-backup"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,14 @@
|
|||
|
||||
## 📢 Actualités
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw disponible sur AliExpress !** Vous pouvez désormais acheter le LicheeRV-Claw sur [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), ce qui facilite l'essai de PicoClaw sur du matériel RISC-V compact.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Support Android !** PicoClaw fonctionne maintenant sur Android ! Téléchargez l'APK sur [picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 publiée !** Refonte de l'architecture Agent (SubTurn, Hooks, Steering, EventBus), intégration WeChat/WeCom, renforcement de la sécurité (.security.yml, filtrage des données sensibles), nouveaux providers (AWS Bedrock, Azure, Xiaomi MiMo), et 35 corrections de bugs. PicoClaw a atteint **26K Stars** !
|
||||
|
|
@ -346,6 +354,7 @@ Cela crée `~/.picoclaw/config.json` et le répertoire workspace.
|
|||
|
||||
```json
|
||||
{
|
||||
"version": 3,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
|
|
@ -355,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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -479,7 +488,7 @@ PicoClaw peut effectuer des recherches sur le web pour fournir des informations
|
|||
| Moteur de recherche | Clé API | Niveau gratuit | Lien |
|
||||
|--------------------|---------|----------------|------|
|
||||
| DuckDuckGo | Non requise | Illimité | Fallback intégré |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Requise | 1000 requêtes/jour | IA, optimisé pour le chinois |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Requise | 1500 requêtes/mois (allocation journalière) | IA, optimisé pour le chinois |
|
||||
| [Tavily](https://tavily.com) | Requise | 1000 requêtes/mois | Optimisé pour les Agents IA |
|
||||
| [Brave Search](https://brave.com/search/api) | Requise | 2000 requêtes/mois | Rapide et privé |
|
||||
| [Perplexity](https://www.perplexity.ai) | Requise | Payant | Recherche propulsée par IA |
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 Berita
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Kini Anda dapat membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), sehingga lebih mudah mencoba PicoClaw di hardware RISC-V ringkas.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Dukungan Android!** PicoClaw sekarang berjalan di Android! Unduh APK di [picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 Dirilis!** Perombakan arsitektur Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keamanan (.security.yml, penyaringan data sensitif), provider baru (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 perbaikan bug. PicoClaw telah mencapai **26K Stars**!
|
||||
|
|
@ -342,6 +350,7 @@ Ini membuat `~/.picoclaw/config.json` dan direktori workspace.
|
|||
|
||||
```json
|
||||
{
|
||||
"version": 3,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
|
|
@ -351,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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -474,7 +483,7 @@ PicoClaw dapat mencari web untuk memberikan informasi terkini. Konfigurasi di `t
|
|||
| Mesin Pencari | API Key | Tier Gratis | Tautan |
|
||||
|--------------|---------|-------------|--------|
|
||||
| DuckDuckGo | Tidak perlu | Tidak terbatas | Fallback bawaan |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 kueri/hari | Bertenaga AI, dioptimalkan untuk bahasa Mandarin |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1500 kueri/bulan (alokasi harian) | Bertenaga AI, dioptimalkan untuk bahasa Mandarin |
|
||||
| [Tavily](https://tavily.com) | Diperlukan | 1000 kueri/bulan | Dioptimalkan untuk AI Agent |
|
||||
| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 kueri/bulan | Cepat dan privat |
|
||||
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Pencarian bertenaga AI |
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 Novità
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw disponibile su AliExpress!** Ora puoi acquistare LicheeRV-Claw su [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), rendendo più semplice provare PicoClaw su hardware RISC-V compatto.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Supporto Android!** PicoClaw ora funziona su Android! Scarica l'APK su [picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 rilasciata!** Revisione dell'architettura Agent (SubTurn, Hooks, Steering, EventBus), integrazione WeChat/WeCom, rafforzamento della sicurezza (.security.yml, filtraggio dati sensibili), nuovi provider (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correzioni di bug. PicoClaw raggiunge **26K Stars**!
|
||||
|
|
@ -342,6 +350,7 @@ Questo crea `~/.picoclaw/config.json` e la directory workspace.
|
|||
|
||||
```json
|
||||
{
|
||||
"version": 3,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
|
|
@ -351,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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -474,7 +483,7 @@ PicoClaw può cercare sul web per fornire informazioni aggiornate. Configura in
|
|||
| Motore di Ricerca | API Key | Piano Gratuito | Link |
|
||||
|-------------------|---------|----------------|------|
|
||||
| DuckDuckGo | Non necessaria | Illimitato | Fallback integrato |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Richiesta | 1000 query/giorno | IA, ottimizzato per il cinese |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Richiesta | 1500 query/mese (allocazione giornaliera) | IA, ottimizzato per il cinese |
|
||||
| [Tavily](https://tavily.com) | Richiesta | 1000 query/mese | Ottimizzato per AI Agent |
|
||||
| [Brave Search](https://brave.com/search/api) | Richiesta | 2000 query/mese | Veloce e privato |
|
||||
| [Perplexity](https://www.perplexity.ai) | Richiesta | A pagamento | Ricerca potenziata dall'IA |
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 ニュース
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw が AliExpress で購入可能に!** [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) から LicheeRV-Claw を購入できるようになり、コンパクトな RISC-V ハードウェアで PicoClaw を試しやすくなりました。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Android サポート!** PicoClawがAndroidで動作!APKは[picoclaw.io](https://picoclaw.io/download)からダウンロード
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 リリース!** Agent アーキテクチャ全面刷新(SubTurn、Hooks、Steering、EventBus)、WeChat/WeCom 統合、セキュリティ強化(.security.yml、機密データフィルタリング)、新プロバイダー(AWS Bedrock、Azure、Xiaomi MiMo)、35 件のバグ修正。PicoClaw **26K ⭐** 達成!
|
||||
|
|
@ -343,6 +351,7 @@ picoclaw onboard
|
|||
|
||||
```json
|
||||
{
|
||||
"version": 3,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
|
|
@ -352,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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -475,7 +484,7 @@ PicoClaw は最新情報を提供するために Web を検索できます。`to
|
|||
| 検索エンジン | API キー | 無料枠 | リンク |
|
||||
|------------|---------|--------|-------|
|
||||
| DuckDuckGo | 不要 | 無制限 | 内蔵フォールバック |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必須 | 1000 クエリ/日 | AI 搭載、中国語に最適化 |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必須 | 1500 クエリ/月(日次割り当て) | AI 搭載、中国語に最適化 |
|
||||
| [Tavily](https://tavily.com) | 必須 | 1000 クエリ/月 | AI Agent 向けに最適化 |
|
||||
| [Brave Search](https://brave.com/search/api) | 必須 | 2000 クエリ/月 | 高速でプライベート |
|
||||
| [Perplexity](https://www.perplexity.ai) | 必須 | 有料 | AI 搭載検索 |
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 뉴스
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw를 AliExpress에서 구매할 수 있습니다!** 이제 [AliExpress](https://www.aliexpress.com/item/1005006519668532.html)에서 LicheeRV-Claw를 구매해 소형 RISC-V 하드웨어에서 PicoClaw를 더 쉽게 사용해 볼 수 있습니다.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Android 지원!** PicoClaw가 이제 Android에서 실행됩니다! APK는 [picoclaw.io](https://picoclaw.io/download)에서 다운로드하세요.
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 출시!** 에이전트 아키텍처 전면 개편(SubTurn, Hooks, Steering, EventBus), WeChat/WeCom 통합, 보안 강화(`.security.yml`, 민감 정보 필터링), 새 프로바이더(AWS Bedrock, Azure, Xiaomi MiMo), 그리고 35건의 버그 수정이 포함되었습니다. PicoClaw는 **26K 스타**를 달성했습니다!
|
||||
|
|
@ -480,7 +488,7 @@ PicoClaw는 최신 정보를 제공하기 위해 웹 검색을 수행할 수 있
|
|||
| 검색 엔진 | API Key | 무료 제공량 | 링크 |
|
||||
|-----------|---------|-------------|------|
|
||||
| DuckDuckGo | 불필요 | 무제한 | 내장 백업 검색 |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 필수 | 하루 1000회 쿼리 | AI 기반, 중국 시장 최적화 |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 필수 | 월 1500회 쿼리 (일할 할당) | AI 기반, 중국 시장 최적화 |
|
||||
| [Tavily](https://tavily.com) | 필수 | 월 1000회 쿼리 | AI 에이전트에 최적화 |
|
||||
| [Brave Search](https://brave.com/search/api) | 필수 | 월 2000회 쿼리 | 빠르고 프라이빗함 |
|
||||
| [Perplexity](https://www.perplexity.ai) | 필수 | 유료 | AI 기반 검색 |
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 Berita
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Anda kini boleh membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), menjadikannya lebih mudah untuk mencuba PicoClaw pada perkakasan RISC-V yang kompak.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Sokongan Android!** PicoClaw sekarang berjalan di Android! Muat turun APK di [picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 Dikeluarkan!** Penstrukturan semula seni bina Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keselamatan (.security.yml, penapisan data sensitif), penyedia baharu (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 pembetulan pepijat. PicoClaw mencapai **26K Stars**!
|
||||
|
|
@ -474,7 +482,7 @@ PicoClaw boleh mencari web untuk menyediakan maklumat terkini. Konfigurasikan da
|
|||
| Enjin Carian | Kunci API | Peringkat Percuma | Pautan |
|
||||
|-------------|-----------|-------------------|--------|
|
||||
| DuckDuckGo | Tidak perlu | Tanpa had | Sandaran terbina dalam |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 pertanyaan/hari | Dikuasai AI, dioptimumkan untuk China |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1500 pertanyaan/bulan (peruntukan harian) | Dikuasai AI, dioptimumkan untuk China |
|
||||
| [Tavily](https://tavily.com) | Diperlukan | 1000 pertanyaan/bulan | Dioptimumkan untuk AI Agent |
|
||||
| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 pertanyaan/bulan | Pantas dan peribadi |
|
||||
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Carian dikuasai AI |
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 Novidades
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw no AliExpress!** Agora você pode comprar o LicheeRV-Claw no [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), facilitando testar o PicoClaw em hardware RISC-V compacto.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Suporte Android!** PicoClaw agora roda no Android! Baixe o APK em [picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 Lançada!** Reformulação da arquitetura Agent (SubTurn, Hooks, Steering, EventBus), integração WeChat/WeCom, fortalecimento de segurança (.security.yml, filtragem de dados sensíveis), novos providers (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correções de bugs. O PicoClaw atingiu **26K Stars**!
|
||||
|
|
@ -343,6 +351,7 @@ Isso cria `~/.picoclaw/config.json` e o diretório workspace.
|
|||
|
||||
```json
|
||||
{
|
||||
"version": 3,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
|
|
@ -352,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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -475,7 +484,7 @@ O PicoClaw pode pesquisar na web para fornecer informações atualizadas. Config
|
|||
| Motor de Busca | API Key | Nível Gratuito | Link |
|
||||
|----------------|---------|----------------|------|
|
||||
| DuckDuckGo | Não necessária | Ilimitado | Fallback integrado |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Obrigatória | 1000 consultas/dia | IA, otimizado para chinês |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Obrigatória | 1500 consultas/mês (alocação diária) | IA, otimizado para chinês |
|
||||
| [Tavily](https://tavily.com) | Obrigatória | 1000 consultas/mês | Otimizado para AI Agents |
|
||||
| [Brave Search](https://brave.com/search/api) | Obrigatória | 2000 consultas/mês | Rápido e privado |
|
||||
| [Perplexity](https://www.perplexity.ai) | Obrigatória | Pago | Busca com IA |
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 Tin tức
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw đã có trên AliExpress!** Bạn hiện có thể mua LicheeRV-Claw trên [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), giúp việc thử PicoClaw trên phần cứng RISC-V nhỏ gọn dễ dàng hơn.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Hỗ trợ Android!** PicoClaw giờ chạy trên Android! Tải APK tại [picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 đã phát hành!** Tái cấu trúc kiến trúc Agent (SubTurn, Hooks, Steering, EventBus), tích hợp WeChat/WeCom, tăng cường bảo mật (.security.yml, lọc dữ liệu nhạy cảm), provider mới (AWS Bedrock, Azure, Xiaomi MiMo) và 35 bản vá lỗi. PicoClaw đã đạt **26K Stars**!
|
||||
|
|
@ -343,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"
|
||||
|
|
@ -352,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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -475,7 +484,7 @@ PicoClaw có thể tìm kiếm web để cung cấp thông tin cập nhật. C
|
|||
| Công cụ Tìm kiếm | API Key | Gói miễn phí | Liên kết |
|
||||
|------------------|---------|--------------|----------|
|
||||
| DuckDuckGo | Không cần | Không giới hạn | Dự phòng tích hợp sẵn |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Bắt buộc | 1000 truy vấn/ngày | AI, tối ưu cho tiếng Trung |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Bắt buộc | 1500 truy vấn/tháng (phân bổ hàng ngày) | AI, tối ưu cho tiếng Trung |
|
||||
| [Tavily](https://tavily.com) | Bắt buộc | 1000 truy vấn/tháng | Tối ưu cho AI Agent |
|
||||
| [Brave Search](https://brave.com/search/api) | Bắt buộc | 2000 truy vấn/tháng | Nhanh và riêng tư |
|
||||
| [Perplexity](https://www.perplexity.ai) | Bắt buộc | Trả phí | Tìm kiếm hỗ trợ AI |
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@
|
|||
|
||||
## 📢 新闻
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw 已上架淘宝!** 现在可以在 [淘宝](https://item.taobao.com/item.htm?abbucket=20&id=764939520376) 购买 LicheeRV-Claw,更方便地在小型 RISC-V 硬件上体验 PicoClaw。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://item.taobao.com/item.htm?abbucket=20&id=764939520376">
|
||||
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on Taobao" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
2026-03-31 📱 **Android 支持!** PicoClaw 现可在 Android 上运行!APK 下载地址:[picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 发布!** Agent 架构全面重构(SubTurn、Hook、Steering、EventBus)、微信/企业微信深度集成、安全体系升级(.security.yml、敏感数据过滤)、新增 Provider(AWS Bedrock、Azure、小米 MiMo),以及 35 项 Bug 修复。PicoClaw 已达 **26K ⭐**!
|
||||
|
|
@ -144,9 +152,9 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入
|
|||
|
||||
PicoClaw 几乎可以部署在任何 Linux 设备上!
|
||||
|
||||
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控
|
||||
- $9.9 [LicheeRV-Nano](https://item.taobao.com/item.htm?id=764939520376) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手
|
||||
- $30~50 [NanoKVM](https://item.taobao.com/item.htm?id=811206560480),或 $100 [NanoKVM-Pro](https://item.taobao.com/item.htm?id=994419942411),用于自动化服务器运维
|
||||
- $50 [MaixCAM](https://item.taobao.com/item.htm?id=784724795837) 或 $100 [MaixCAM2](https://item.taobao.com/item.htm?id=1050380368975),用于智能监控
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
|
|
@ -343,6 +351,7 @@ picoclaw onboard
|
|||
|
||||
```json
|
||||
{
|
||||
"version": 3,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
|
|
@ -352,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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -475,7 +484,7 @@ PicoClaw 可以搜索网络以提供最新信息。在 `tools.web` 中配置:
|
|||
|
||||
| 搜索引擎 | API Key | 免费额度 | 链接 |
|
||||
|---------|---------|---------|------|
|
||||
| [百度搜索](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必填 | 1000 次/天 | AI 搜索,国内首选 |
|
||||
| [百度搜索](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必填 | 1500 次/月(按天发放) | AI 搜索,国内首选 |
|
||||
| [Tavily](https://tavily.com) | 必填 | 1000 次/月 | 专为 AI Agent 优化 |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | 必填 | 视情况 | 智谱网络搜索 |
|
||||
| DuckDuckGo | 无需 | 无限制 | 内置备用(国内访问困难) |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -282,4 +283,3 @@ New config (version 3):
|
|||
- Check that the migration doesn't overwrite values with defaults unnecessarily
|
||||
- Review the conversion logic in the loader functions
|
||||
- Check the auto-backup files (e.g., `config.json.20260330.bak`) to recover original data
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -89,6 +89,13 @@ channels:
|
|||
nickserv_password: "your-irc-nickserv-password"
|
||||
sasl_password: "your-irc-sasl-password"
|
||||
|
||||
# Channel Settings (nested format for channels that use settings block)
|
||||
channel_list:
|
||||
mqtt:
|
||||
settings:
|
||||
username: "your-mqtt-username"
|
||||
password: "your-mqtt-password"
|
||||
|
||||
# Web Tool API Keys
|
||||
web:
|
||||
brave:
|
||||
|
|
@ -145,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": {
|
||||
|
|
@ -226,6 +233,19 @@ channels:
|
|||
- `channels.feishu.app_secret` → `config.channels.feishu.app_secret`
|
||||
- etc.
|
||||
|
||||
Channels that use a `settings` block (e.g. MQTT) use the `channel_list` key instead:
|
||||
|
||||
```yaml
|
||||
channel_list:
|
||||
mqtt:
|
||||
settings:
|
||||
username: "value"
|
||||
password: "value"
|
||||
```
|
||||
|
||||
- `channel_list.mqtt.settings.username` → `config.channel_list.mqtt.settings.username`
|
||||
- `channel_list.mqtt.settings.password` → `config.channel_list.mqtt.settings.password`
|
||||
|
||||
### Web Tools
|
||||
|
||||
**Brave, Tavily, Perplexity:**
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
74
go.mod
74
go.mod
|
|
@ -1,38 +1,41 @@
|
|||
module github.com/sipeed/picoclaw
|
||||
|
||||
go 1.25.9
|
||||
go 1.25.10
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.0
|
||||
fyne.io/systray v1.12.1
|
||||
github.com/SevereCloud/vksdk/v3 v3.3.1
|
||||
github.com/adhocore/gronx v1.19.6
|
||||
github.com/adhocore/gronx v1.19.7
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.6
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.16
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5
|
||||
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/bwmarrin/discordgo v0.29.0
|
||||
github.com/caarlos0/env/v11 v11.4.0
|
||||
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.5.4
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.5
|
||||
github.com/line/line-bot-sdk-go/v8 v8.19.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/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/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
|
||||
|
|
@ -40,31 +43,31 @@ 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.50.1
|
||||
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.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
|
||||
github.com/aws/smithy-go v1.25.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/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beeper/argo-go v1.1.2 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
|
|
@ -88,7 +91,6 @@ 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
|
|
@ -103,24 +105,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.2
|
||||
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
|
||||
|
|
@ -128,14 +130,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/crypto v0.51.0
|
||||
golang.org/x/net v0.54.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/sys v0.44.0
|
||||
)
|
||||
|
||||
replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532
|
||||
|
|
|
|||
152
go.sum
152
go.sum
|
|
@ -3,66 +3,66 @@ aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
|
|||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
|
||||
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
|
||||
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
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.19.7 h1:7hhFwChgDw9eHC3+TQ+OKKBqJnP44oWkDCnnW9nrsuA=
|
||||
github.com/adhocore/gronx v1.19.7/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/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||
github.com/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.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5 h1:ZGTl4Rxft1uyENAlGESY04hMzE4cLLNUPI7dGw08haw=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5/go.mod h1:jnugA+VgESQGgXuEKK6zVToET/DtODq7LQYpe+BkKT4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo=
|
||||
github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
|
||||
github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
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/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/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/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/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
|
||||
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
|
||||
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
|
|
@ -95,6 +95,8 @@ github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
|||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||
github.com/ergochat/irc-go v0.6.0 h1:Y0AGV76aeihJfCtLaQh+OyJKFiKGrYC0VTkeMZ6XW28=
|
||||
|
|
@ -142,8 +144,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
|
|
@ -164,8 +166,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
|||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
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=
|
||||
|
|
@ -177,8 +179,10 @@ 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.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.5 h1:dimv+ZAGia01f4xCDGvCiBHKWMf4K1AB7fGsM+lv5Jw=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.5/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/line/line-bot-sdk-go/v8 v8.19.0 h1:5FD/1SprRZ8Y0FiUI6syYiBewOs0ak2tuUBMYN0wzE4=
|
||||
github.com/line/line-bot-sdk-go/v8 v8.19.0/go.mod h1:AeSRUuu7WGgveGDJb6DyKyFUOst2UB2aF6LO2cQeuXs=
|
||||
github.com/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/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
|
|
@ -197,8 +201,8 @@ github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzB
|
|||
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
|
||||
github.com/mymmrac/telego v1.8.0/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=
|
||||
|
|
@ -242,8 +246,8 @@ 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=
|
||||
|
|
@ -279,8 +283,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=
|
||||
|
|
@ -326,8 +330,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=
|
||||
|
|
@ -350,8 +354,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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
|
|
@ -384,16 +388,16 @@ 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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.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=
|
||||
|
|
@ -401,8 +405,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=
|
||||
|
|
@ -445,10 +449,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=
|
||||
|
|
@ -457,18 +461,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.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/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=
|
||||
|
|
|
|||
255
integration/README.md
Normal file
255
integration/README.md
Normal file
|
|
@ -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.
|
||||
19
integration/docker-compose.runner.yml
Normal file
19
integration/docker-compose.runner.yml
Normal file
|
|
@ -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:
|
||||
23
integration/fixtures/mcp-streamable-server/Dockerfile
Normal file
23
integration/fixtures/mcp-streamable-server/Dockerfile
Normal file
|
|
@ -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"]
|
||||
71
integration/fixtures/mcp-streamable-server/main.go
Normal file
71
integration/fixtures/mcp-streamable-server/main.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
156
integration/run_integration_tests_script_test.go
Normal file
156
integration/run_integration_tests_script_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
19
integration/suites/mcp-streamable/docker-compose.yml
Normal file
19
integration/suites/mcp-streamable/docker-compose.yml
Normal file
|
|
@ -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"
|
||||
1
integration/suites/mcp-streamable/suite.env
Normal file
1
integration/suites/mcp-streamable/suite.env
Normal file
|
|
@ -0,0 +1 @@
|
|||
TEST_COMMAND='go test ./pkg/mcp -run TestIntegration_RealConfiguredServer -v'
|
||||
|
|
@ -21,6 +21,7 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/commands"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/constants"
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
|
|
@ -37,9 +38,13 @@ type AgentLoop struct {
|
|||
registry *AgentRegistry
|
||||
state *state.Manager
|
||||
|
||||
// Event system (from Incoming)
|
||||
eventBus *EventBus
|
||||
hooks *HookManager
|
||||
// Runtime event system
|
||||
runtimeEvents runtimeevents.Bus
|
||||
ownsRuntimeEvents bool
|
||||
runtimeEventLogMu sync.RWMutex
|
||||
runtimeEventLogger *runtimeEventLogger
|
||||
runtimeEventLogSub runtimeevents.Subscription
|
||||
hooks *HookManager
|
||||
|
||||
// Runtime state
|
||||
running atomic.Bool
|
||||
|
|
@ -50,9 +55,11 @@ type AgentLoop struct {
|
|||
transcriber asr.Transcriber
|
||||
cmdRegistry *commands.Registry
|
||||
mcp mcpRuntime
|
||||
evolution *evolutionBridge
|
||||
hookRuntime hookRuntime
|
||||
steering *steeringQueue
|
||||
pendingSkills sync.Map
|
||||
pendingStops sync.Map
|
||||
mu sync.RWMutex
|
||||
|
||||
// workerSem limits concurrent turn processing workers.
|
||||
|
|
@ -172,6 +179,12 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
|||
phase: TurnPhaseSetup,
|
||||
}
|
||||
if _, loaded := al.activeTurnStates.LoadOrStore(sessionKey, placeholder); loaded {
|
||||
if al.tryHandleStopCommand(ctx, msg, sessionKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
msg = al.prepareInboundMessageForAgent(ctx, msg)
|
||||
|
||||
// Another turn is already active (or reserved) for this session — enqueue
|
||||
if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{
|
||||
Role: "user",
|
||||
|
|
@ -235,6 +248,24 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
|||
defer al.channelManager.InvokeTypingStop(m.Channel, m.ChatID)
|
||||
}
|
||||
|
||||
if al.takePendingStop(sessionKey) {
|
||||
al.activeTurnStates.Delete(sessionKey)
|
||||
target := &continuationTarget{
|
||||
SessionKey: sessionKey,
|
||||
Channel: m.Channel,
|
||||
ChatID: m.ChatID,
|
||||
}
|
||||
continued, continueErr := al.drainQueuedSteeringContinuations(ctx, target)
|
||||
if continueErr != nil {
|
||||
al.maybePublishError(ctx, m.Channel, m.ChatID, sessionKey, continueErr)
|
||||
return
|
||||
}
|
||||
if continued != "" {
|
||||
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, continued)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
al.runTurnWithSteering(ctx, m)
|
||||
}(msg)
|
||||
|
||||
|
|
@ -280,13 +311,28 @@ func (al *AgentLoop) Close() {
|
|||
})
|
||||
}
|
||||
}
|
||||
evolution := al.currentEvolutionBridge()
|
||||
if evolution != nil {
|
||||
if err := evolution.Close(); err != nil {
|
||||
logger.ErrorCF("agent", "Failed to close evolution bridge",
|
||||
map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
al.GetRegistry().Close()
|
||||
if al.hooks != nil {
|
||||
al.hooks.Close()
|
||||
}
|
||||
if al.eventBus != nil {
|
||||
al.eventBus.Close()
|
||||
al.closeRuntimeEventLogger()
|
||||
if al.runtimeEvents != nil && al.ownsRuntimeEvents {
|
||||
if err := al.runtimeEvents.Close(); err != nil {
|
||||
logger.ErrorCF("agent", "Failed to close runtime event bus",
|
||||
map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -294,12 +340,6 @@ func (al *AgentLoop) Close() {
|
|||
|
||||
// UnmountHook removes a previously registered in-process hook.
|
||||
|
||||
// SubscribeEvents registers a subscriber for agent-loop events.
|
||||
|
||||
// UnsubscribeEvents removes a previously registered event subscriber.
|
||||
|
||||
// EventDrops returns the number of dropped events for the given kind.
|
||||
|
||||
type turnEventScope struct {
|
||||
agentID string
|
||||
sessionKey string
|
||||
|
|
@ -364,14 +404,29 @@ func (al *AgentLoop) ReloadProviderAndConfig(
|
|||
// Ensure shared tools are re-registered on the new registry
|
||||
registerSharedTools(al, cfg, al.bus, registry, provider)
|
||||
|
||||
newEvolution, evolutionErr := newEvolutionBridge(registry, cfg, provider)
|
||||
if evolutionErr != nil {
|
||||
logger.WarnCF("agent", "Failed to reinitialize evolution bridge during reload",
|
||||
map[string]any{"error": evolutionErr.Error()})
|
||||
}
|
||||
if newEvolution != nil {
|
||||
newEvolution.setCurrentCheck(al.isCurrentEvolutionBridge)
|
||||
if err := newEvolution.subscribeRuntimeEvents(al.runtimeEvents.Channel()); err != nil {
|
||||
logger.WarnCF("agent", "Failed to subscribe reloaded evolution bridge to runtime events",
|
||||
map[string]any{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// Atomically swap the config and registry under write lock
|
||||
// This ensures readers see a consistent pair
|
||||
al.mu.Lock()
|
||||
oldRegistry := al.registry
|
||||
oldEvolution := al.evolution
|
||||
|
||||
// Store new values
|
||||
al.cfg = cfg
|
||||
al.registry = registry
|
||||
al.evolution = newEvolution
|
||||
|
||||
// Also update fallback chain with new config; rebuild rate limiter registry.
|
||||
newRL := providers.NewRateLimiterRegistry()
|
||||
|
|
@ -384,6 +439,7 @@ func (al *AgentLoop) ReloadProviderAndConfig(
|
|||
al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker(), newRL)
|
||||
|
||||
al.mu.Unlock()
|
||||
al.refreshRuntimeEventLogger(cfg)
|
||||
|
||||
oldMCPManager := al.mcp.reset()
|
||||
al.hookRuntime.reset(al)
|
||||
|
|
@ -398,6 +454,12 @@ func (al *AgentLoop) ReloadProviderAndConfig(
|
|||
map[string]any{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
if oldEvolution != nil {
|
||||
if err := oldEvolution.Close(); err != nil {
|
||||
logger.WarnCF("agent", "Failed to close previous evolution bridge during reload",
|
||||
map[string]any{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||
logger.WarnCF("agent", "MCP failed to reinitialize after reload",
|
||||
map[string]any{"error": err.Error()})
|
||||
|
|
|
|||
|
|
@ -274,6 +274,12 @@ func (al *AgentLoop) buildCommandsRuntime(
|
|||
return nil
|
||||
},
|
||||
}
|
||||
rt.StopActiveTurn = func() (commands.StopResult, error) {
|
||||
if opts == nil {
|
||||
return commands.StopResult{}, fmt.Errorf("process options not available")
|
||||
}
|
||||
return al.stopActiveTurnForSession(opts.Dispatch.SessionKey)
|
||||
}
|
||||
if agent != nil && agent.ContextBuilder != nil {
|
||||
rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ package agent
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
)
|
||||
|
||||
func (al *AgentLoop) newTurnEventScope(agentID, sessionKey string, turnCtx *TurnContext) turnEventScope {
|
||||
|
|
@ -18,8 +18,8 @@ func (al *AgentLoop) newTurnEventScope(agentID, sessionKey string, turnCtx *Turn
|
|||
}
|
||||
}
|
||||
|
||||
func (ts turnEventScope) meta(iteration int, source, tracePath string) EventMeta {
|
||||
return EventMeta{
|
||||
func (ts turnEventScope) meta(iteration int, source, tracePath string) HookMeta {
|
||||
return HookMeta{
|
||||
AgentID: ts.agentID,
|
||||
TurnID: ts.turnID,
|
||||
SessionKey: ts.sessionKey,
|
||||
|
|
@ -30,119 +30,55 @@ func (ts turnEventScope) meta(iteration int, source, tracePath string) EventMeta
|
|||
}
|
||||
}
|
||||
|
||||
func (al *AgentLoop) emitEvent(kind EventKind, meta EventMeta, payload any) {
|
||||
clonedMeta := cloneEventMeta(meta)
|
||||
evt := Event{
|
||||
Kind: kind,
|
||||
Meta: clonedMeta,
|
||||
Context: cloneTurnContext(clonedMeta.turnContext),
|
||||
Payload: payload,
|
||||
func (al *AgentLoop) emitEvent(kind runtimeevents.Kind, meta HookMeta, payload any) {
|
||||
clonedMeta := cloneHookMeta(meta)
|
||||
eventCtx := cloneTurnContext(clonedMeta.turnContext)
|
||||
evt := runtimeevents.Event{
|
||||
Kind: kind,
|
||||
Source: runtimeevents.Source{Component: "agent", Name: clonedMeta.AgentID},
|
||||
Scope: runtimeScopeFromHookMeta(clonedMeta, eventCtx),
|
||||
Correlation: runtimeCorrelationFromHookMeta(clonedMeta),
|
||||
Severity: runtimeSeverityForAgentEvent(kind, payload),
|
||||
Payload: payload,
|
||||
Attrs: runtimeAttrsFromHookMeta(clonedMeta),
|
||||
}
|
||||
|
||||
if al == nil || al.eventBus == nil {
|
||||
if al == nil {
|
||||
return
|
||||
}
|
||||
|
||||
al.logEvent(evt)
|
||||
|
||||
al.eventBus.Emit(evt)
|
||||
deliveredToEvolution := false
|
||||
if kind == runtimeevents.KindAgentTurnEnd {
|
||||
evolution := al.currentEvolutionBridge()
|
||||
if evolution != nil {
|
||||
deliveredToEvolution = evolution.handleRuntimeTurnEnd(evt)
|
||||
}
|
||||
}
|
||||
if deliveredToEvolution {
|
||||
if evt.Attrs == nil {
|
||||
evt.Attrs = make(map[string]any, 1)
|
||||
}
|
||||
evt.Attrs[evolutionDirectDeliveryAttr] = true
|
||||
}
|
||||
al.publishRuntimeEvent(evt)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) logEvent(evt Event) {
|
||||
fields := map[string]any{
|
||||
"event_kind": evt.Kind.String(),
|
||||
"agent_id": evt.Meta.AgentID,
|
||||
"turn_id": evt.Meta.TurnID,
|
||||
"session_key": evt.Meta.SessionKey,
|
||||
"iteration": evt.Meta.Iteration,
|
||||
func (al *AgentLoop) currentEvolutionBridge() *evolutionBridge {
|
||||
if al == nil {
|
||||
return nil
|
||||
}
|
||||
al.mu.RLock()
|
||||
defer al.mu.RUnlock()
|
||||
return al.evolution
|
||||
}
|
||||
|
||||
if evt.Meta.TracePath != "" {
|
||||
fields["trace"] = evt.Meta.TracePath
|
||||
func (al *AgentLoop) isCurrentEvolutionBridge(bridge *evolutionBridge) bool {
|
||||
if al == nil || bridge == nil {
|
||||
return false
|
||||
}
|
||||
if evt.Meta.Source != "" {
|
||||
fields["source"] = evt.Meta.Source
|
||||
}
|
||||
|
||||
appendEventContextFields(fields, evt.Context)
|
||||
|
||||
switch payload := evt.Payload.(type) {
|
||||
case TurnStartPayload:
|
||||
fields["user_len"] = len(payload.UserMessage)
|
||||
fields["media_count"] = payload.MediaCount
|
||||
case TurnEndPayload:
|
||||
fields["status"] = payload.Status
|
||||
fields["iterations_total"] = payload.Iterations
|
||||
fields["duration_ms"] = payload.Duration.Milliseconds()
|
||||
fields["final_len"] = payload.FinalContentLen
|
||||
case LLMRequestPayload:
|
||||
fields["model"] = payload.Model
|
||||
fields["messages"] = payload.MessagesCount
|
||||
fields["tools"] = payload.ToolsCount
|
||||
fields["max_tokens"] = payload.MaxTokens
|
||||
case LLMDeltaPayload:
|
||||
fields["content_delta_len"] = payload.ContentDeltaLen
|
||||
fields["reasoning_delta_len"] = payload.ReasoningDeltaLen
|
||||
case LLMResponsePayload:
|
||||
fields["content_len"] = payload.ContentLen
|
||||
fields["tool_calls"] = payload.ToolCalls
|
||||
fields["has_reasoning"] = payload.HasReasoning
|
||||
case LLMRetryPayload:
|
||||
fields["attempt"] = payload.Attempt
|
||||
fields["max_retries"] = payload.MaxRetries
|
||||
fields["reason"] = payload.Reason
|
||||
fields["error"] = payload.Error
|
||||
fields["backoff_ms"] = payload.Backoff.Milliseconds()
|
||||
case ContextCompressPayload:
|
||||
fields["reason"] = payload.Reason
|
||||
fields["dropped_messages"] = payload.DroppedMessages
|
||||
fields["remaining_messages"] = payload.RemainingMessages
|
||||
case SessionSummarizePayload:
|
||||
fields["summarized_messages"] = payload.SummarizedMessages
|
||||
fields["kept_messages"] = payload.KeptMessages
|
||||
fields["summary_len"] = payload.SummaryLen
|
||||
fields["omitted_oversized"] = payload.OmittedOversized
|
||||
case ToolExecStartPayload:
|
||||
fields["tool"] = payload.Tool
|
||||
fields["args_count"] = len(payload.Arguments)
|
||||
case ToolExecEndPayload:
|
||||
fields["tool"] = payload.Tool
|
||||
fields["duration_ms"] = payload.Duration.Milliseconds()
|
||||
fields["for_llm_len"] = payload.ForLLMLen
|
||||
fields["for_user_len"] = payload.ForUserLen
|
||||
fields["is_error"] = payload.IsError
|
||||
fields["async"] = payload.Async
|
||||
case ToolExecSkippedPayload:
|
||||
fields["tool"] = payload.Tool
|
||||
fields["reason"] = payload.Reason
|
||||
case SteeringInjectedPayload:
|
||||
fields["count"] = payload.Count
|
||||
fields["total_content_len"] = payload.TotalContentLen
|
||||
case FollowUpQueuedPayload:
|
||||
fields["source_tool"] = payload.SourceTool
|
||||
fields["content_len"] = payload.ContentLen
|
||||
case InterruptReceivedPayload:
|
||||
fields["interrupt_kind"] = payload.Kind
|
||||
fields["role"] = payload.Role
|
||||
fields["content_len"] = payload.ContentLen
|
||||
fields["queue_depth"] = payload.QueueDepth
|
||||
fields["hint_len"] = payload.HintLen
|
||||
case SubTurnSpawnPayload:
|
||||
fields["child_agent_id"] = payload.AgentID
|
||||
fields["label"] = payload.Label
|
||||
case SubTurnEndPayload:
|
||||
fields["child_agent_id"] = payload.AgentID
|
||||
fields["status"] = payload.Status
|
||||
case SubTurnResultDeliveredPayload:
|
||||
fields["target_channel"] = payload.TargetChannel
|
||||
fields["target_chat_id"] = payload.TargetChatID
|
||||
fields["content_len"] = payload.ContentLen
|
||||
case ErrorPayload:
|
||||
fields["stage"] = payload.Stage
|
||||
fields["error"] = payload.Message
|
||||
}
|
||||
|
||||
logger.InfoCF("eventbus", fmt.Sprintf("Agent event: %s", evt.Kind.String()), fields)
|
||||
al.mu.RLock()
|
||||
defer al.mu.RUnlock()
|
||||
return al.evolution == bridge
|
||||
}
|
||||
|
||||
// MountHook registers an in-process hook on the agent loop.
|
||||
|
|
@ -161,28 +97,26 @@ func (al *AgentLoop) UnmountHook(name string) {
|
|||
al.hooks.Unmount(name)
|
||||
}
|
||||
|
||||
// SubscribeEvents registers a subscriber for agent-loop events.
|
||||
func (al *AgentLoop) SubscribeEvents(buffer int) EventSubscription {
|
||||
if al == nil || al.eventBus == nil {
|
||||
ch := make(chan Event)
|
||||
close(ch)
|
||||
return EventSubscription{C: ch}
|
||||
// RuntimeEvents returns the root runtime event channel.
|
||||
func (al *AgentLoop) RuntimeEvents() runtimeevents.EventChannel {
|
||||
if al == nil || al.runtimeEvents == nil {
|
||||
return nil
|
||||
}
|
||||
return al.eventBus.Subscribe(buffer)
|
||||
return al.runtimeEvents.Channel()
|
||||
}
|
||||
|
||||
// UnsubscribeEvents removes a previously registered event subscriber.
|
||||
func (al *AgentLoop) UnsubscribeEvents(id uint64) {
|
||||
if al == nil || al.eventBus == nil {
|
||||
return
|
||||
// RuntimeEventStats returns runtime event bus counters.
|
||||
func (al *AgentLoop) RuntimeEventStats() runtimeevents.Stats {
|
||||
if al == nil || al.runtimeEvents == nil {
|
||||
return runtimeevents.Stats{Closed: true}
|
||||
}
|
||||
al.eventBus.Unsubscribe(id)
|
||||
return al.runtimeEvents.Stats()
|
||||
}
|
||||
|
||||
// EventDrops returns the number of dropped events for the given kind.
|
||||
func (al *AgentLoop) EventDrops(kind EventKind) int64 {
|
||||
if al == nil || al.eventBus == nil {
|
||||
return 0
|
||||
// RuntimeEventBus returns the runtime event bus used by the agent loop.
|
||||
func (al *AgentLoop) RuntimeEventBus() runtimeevents.Bus {
|
||||
if al == nil {
|
||||
return nil
|
||||
}
|
||||
return al.eventBus.Dropped(kind)
|
||||
return al.runtimeEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/commands"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
|
|
@ -24,6 +25,7 @@ func NewAgentLoop(
|
|||
cfg *config.Config,
|
||||
msgBus *bus.MessageBus,
|
||||
provider providers.LLMProvider,
|
||||
opts ...AgentLoopOption,
|
||||
) *AgentLoop {
|
||||
registry := NewAgentRegistry(cfg, provider)
|
||||
|
||||
|
|
@ -47,7 +49,12 @@ func NewAgentLoop(
|
|||
stateManager = state.NewManager(defaultAgent.Workspace)
|
||||
}
|
||||
|
||||
eventBus := NewEventBus()
|
||||
bridge, err := newEvolutionBridge(registry, cfg, provider)
|
||||
if err != nil {
|
||||
logger.WarnCF("agent", "Failed to initialize evolution bridge", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Determine worker pool size from config (default: 1 = sequential)
|
||||
workerPoolSize := cfg.Agents.Defaults.MaxParallelTurns
|
||||
|
|
@ -56,18 +63,37 @@ func NewAgentLoop(
|
|||
}
|
||||
|
||||
al := &AgentLoop{
|
||||
bus: msgBus,
|
||||
cfg: cfg,
|
||||
registry: registry,
|
||||
state: stateManager,
|
||||
eventBus: eventBus,
|
||||
fallback: fallbackChain,
|
||||
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
|
||||
steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)),
|
||||
workerSem: make(chan struct{}, workerPoolSize),
|
||||
bus: msgBus,
|
||||
cfg: cfg,
|
||||
registry: registry,
|
||||
state: stateManager,
|
||||
fallback: fallbackChain,
|
||||
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
|
||||
evolution: bridge,
|
||||
steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)),
|
||||
workerSem: make(chan struct{}, workerPoolSize),
|
||||
ownsRuntimeEvents: true,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(al)
|
||||
}
|
||||
}
|
||||
if al.runtimeEvents == nil {
|
||||
al.runtimeEvents = runtimeevents.NewBus()
|
||||
al.ownsRuntimeEvents = true
|
||||
}
|
||||
if bridge != nil {
|
||||
bridge.setCurrentCheck(al.isCurrentEvolutionBridge)
|
||||
if err := bridge.subscribeRuntimeEvents(al.runtimeEvents.Channel()); err != nil {
|
||||
logger.WarnCF("agent", "Failed to subscribe evolution bridge to runtime events", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
al.refreshRuntimeEventLogger(cfg)
|
||||
al.providerFactory = providers.CreateProviderFromConfig
|
||||
al.hooks = NewHookManager(eventBus)
|
||||
al.hooks = NewHookManager(al.runtimeEvents.Channel())
|
||||
configureHookManagerFromConfig(al.hooks, cfg)
|
||||
al.contextManager = al.resolveContextManager()
|
||||
|
||||
|
|
@ -327,5 +353,22 @@ func registerSharedTools(
|
|||
} else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") {
|
||||
logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil)
|
||||
}
|
||||
|
||||
// Register delegate tool for multi-agent setups.
|
||||
// Auto-enabled when multiple agents exist. Delegation uses the SubTurn
|
||||
// mechanism directly (not SubagentManager) and is independent of the
|
||||
// subagent tool.
|
||||
if len(registry.ListAgentIDs()) > 1 {
|
||||
delegateTool := tools.NewDelegateTool()
|
||||
delegateTool.SetSpawner(NewSubTurnSpawner(al))
|
||||
currentAgentID := agentID
|
||||
delegateTool.SetSelfAgentID(currentAgentID)
|
||||
delegateTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
||||
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
||||
})
|
||||
agent.Tools.Register(delegateTool)
|
||||
}
|
||||
|
||||
warnOnUnknownAgentToolDeclarations(agentID, agent.Workspace, agent.Definition, agent.Tools)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,8 +85,18 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
mcpCfg := filterMCPConfigServers(al.cfg.Tools.MCP, al.registry.allowedMCPServers())
|
||||
if mcpCfg.Servers == nil || len(mcpCfg.Servers) == 0 {
|
||||
logger.InfoCF(
|
||||
"agent",
|
||||
"No MCP servers selected after applying per-agent mcpServers allowlists",
|
||||
nil,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
findValidServer := false
|
||||
for _, serverCfg := range al.cfg.Tools.MCP.Servers {
|
||||
for _, serverCfg := range mcpCfg.Servers {
|
||||
if serverCfg.Enabled {
|
||||
findValidServer = true
|
||||
}
|
||||
|
|
@ -97,7 +107,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
|||
}
|
||||
|
||||
al.mcp.initOnce.Do(func() {
|
||||
mcpManager := mcp.NewManager()
|
||||
mcpManager := mcp.NewManager(mcp.WithRuntimeEvents(al.runtimeEvents))
|
||||
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
workspacePath := al.cfg.WorkspacePath()
|
||||
|
|
@ -105,7 +115,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
|||
workspacePath = defaultAgent.Workspace
|
||||
}
|
||||
|
||||
if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {
|
||||
if err := mcpManager.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil {
|
||||
al.mcp.setInitErr(fmt.Errorf("failed to load MCP servers: %w", err))
|
||||
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
|
||||
map[string]any{
|
||||
|
|
@ -132,27 +142,9 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
|||
|
||||
// Determine whether this server's tools should be deferred (hidden).
|
||||
// Per-server "deferred" field takes precedence over the global Discovery.Enabled.
|
||||
serverCfg := al.cfg.Tools.MCP.Servers[serverName]
|
||||
serverCfg := mcpCfg.Servers[serverName]
|
||||
registerAsHidden := serverIsDeferred(al.cfg.Tools.MCP.Discovery.Enabled, serverCfg)
|
||||
|
||||
for _, agentID := range agentIDs {
|
||||
agent, ok := al.registry.GetAgent(agentID)
|
||||
if !ok || agent.ContextBuilder == nil {
|
||||
continue
|
||||
}
|
||||
if err := agent.ContextBuilder.RegisterPromptContributor(mcpServerPromptContributor{
|
||||
serverName: serverName,
|
||||
toolCount: len(conn.Tools),
|
||||
deferred: registerAsHidden,
|
||||
}); err != nil {
|
||||
logger.WarnCF("agent", "Failed to register MCP prompt contributor",
|
||||
map[string]any{
|
||||
"agent_id": agentID,
|
||||
"server": serverName,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
registeredToolsByAgent := make(map[string]map[string]struct{}, len(agentIDs))
|
||||
|
||||
for _, tool := range conn.Tools {
|
||||
for _, agentID := range agentIDs {
|
||||
|
|
@ -160,28 +152,57 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
|||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !agent.AllowsMCPServer(serverName) {
|
||||
logger.DebugCF("agent", "Skipped MCP tool registration by agent mcpServers allowlist",
|
||||
map[string]any{
|
||||
"agent_id": agentID,
|
||||
"server": serverName,
|
||||
"tool": tool.Name,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
|
||||
toolName := mcpTool.Name()
|
||||
mcpTool.SetWorkspace(agent.Workspace)
|
||||
mcpTool.SetMaxInlineTextRunes(al.cfg.Tools.MCP.GetMaxInlineTextChars())
|
||||
mcpTool.SetEventPublisher(al.runtimeEvents)
|
||||
|
||||
if registerAsHidden {
|
||||
agent.Tools.RegisterHidden(mcpTool)
|
||||
} else {
|
||||
agent.Tools.Register(mcpTool)
|
||||
}
|
||||
if !toolRegistryIncludes(agent.Tools, toolName) {
|
||||
continue
|
||||
}
|
||||
|
||||
recordRegisteredMCPTool(registeredToolsByAgent, agentID, toolName)
|
||||
totalRegistrations++
|
||||
logger.DebugCF("agent", "Registered MCP tool",
|
||||
map[string]any{
|
||||
"agent_id": agentID,
|
||||
"server": serverName,
|
||||
"tool": tool.Name,
|
||||
"name": mcpTool.Name(),
|
||||
"name": toolName,
|
||||
"deferred": registerAsHidden,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, agentID := range agentIDs {
|
||||
agent, ok := al.registry.GetAgent(agentID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
registerMCPServerPromptContributor(
|
||||
agentID,
|
||||
agent,
|
||||
serverName,
|
||||
len(registeredToolsByAgent[agentID]),
|
||||
registerAsHidden,
|
||||
)
|
||||
}
|
||||
}
|
||||
logger.InfoCF("agent", "MCP tools registered successfully",
|
||||
map[string]any{
|
||||
|
|
@ -229,6 +250,9 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
|||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !agentHasDiscoverableMCPServers(al.cfg, agent.MCPServerAllowlist) {
|
||||
continue
|
||||
}
|
||||
|
||||
if useRegex {
|
||||
agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults))
|
||||
|
|
@ -245,6 +269,89 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
|||
return al.mcp.getInitErr()
|
||||
}
|
||||
|
||||
func registerMCPServerPromptContributor(
|
||||
agentID string,
|
||||
agent *AgentInstance,
|
||||
serverName string,
|
||||
toolCount int,
|
||||
registerAsHidden bool,
|
||||
) {
|
||||
if agent == nil || agent.ContextBuilder == nil || toolCount <= 0 {
|
||||
return
|
||||
}
|
||||
if err := agent.ContextBuilder.RegisterPromptContributor(mcpServerPromptContributor{
|
||||
serverName: serverName,
|
||||
toolCount: toolCount,
|
||||
deferred: registerAsHidden,
|
||||
}); err != nil {
|
||||
logger.WarnCF("agent", "Failed to register MCP prompt contributor",
|
||||
map[string]any{
|
||||
"agent_id": agentID,
|
||||
"server": serverName,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func recordRegisteredMCPTool(
|
||||
registeredToolsByAgent map[string]map[string]struct{},
|
||||
agentID, toolName string,
|
||||
) {
|
||||
if registeredToolsByAgent[agentID] == nil {
|
||||
registeredToolsByAgent[agentID] = make(map[string]struct{})
|
||||
}
|
||||
registeredToolsByAgent[agentID][toolName] = struct{}{}
|
||||
}
|
||||
|
||||
func toolRegistryIncludes(registry *tools.ToolRegistry, name string) bool {
|
||||
if registry == nil {
|
||||
return false
|
||||
}
|
||||
return registry.HasRegistered(name)
|
||||
}
|
||||
|
||||
func filterMCPConfigServers(
|
||||
mcpCfg config.MCPConfig,
|
||||
allowed map[string]struct{},
|
||||
) config.MCPConfig {
|
||||
if allowed == nil {
|
||||
return mcpCfg
|
||||
}
|
||||
|
||||
filtered := mcpCfg
|
||||
filtered.Servers = make(map[string]config.MCPServerConfig)
|
||||
normalizedAllowed := make(map[string]struct{}, len(allowed))
|
||||
for serverName := range allowed {
|
||||
name := normalizeMCPServerName(serverName)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
normalizedAllowed[name] = struct{}{}
|
||||
}
|
||||
for serverName, serverCfg := range mcpCfg.Servers {
|
||||
if _, ok := normalizedAllowed[normalizeMCPServerName(serverName)]; ok {
|
||||
filtered.Servers[serverName] = serverCfg
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func agentHasDiscoverableMCPServers(cfg *config.Config, allowed map[string]struct{}) bool {
|
||||
if cfg == nil || !cfg.Tools.MCP.Enabled || !cfg.Tools.MCP.Discovery.Enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
filtered := filterMCPConfigServers(cfg.Tools.MCP, allowed)
|
||||
for _, serverCfg := range filtered.Servers {
|
||||
if serverCfg.Enabled && serverIsDeferred(cfg.Tools.MCP.Discovery.Enabled, serverCfg) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// serverIsDeferred reports whether an MCP server's tools should be registered
|
||||
// as hidden (deferred/discovery mode).
|
||||
//
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/mcp"
|
||||
agenttools "github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
|
@ -135,6 +136,139 @@ func TestServerIsDeferred(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRegisterMCPServerPromptContributorUsesActualRegisteredToolCount(t *testing.T) {
|
||||
cb := NewContextBuilder(t.TempDir())
|
||||
agent := &AgentInstance{ContextBuilder: cb}
|
||||
|
||||
registerMCPServerPromptContributor("research", agent, "github", 0, false)
|
||||
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"})
|
||||
if prompt := messages[0].Content; strings.Contains(prompt, "MCP server `github`") {
|
||||
t.Fatalf("expected no MCP prompt when no tools were registered, got %q", prompt)
|
||||
}
|
||||
|
||||
registerMCPServerPromptContributor("research", agent, "github", 2, false)
|
||||
messages = cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"})
|
||||
prompt := messages[0].Content
|
||||
if !strings.Contains(prompt, "MCP server `github` is connected") {
|
||||
t.Fatalf("expected MCP prompt for registered tools, got %q", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "It contributes 2 tool(s)") {
|
||||
t.Fatalf("expected actual registered tool count in prompt, got %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistryIncludesReportsOnlyRegisteredTools(t *testing.T) {
|
||||
registry := agenttools.NewToolRegistry()
|
||||
registry.SetAllowlist([]string{"mcp_github_search"})
|
||||
|
||||
registry.RegisterHidden(&allowlistTestTool{name: "mcp_github_search"})
|
||||
registry.RegisterHidden(&allowlistTestTool{name: "mcp_github_create_issue"})
|
||||
|
||||
if !toolRegistryIncludes(registry, "mcp_github_search") {
|
||||
t.Fatal("expected hidden registered MCP tool to be included")
|
||||
}
|
||||
if toolRegistryIncludes(registry, "mcp_github_create_issue") {
|
||||
t.Fatal("blocked MCP tool should not be included")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMCPConfigServersCaseInsensitivePreservesOriginalKeys(t *testing.T) {
|
||||
mcpCfg := config.MCPConfig{
|
||||
Servers: map[string]config.MCPServerConfig{
|
||||
"GitHub": {Enabled: true},
|
||||
"filesystem": {Enabled: true},
|
||||
"Slack": {Enabled: true},
|
||||
},
|
||||
}
|
||||
allowed := map[string]struct{}{
|
||||
"github": {},
|
||||
"FILESYSTEM": {},
|
||||
}
|
||||
|
||||
filtered := filterMCPConfigServers(mcpCfg, allowed)
|
||||
|
||||
if len(filtered.Servers) != 2 {
|
||||
t.Fatalf("filtered.Servers = %v, want 2 entries", filtered.Servers)
|
||||
}
|
||||
if _, ok := filtered.Servers["GitHub"]; !ok {
|
||||
t.Fatal("expected original GitHub config key to be preserved")
|
||||
}
|
||||
if _, ok := filtered.Servers["filesystem"]; !ok {
|
||||
t.Fatal("expected filesystem config key to be preserved")
|
||||
}
|
||||
if _, ok := filtered.Servers["github"]; ok {
|
||||
t.Fatal("did not expect normalized github key to replace original config key")
|
||||
}
|
||||
if _, ok := filtered.Servers["Slack"]; ok {
|
||||
t.Fatal("did not expect unallowed Slack server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHasDiscoverableMCPServers(t *testing.T) {
|
||||
deferredFalse := false
|
||||
cfg := &config.Config{
|
||||
Tools: config.ToolsConfig{
|
||||
MCP: config.MCPConfig{
|
||||
ToolConfig: config.ToolConfig{Enabled: true},
|
||||
Discovery: config.ToolDiscoveryConfig{
|
||||
Enabled: true,
|
||||
UseBM25: true,
|
||||
UseRegex: false,
|
||||
},
|
||||
Servers: map[string]config.MCPServerConfig{
|
||||
"github": {Enabled: true},
|
||||
"filesystem": {Enabled: true, Deferred: &deferredFalse},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
allowed map[string]struct{}
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil allowlist includes discoverable enabled server",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "empty allowlist denies all servers",
|
||||
allowed: map[string]struct{}{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "selected server discoverable",
|
||||
allowed: map[string]struct{}{
|
||||
"github": {},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "selected server opted out of discovery",
|
||||
allowed: map[string]struct{}{
|
||||
"filesystem": {},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unknown allowlist server matches nothing",
|
||||
allowed: map[string]struct{}{
|
||||
"slack": {},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := agentHasDiscoverableMCPServers(cfg, tt.allowed); got != tt.want {
|
||||
t.Fatalf("agentHasDiscoverableMCPServers() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMCPInitialized_LoadFailureSetsInitErr(t *testing.T) {
|
||||
al, cfg, _, _, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
|
|
|||
|
|
@ -102,9 +102,27 @@ func (al *AgentLoop) ProcessHeartbeat(
|
|||
})
|
||||
}
|
||||
|
||||
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
|
||||
func (al *AgentLoop) prepareInboundMessageForAgent(
|
||||
ctx context.Context,
|
||||
msg bus.InboundMessage,
|
||||
) bus.InboundMessage {
|
||||
msg = bus.NormalizeInboundMessage(msg)
|
||||
|
||||
var hadAudio bool
|
||||
msg, hadAudio = al.transcribeAudioInMessage(ctx, msg)
|
||||
|
||||
// For audio messages the placeholder was deferred by the channel.
|
||||
// Now that transcription (and optional feedback) is done, send it.
|
||||
if hadAudio && al.channelManager != nil {
|
||||
al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID)
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
|
||||
msg = al.prepareInboundMessageForAgent(ctx, msg)
|
||||
|
||||
// Add message preview to log (show full content for error messages)
|
||||
var logContent string
|
||||
if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") {
|
||||
|
|
@ -123,15 +141,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||
},
|
||||
)
|
||||
|
||||
var hadAudio bool
|
||||
msg, hadAudio = al.transcribeAudioInMessage(ctx, msg)
|
||||
|
||||
// For audio messages the placeholder was deferred by the channel.
|
||||
// Now that transcription (and optional feedback) is done, send it.
|
||||
if hadAudio && al.channelManager != nil {
|
||||
al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID)
|
||||
}
|
||||
|
||||
// Route system messages to processSystemMessage
|
||||
if msg.Channel == "system" {
|
||||
return al.processSystemMessage(ctx, msg)
|
||||
|
|
|
|||
20
pkg/agent/agent_options.go
Normal file
20
pkg/agent/agent_options.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package agent
|
||||
|
||||
import runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
|
||||
// AgentLoopOption configures an AgentLoop at construction time.
|
||||
type AgentLoopOption func(*AgentLoop)
|
||||
|
||||
// WithRuntimeEvents injects the runtime event bus used for new observation APIs.
|
||||
//
|
||||
// The injected bus is treated as externally owned and will not be closed by
|
||||
// AgentLoop.Close. Passing nil leaves the default owned runtime bus enabled.
|
||||
func WithRuntimeEvents(bus runtimeevents.Bus) AgentLoopOption {
|
||||
return func(al *AgentLoop) {
|
||||
if bus == nil {
|
||||
return
|
||||
}
|
||||
al.runtimeEvents = bus
|
||||
al.ownsRuntimeEvents = false
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,16 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
|
|||
}
|
||||
|
||||
if alreadySentToSameChat {
|
||||
if al.channelManager != nil && channel != "" && chatID != "" {
|
||||
dismissCtx, dismissCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
al.channelManager.DismissToolFeedback(
|
||||
dismissCtx,
|
||||
channel,
|
||||
chatID,
|
||||
nil,
|
||||
)
|
||||
dismissCancel()
|
||||
}
|
||||
logger.DebugCF(
|
||||
"agent",
|
||||
"Skipped outbound (message tool already sent to same chat)",
|
||||
|
|
|
|||
|
|
@ -44,11 +44,36 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb
|
|||
return
|
||||
}
|
||||
|
||||
// Drain steering queue using existing Continue mechanism
|
||||
continued, continueErr := al.drainQueuedSteeringContinuations(ctx, target)
|
||||
if continueErr != nil {
|
||||
logger.WarnCF("agent", "Failed to continue queued steering",
|
||||
map[string]any{
|
||||
"channel": target.Channel,
|
||||
"chat_id": target.ChatID,
|
||||
"error": continueErr.Error(),
|
||||
})
|
||||
} else if continued != "" {
|
||||
finalResponse = continued
|
||||
}
|
||||
|
||||
// Publish final response
|
||||
if finalResponse != "" {
|
||||
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse)
|
||||
}
|
||||
}
|
||||
|
||||
func (al *AgentLoop) drainQueuedSteeringContinuations(
|
||||
ctx context.Context,
|
||||
target *continuationTarget,
|
||||
) (string, error) {
|
||||
if target == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
finalResponse := ""
|
||||
for al.pendingSteeringCountForScope(target.SessionKey) > 0 {
|
||||
// Check for context cancellation between iterations
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
if err := ctx.Err(); err != nil {
|
||||
return finalResponse, err
|
||||
}
|
||||
|
||||
logger.InfoCF("agent", "Continuing queued steering after turn end",
|
||||
|
|
@ -61,13 +86,7 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb
|
|||
|
||||
continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID)
|
||||
if continueErr != nil {
|
||||
logger.WarnCF("agent", "Failed to continue queued steering",
|
||||
map[string]any{
|
||||
"channel": target.Channel,
|
||||
"chat_id": target.ChatID,
|
||||
"error": continueErr.Error(),
|
||||
})
|
||||
break
|
||||
return finalResponse, continueErr
|
||||
}
|
||||
if continued == "" {
|
||||
break
|
||||
|
|
@ -75,10 +94,7 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb
|
|||
finalResponse = continued
|
||||
}
|
||||
|
||||
// Publish final response
|
||||
if finalResponse != "" {
|
||||
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse)
|
||||
}
|
||||
return finalResponse, nil
|
||||
}
|
||||
|
||||
func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) {
|
||||
|
|
|
|||
122
pkg/agent/agent_stop.go
Normal file
122
pkg/agent/agent_stop.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/commands"
|
||||
)
|
||||
|
||||
func (al *AgentLoop) tryHandleStopCommand(
|
||||
ctx context.Context,
|
||||
msg bus.InboundMessage,
|
||||
sessionKey string,
|
||||
) bool {
|
||||
cmdName, ok := commands.CommandName(msg.Content)
|
||||
if !ok || cmdName != "stop" {
|
||||
return false
|
||||
}
|
||||
|
||||
result, err := al.stopActiveTurnForSession(sessionKey)
|
||||
|
||||
// This function is only called when loaded=true (another turn already
|
||||
// claimed this session). If stopActiveTurnForSession found a pending
|
||||
// placeholder but didn't stop it, that placeholder belongs to the other
|
||||
// message's worker which hasn't started yet — arm a pending stop so the
|
||||
// worker will bail when it checks before running.
|
||||
if err == nil && !result.Stopped {
|
||||
if ts := al.getActiveTurnState(sessionKey); ts != nil {
|
||||
snap := ts.snapshot()
|
||||
if strings.HasPrefix(snap.TurnID, pendingTurnPrefix) {
|
||||
al.markPendingStop(sessionKey)
|
||||
result.Stopped = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reply := commands.FormatStopReply(result)
|
||||
if err != nil {
|
||||
reply = "Failed to stop task: " + err.Error()
|
||||
}
|
||||
|
||||
if al.channelManager != nil {
|
||||
al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID)
|
||||
}
|
||||
al.resetMessageToolRound(sessionKey)
|
||||
al.PublishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, sessionKey, reply)
|
||||
return true
|
||||
}
|
||||
|
||||
func (al *AgentLoop) stopActiveTurnForSession(sessionKey string) (commands.StopResult, error) {
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if sessionKey == "" {
|
||||
return commands.StopResult{}, fmt.Errorf("session key is required")
|
||||
}
|
||||
|
||||
result := commands.StopResult{}
|
||||
cleared := al.clearSteeringMessagesForScope(sessionKey)
|
||||
al.clearPendingSkills(sessionKey)
|
||||
|
||||
ts := al.getActiveTurnState(sessionKey)
|
||||
if ts == nil {
|
||||
result.Stopped = cleared > 0
|
||||
return result, nil
|
||||
}
|
||||
|
||||
snap := ts.snapshot()
|
||||
result.TaskName = snap.UserMessage
|
||||
|
||||
if strings.HasPrefix(snap.TurnID, pendingTurnPrefix) {
|
||||
// A pending placeholder means this session is either idle (our own
|
||||
// placeholder from the /stop command) or another message is queued but
|
||||
// hasn't started yet. In both cases, we don't arm a pending stop here;
|
||||
// the caller (tryHandleStopCommand) handles the "another message queued"
|
||||
// case explicitly, since it knows loaded=true.
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if err := al.HardAbort(sessionKey); err != nil {
|
||||
if al.getActiveTurnState(sessionKey) == nil {
|
||||
result.Stopped = cleared > 0
|
||||
return result, nil
|
||||
}
|
||||
return commands.StopResult{}, err
|
||||
}
|
||||
|
||||
result.Stopped = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (al *AgentLoop) markPendingStop(sessionKey string) {
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if sessionKey == "" {
|
||||
return
|
||||
}
|
||||
al.pendingStops.Store(sessionKey, struct{}{})
|
||||
}
|
||||
|
||||
func (al *AgentLoop) takePendingStop(sessionKey string) bool {
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if sessionKey == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := al.pendingStops.LoadAndDelete(sessionKey)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (al *AgentLoop) resetMessageToolRound(sessionKey string) {
|
||||
if strings.TrimSpace(sessionKey) == "" {
|
||||
return
|
||||
}
|
||||
if registry := al.GetRegistry(); registry != nil {
|
||||
if agent := registry.GetDefaultAgent(); agent != nil {
|
||||
if tool, ok := agent.Tools.Get("message"); ok {
|
||||
if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok {
|
||||
resetter.ResetSentInRound(sessionKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/routing"
|
||||
|
|
@ -56,6 +57,38 @@ func (f *fakeMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaM
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
type recordingChannelManager struct {
|
||||
dismissed []string
|
||||
}
|
||||
|
||||
func (m *recordingChannelManager) GetChannel(name string) (channels.Channel, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *recordingChannelManager) GetEnabledChannels() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *recordingChannelManager) InvokeTypingStop(channel, chatID string) {}
|
||||
|
||||
func (m *recordingChannelManager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *recordingChannelManager) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *recordingChannelManager) SendPlaceholder(ctx context.Context, channel, chatID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *recordingChannelManager) DismissToolFeedback(
|
||||
ctx context.Context, channel, chatID string, outboundCtx *bus.InboundContext,
|
||||
) {
|
||||
m.dismissed = append(m.dismissed, fmt.Sprintf("%s:%s", channel, chatID))
|
||||
}
|
||||
|
||||
func newStartedTestChannelManager(
|
||||
t *testing.T,
|
||||
msgBus *bus.MessageBus,
|
||||
|
|
@ -213,6 +246,44 @@ func TestNewAgentLoop_DoesNotRegisterWebSearchTool_WhenNoReadyProviders(t *testi
|
|||
}
|
||||
}
|
||||
|
||||
func TestPublishResponseIfNeeded_DismissesToolFeedbackWhenMessageToolAlreadySent(t *testing.T) {
|
||||
al, msgBus, provider, sessions, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
_ = msgBus
|
||||
_ = provider
|
||||
_ = sessions
|
||||
|
||||
cm := &recordingChannelManager{}
|
||||
al.channelManager = cm
|
||||
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
mt := tools.NewMessageTool()
|
||||
mt.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
|
||||
return nil
|
||||
})
|
||||
defaultAgent.Tools.Register(mt)
|
||||
|
||||
result := mt.Execute(
|
||||
tools.WithToolSessionContext(context.Background(), "main", "session-1", nil),
|
||||
map[string]any{
|
||||
"content": "ack",
|
||||
"channel": "telegram",
|
||||
"chat_id": "-100123",
|
||||
},
|
||||
)
|
||||
if result == nil || result.IsError {
|
||||
t.Fatalf("message tool execute failed: %+v", result)
|
||||
}
|
||||
al.PublishResponseIfNeeded(context.Background(), "telegram", "-100123", "session-1", "final reply")
|
||||
|
||||
if got := cm.dismissed; len(got) != 1 || got[0] != "telegram:-100123" {
|
||||
t.Fatalf("dismissed = %v, want [telegram:-100123]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
if err != nil {
|
||||
|
|
@ -5456,6 +5527,7 @@ func TestParallelMessageProcessing_SameSessionProcessedSequentially(t *testing.T
|
|||
var mu sync.Mutex
|
||||
turnIDs := make(map[string]bool)
|
||||
var wg sync.WaitGroup
|
||||
var firstResponse sync.Once
|
||||
wg.Add(1) // Only 1 turn should be created for same session
|
||||
|
||||
cfg := &config.Config{
|
||||
|
|
@ -5478,19 +5550,27 @@ func TestParallelMessageProcessing_SameSessionProcessedSequentially(t *testing.T
|
|||
|
||||
al := NewAgentLoop(cfg, msgBus, &concurrentMockProvider{
|
||||
responseFunc: func(callID int) string {
|
||||
wg.Done()
|
||||
firstResponse.Do(func() {
|
||||
wg.Done()
|
||||
})
|
||||
return "ok"
|
||||
},
|
||||
})
|
||||
defer al.Close()
|
||||
|
||||
sub := al.SubscribeEvents(64)
|
||||
runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest(
|
||||
t,
|
||||
al,
|
||||
64,
|
||||
runtimeevents.KindAgentTurnStart,
|
||||
)
|
||||
defer closeRuntimeEvents()
|
||||
|
||||
go func() {
|
||||
for evt := range sub.C {
|
||||
if evt.Kind == EventKindTurnStart {
|
||||
for evt := range runtimeCh {
|
||||
if evt.Kind == runtimeevents.KindAgentTurnStart {
|
||||
mu.Lock()
|
||||
turnIDs[evt.Meta.TurnID] = true
|
||||
turnIDs[evt.Scope.TurnID] = true
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ package agent
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path/filepath"
|
||||
|
|
@ -171,15 +170,8 @@ func toolFeedbackExplanationFromMessages(messages []providers.Message) string {
|
|||
}
|
||||
|
||||
func toolFeedbackArgsPreview(args map[string]any, maxLen int) string {
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
|
||||
argsJSON, err := json.MarshalIndent(args, "", " ")
|
||||
if err != nil {
|
||||
return utils.Truncate(fmt.Sprintf("%v", args), maxLen)
|
||||
}
|
||||
return utils.Truncate(string(argsJSON), maxLen)
|
||||
argsJSON := utils.FormatArgsJSON(args, true, false)
|
||||
return utils.Truncate(argsJSON, maxLen)
|
||||
}
|
||||
|
||||
func shouldPublishToolFeedback(cfg *config.Config, ts *turnState) bool {
|
||||
|
|
@ -293,6 +285,12 @@ func inferMediaType(filename, contentType string) string {
|
|||
ct := strings.ToLower(contentType)
|
||||
fn := strings.ToLower(filename)
|
||||
|
||||
// SVG is an image MIME type, but raster-only delivery endpoints such as
|
||||
// Telegram SendPhoto reject it. Treat it as a file/document instead.
|
||||
if strings.HasPrefix(ct, "image/svg") || filepath.Ext(fn) == ".svg" {
|
||||
return "file"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(ct, "image/") {
|
||||
return "image"
|
||||
}
|
||||
|
|
@ -306,7 +304,7 @@ func inferMediaType(filename, contentType string) string {
|
|||
// Fallback: infer from extension
|
||||
ext := filepath.Ext(fn)
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp":
|
||||
return "image"
|
||||
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
|
||||
return "audio"
|
||||
|
|
|
|||
76
pkg/agent/agent_utils_test.go
Normal file
76
pkg/agent/agent_utils_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInferMediaType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
contentType string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "png content type",
|
||||
filename: "diagram",
|
||||
contentType: "image/png",
|
||||
want: "image",
|
||||
},
|
||||
{
|
||||
name: "jpeg extension fallback",
|
||||
filename: "photo.JPG",
|
||||
contentType: "",
|
||||
want: "image",
|
||||
},
|
||||
{
|
||||
name: "svg content type is file",
|
||||
filename: "diagram",
|
||||
contentType: "image/svg+xml",
|
||||
want: "file",
|
||||
},
|
||||
{
|
||||
name: "svg content type with parameters is file",
|
||||
filename: "diagram",
|
||||
contentType: "image/svg+xml; charset=utf-8",
|
||||
want: "file",
|
||||
},
|
||||
{
|
||||
name: "svg extension fallback is file",
|
||||
filename: "diagram.SVG",
|
||||
contentType: "",
|
||||
want: "file",
|
||||
},
|
||||
{
|
||||
name: "audio content type",
|
||||
filename: "voice",
|
||||
contentType: "audio/ogg",
|
||||
want: "audio",
|
||||
},
|
||||
{
|
||||
name: "ogg application content type",
|
||||
filename: "voice.ogg",
|
||||
contentType: "application/ogg",
|
||||
want: "audio",
|
||||
},
|
||||
{
|
||||
name: "video extension fallback",
|
||||
filename: "clip.MP4",
|
||||
contentType: "",
|
||||
want: "video",
|
||||
},
|
||||
{
|
||||
name: "unknown type",
|
||||
filename: "archive.bin",
|
||||
contentType: "application/octet-stream",
|
||||
want: "file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := inferMediaType(tt.filename, tt.contentType)
|
||||
if got != tt.want {
|
||||
t.Fatalf("inferMediaType(%q, %q) = %q, want %q", tt.filename, tt.contentType, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ type ContextBuilder struct {
|
|||
skillsLoader *skills.SkillsLoader
|
||||
memory *MemoryStore
|
||||
splitOnMarker bool
|
||||
agentDiscovery func(agentID string) []AgentDescriptor
|
||||
promptRegistry *PromptRegistry
|
||||
|
||||
// Cache for system prompt to avoid rebuilding on every call.
|
||||
|
|
@ -66,6 +67,24 @@ func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder {
|
|||
return cb
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) WithAgentDiscovery(
|
||||
agentID string,
|
||||
discover func(agentID string) []AgentDescriptor,
|
||||
) *ContextBuilder {
|
||||
cb.agentDiscovery = discover
|
||||
if discover != nil {
|
||||
if err := cb.RegisterPromptContributor(agentDiscoveryPromptContributor{
|
||||
agentID: agentID,
|
||||
discover: discover,
|
||||
}); err != nil {
|
||||
logger.WarnCF("agent", "Failed to register agent discovery prompt contributor", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return cb
|
||||
}
|
||||
|
||||
func getGlobalConfigDir() string {
|
||||
return config.GetHome()
|
||||
}
|
||||
|
|
@ -625,7 +644,9 @@ func formatCurrentSenderLine(senderID, senderDisplayName string) string {
|
|||
}
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) buildDynamicContext(channel, chatID, senderID, senderDisplayName string) string {
|
||||
func (cb *ContextBuilder) buildDynamicContext(
|
||||
channel, chatID, senderID, senderDisplayName string,
|
||||
) string {
|
||||
now := time.Now().Format("2006-01-02 15:04 (Monday)")
|
||||
rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
|
||||
|
||||
|
|
@ -854,7 +875,11 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
|||
case "assistant":
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
if len(sanitized) == 0 {
|
||||
logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{})
|
||||
logger.DebugCF(
|
||||
"agent",
|
||||
"Dropping assistant tool-call turn at history start",
|
||||
map[string]any{},
|
||||
)
|
||||
continue
|
||||
}
|
||||
prev := sanitized[len(sanitized)-1]
|
||||
|
|
@ -999,10 +1024,28 @@ func (cb *ContextBuilder) AddAssistantMessage(
|
|||
}
|
||||
|
||||
func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string {
|
||||
if cb.skillsLoader == nil || len(skillNames) == 0 {
|
||||
ordered := cb.ResolveActiveSkillsForContext(skillNames)
|
||||
if len(ordered) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
content := cb.skillsLoader.LoadSkillsForContext(ordered)
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`# Active Skills
|
||||
|
||||
The following skills are active for this request. Follow them when relevant.
|
||||
|
||||
%s`, content)
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) ResolveActiveSkillsForContext(skillNames []string) []string {
|
||||
if cb.skillsLoader == nil || len(skillNames) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ordered []string
|
||||
seen := make(map[string]struct{}, len(skillNames))
|
||||
for _, name := range skillNames {
|
||||
|
|
@ -1017,19 +1060,9 @@ func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string {
|
|||
ordered = append(ordered, canonical)
|
||||
}
|
||||
if len(ordered) == 0 {
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
|
||||
content := cb.skillsLoader.LoadSkillsForContext(ordered)
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`# Active Skills
|
||||
|
||||
The following skills are active for this request. Follow them when relevant.
|
||||
|
||||
%s`, content)
|
||||
return ordered
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) buildActiveSkillsPromptParts(skillNames []string) []PromptPart {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
|
@ -41,7 +42,7 @@ func (m *legacyContextManager) Compact(_ context.Context, req *CompactRequest) e
|
|||
// Sync emergency compression — budget exceeded.
|
||||
if result, ok := m.forceCompression(req.SessionKey); ok {
|
||||
m.al.emitEvent(
|
||||
EventKindContextCompress,
|
||||
runtimeevents.KindAgentContextCompress,
|
||||
m.al.newTurnEventScope("", req.SessionKey, nil).meta(0, "forceCompression", "turn.context.compress"),
|
||||
ContextCompressPayload{
|
||||
Reason: req.Reason,
|
||||
|
|
@ -246,7 +247,7 @@ func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey
|
|||
agent.Sessions.TruncateHistory(sessionKey, keepCount)
|
||||
agent.Sessions.Save(sessionKey)
|
||||
m.al.emitEvent(
|
||||
EventKindSessionSummarize,
|
||||
runtimeevents.KindAgentSessionSummarize,
|
||||
m.al.newTurnEventScope(agent.ID, sessionKey, nil).meta(0, "summarizeSession", "turn.session.summarize"),
|
||||
SessionSummarizePayload{
|
||||
SummarizedMessages: len(validMessages),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
|
|
@ -305,8 +306,13 @@ func TestLegacyCompact_Overflow(t *testing.T) {
|
|||
}
|
||||
defaultAgent.Sessions.SetHistory("session-overflow", history)
|
||||
|
||||
sub := al.SubscribeEvents(16)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest(
|
||||
t,
|
||||
al,
|
||||
16,
|
||||
runtimeevents.KindAgentContextCompress,
|
||||
)
|
||||
defer closeRuntimeEvents()
|
||||
|
||||
err := al.contextManager.Compact(context.Background(), &CompactRequest{
|
||||
SessionKey: "session-overflow",
|
||||
|
|
@ -329,8 +335,8 @@ func TestLegacyCompact_Overflow(t *testing.T) {
|
|||
}
|
||||
|
||||
// Event should carry the proactive reason
|
||||
events := collectEventStream(sub.C)
|
||||
compressEvt, ok := findEvent(events, EventKindContextCompress)
|
||||
events := collectRuntimeEventStream(runtimeCh)
|
||||
compressEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentContextCompress)
|
||||
if !ok {
|
||||
t.Fatal("expected context compress event")
|
||||
}
|
||||
|
|
@ -361,8 +367,13 @@ func TestLegacyCompact_Overflow_ProactiveReason(t *testing.T) {
|
|||
}
|
||||
defaultAgent.Sessions.SetHistory("session-proactive", history)
|
||||
|
||||
sub := al.SubscribeEvents(16)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest(
|
||||
t,
|
||||
al,
|
||||
16,
|
||||
runtimeevents.KindAgentContextCompress,
|
||||
)
|
||||
defer closeRuntimeEvents()
|
||||
|
||||
err := al.contextManager.Compact(context.Background(), &CompactRequest{
|
||||
SessionKey: "session-proactive",
|
||||
|
|
@ -372,8 +383,8 @@ func TestLegacyCompact_Overflow_ProactiveReason(t *testing.T) {
|
|||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
events := collectEventStream(sub.C)
|
||||
compressEvt, ok := findEvent(events, EventKindContextCompress)
|
||||
events := collectRuntimeEventStream(runtimeCh)
|
||||
compressEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentContextCompress)
|
||||
if !ok {
|
||||
t.Fatal("expected context compress event")
|
||||
}
|
||||
|
|
@ -483,6 +494,14 @@ func TestLegacyCompact_PostTurn_ExceedsMessageThreshold(t *testing.T) {
|
|||
}
|
||||
defaultAgent.Sessions.SetHistory("session-threshold", history)
|
||||
|
||||
runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest(
|
||||
t,
|
||||
al,
|
||||
16,
|
||||
runtimeevents.KindAgentSessionSummarize,
|
||||
)
|
||||
defer closeRuntimeEvents()
|
||||
|
||||
err := al.contextManager.Compact(context.Background(), &CompactRequest{
|
||||
SessionKey: "session-threshold",
|
||||
Reason: ContextCompressReasonSummarize,
|
||||
|
|
@ -491,12 +510,8 @@ func TestLegacyCompact_PostTurn_ExceedsMessageThreshold(t *testing.T) {
|
|||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Wait for async summarization to complete via event
|
||||
sub := al.SubscribeEvents(16)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
waitForEvent(t, sub.C, 5*time.Second, func(evt Event) bool {
|
||||
return evt.Kind == EventKindSessionSummarize
|
||||
waitForRuntimeEvent(t, runtimeCh, 5*time.Second, func(evt runtimeevents.Event) bool {
|
||||
return evt.Kind == runtimeevents.KindAgentSessionSummarize
|
||||
})
|
||||
|
||||
newHistory := defaultAgent.Sessions.GetHistory("session-threshold")
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ type AgentFrontmatter struct {
|
|||
MaxTurns *int `json:"maxTurns,omitempty"`
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
MCPServers []string `json:"mcpServers,omitempty"`
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
Fields map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
// AgentPromptDefinition represents the parsed AGENT.md or AGENTS.md prompt file.
|
||||
|
|
@ -45,6 +45,7 @@ type AgentPromptDefinition struct {
|
|||
Body string `json:"body"`
|
||||
RawFrontmatter string `json:"raw_frontmatter,omitempty"`
|
||||
Frontmatter AgentFrontmatter `json:"frontmatter"`
|
||||
FrontmatterErr string `json:"frontmatter_error,omitempty"`
|
||||
}
|
||||
|
||||
// SoulDefinition represents the resolved SOUL.md file linked to the agent.
|
||||
|
|
@ -146,19 +147,21 @@ func loadUserDefinition(workspace string) *UserDefinition {
|
|||
|
||||
func parseAgentPromptDefinition(path, content string) AgentPromptDefinition {
|
||||
frontmatter, body := splitAgentFrontmatter(content)
|
||||
parsedFrontmatter, err := parseAgentFrontmatter(path, frontmatter)
|
||||
return AgentPromptDefinition{
|
||||
Path: path,
|
||||
Raw: content,
|
||||
Body: body,
|
||||
RawFrontmatter: frontmatter,
|
||||
Frontmatter: parseAgentFrontmatter(path, frontmatter),
|
||||
Frontmatter: parsedFrontmatter,
|
||||
FrontmatterErr: errorString(err),
|
||||
}
|
||||
}
|
||||
|
||||
func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter {
|
||||
func parseAgentFrontmatter(path, frontmatter string) (AgentFrontmatter, error) {
|
||||
frontmatter = strings.TrimSpace(frontmatter)
|
||||
if frontmatter == "" {
|
||||
return AgentFrontmatter{}
|
||||
return AgentFrontmatter{}, nil
|
||||
}
|
||||
|
||||
rawFields := make(map[string]any)
|
||||
|
|
@ -167,7 +170,7 @@ func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter {
|
|||
"path": path,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return AgentFrontmatter{}
|
||||
return AgentFrontmatter{}, err
|
||||
}
|
||||
|
||||
var typed struct {
|
||||
|
|
@ -184,7 +187,7 @@ func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter {
|
|||
"path": path,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return AgentFrontmatter{}
|
||||
return AgentFrontmatter{}, err
|
||||
}
|
||||
|
||||
return AgentFrontmatter{
|
||||
|
|
@ -196,7 +199,7 @@ func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter {
|
|||
Skills: append([]string(nil), typed.Skills...),
|
||||
MCPServers: append([]string(nil), typed.MCPServers...),
|
||||
Fields: rawFields,
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func splitAgentFrontmatter(content string) (frontmatter, body string) {
|
||||
|
|
@ -253,3 +256,10 @@ func fileExists(path string) bool {
|
|||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func errorString(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
|
|
|||
263
pkg/agent/discovery.go
Normal file
263
pkg/agent/discovery.go
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/routing"
|
||||
)
|
||||
|
||||
// AgentDescriptor is the structured discovery payload injected into each
|
||||
// agent's system prompt so the LLM can choose a peer by identity.
|
||||
type AgentDescriptor struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// ListAgents returns structured descriptors for every agent in the current
|
||||
// PicoClaw instance. The current workspace, when provided, is used only to
|
||||
// order the matching agent first for prompt readability.
|
||||
func (r *AgentRegistry) ListAgents(workspace string) []AgentDescriptor {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
ids := make([]string, 0, len(r.agents))
|
||||
for id := range r.agents {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
selfWorkspace := cleanWorkspacePath(workspace)
|
||||
descriptors := make([]AgentDescriptor, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
agent := r.agents[id]
|
||||
if agent == nil {
|
||||
continue
|
||||
}
|
||||
descriptors = append(descriptors, r.buildAgentDescriptorLocked(agent))
|
||||
}
|
||||
|
||||
if selfWorkspace == "" {
|
||||
return descriptors
|
||||
}
|
||||
|
||||
sort.SliceStable(descriptors, func(i, j int) bool {
|
||||
leftSelf := cleanWorkspacePath(
|
||||
r.workspaceForAgentIDLocked(descriptors[i].ID),
|
||||
) == selfWorkspace
|
||||
rightSelf := cleanWorkspacePath(
|
||||
r.workspaceForAgentIDLocked(descriptors[j].ID),
|
||||
) == selfWorkspace
|
||||
if leftSelf != rightSelf {
|
||||
return leftSelf
|
||||
}
|
||||
return descriptors[i].ID < descriptors[j].ID
|
||||
})
|
||||
|
||||
return descriptors
|
||||
}
|
||||
|
||||
// ListSpawnableAgents returns descriptors only when the current agent can call
|
||||
// spawn, and only for peers it is allowed to spawn. Restricted peers are
|
||||
// intentionally omitted from discovery.
|
||||
func (r *AgentRegistry) ListSpawnableAgents(agentID string) []AgentDescriptor {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
parentID := routing.NormalizeAgentID(agentID)
|
||||
parent, ok := r.agents[parentID]
|
||||
if !ok || parent == nil {
|
||||
return nil
|
||||
}
|
||||
if !agentHasSpawnTool(parent) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(r.agents))
|
||||
for id := range r.agents {
|
||||
if id == parentID {
|
||||
continue
|
||||
}
|
||||
if !agentAllowsSubagent(parent, id) {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
descriptors := make([]AgentDescriptor, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
agent := r.agents[id]
|
||||
if agent == nil {
|
||||
continue
|
||||
}
|
||||
descriptors = append(descriptors, r.buildAgentDescriptorLocked(agent))
|
||||
}
|
||||
return descriptors
|
||||
}
|
||||
|
||||
// GetAgentDescriptor returns the structured discovery payload for one agent.
|
||||
func (r *AgentRegistry) GetAgentDescriptor(agentID string) (*AgentDescriptor, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
id := routing.NormalizeAgentID(agentID)
|
||||
agent, ok := r.agents[id]
|
||||
if !ok || agent == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
descriptor := r.buildAgentDescriptorLocked(agent)
|
||||
return &descriptor, true
|
||||
}
|
||||
|
||||
func (r *AgentRegistry) buildAgentDescriptorLocked(agent *AgentInstance) AgentDescriptor {
|
||||
definition := loadAgentDefinition(agent.Workspace)
|
||||
name, description := descriptorIdentity(agent.ID, definition)
|
||||
|
||||
return AgentDescriptor{
|
||||
ID: agent.ID,
|
||||
Name: name,
|
||||
Description: description,
|
||||
}
|
||||
}
|
||||
|
||||
func descriptorIdentity(agentID string, definition AgentContextDefinition) (string, string) {
|
||||
name := agentID
|
||||
description := ""
|
||||
if definition.Agent != nil {
|
||||
if trimmed := strings.TrimSpace(definition.Agent.Frontmatter.Name); trimmed != "" {
|
||||
name = trimmed
|
||||
}
|
||||
if trimmed := strings.TrimSpace(definition.Agent.Frontmatter.Description); trimmed != "" {
|
||||
description = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
if description == "" &&
|
||||
definition.Agent != nil {
|
||||
if definition.Source == AgentDefinitionSourceAgent {
|
||||
description = firstNonEmptyLine(definition.Agent.Body)
|
||||
} else if definition.Source == AgentDefinitionSourceAgents {
|
||||
description = firstMeaningfulParagraph(definition.Agent.Body)
|
||||
}
|
||||
}
|
||||
|
||||
return name, description
|
||||
}
|
||||
|
||||
func firstNonEmptyLine(content string) string {
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstMeaningfulParagraph(content string) string {
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
paragraphs := strings.Split(content, "\n\n")
|
||||
for _, paragraph := range paragraphs {
|
||||
lines := strings.Split(paragraph, "\n")
|
||||
parts := make([]string, 0, len(lines))
|
||||
inFence := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "```") {
|
||||
inFence = !inFence
|
||||
continue
|
||||
}
|
||||
if inFence || trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
|
||||
trimmed = strings.TrimSpace(trimmed[2:])
|
||||
}
|
||||
parts = append(parts, trimmed)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
continue
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *AgentRegistry) workspaceForAgentIDLocked(agentID string) string {
|
||||
agent, ok := r.agents[routing.NormalizeAgentID(agentID)]
|
||||
if !ok || agent == nil {
|
||||
return ""
|
||||
}
|
||||
return agent.Workspace
|
||||
}
|
||||
|
||||
func (r *AgentRegistry) defaultAgentIDLocked() string {
|
||||
if _, ok := r.agents[routing.DefaultAgentID]; ok {
|
||||
return routing.DefaultAgentID
|
||||
}
|
||||
if r.cfg != nil && len(r.cfg.Agents.List) > 0 {
|
||||
for _, agentCfg := range r.cfg.Agents.List {
|
||||
if !agentCfg.Default {
|
||||
continue
|
||||
}
|
||||
id := routing.NormalizeAgentID(agentCfg.ID)
|
||||
if _, ok := r.agents[id]; ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
id := routing.NormalizeAgentID(r.cfg.Agents.List[0].ID)
|
||||
if _, ok := r.agents[id]; ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
for id := range r.agents {
|
||||
return id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cleanWorkspacePath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
|
||||
func formatAgentDiscoverySection(agents []AgentDescriptor) string {
|
||||
if len(agents) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
payload := struct {
|
||||
Agents []AgentDescriptor `json:"agents"`
|
||||
}{
|
||||
Agents: agents,
|
||||
}
|
||||
|
||||
encoded, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var header strings.Builder
|
||||
header.WriteString("# Agent Discovery\n\n")
|
||||
header.WriteString("This registry lists the peer agents this agent is permitted to spawn.\n")
|
||||
header.WriteString(
|
||||
"Choose a peer based on its description. Use only agent IDs listed here when calling spawn.\n\n",
|
||||
)
|
||||
header.WriteString("```json\n")
|
||||
header.Write(encoded)
|
||||
header.WriteString("\n```")
|
||||
|
||||
return header.String()
|
||||
}
|
||||
420
pkg/agent/discovery_test.go
Normal file
420
pkg/agent/discovery_test.go
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestAgentRegistry_ListAgentsBuildsStructuredDescriptors(t *testing.T) {
|
||||
mainWorkspace := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": `---
|
||||
name: Main Frontmatter Name
|
||||
description: Structured main agent
|
||||
---
|
||||
# Agent
|
||||
|
||||
Handle general requests.
|
||||
`,
|
||||
})
|
||||
defer cleanupWorkspace(t, mainWorkspace)
|
||||
|
||||
supportWorkspace := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": `---
|
||||
name: Support Frontmatter Name
|
||||
description: Support frontmatter description
|
||||
---
|
||||
# Agent
|
||||
|
||||
Handle support tickets carefully.
|
||||
`,
|
||||
})
|
||||
defer cleanupWorkspace(t, supportWorkspace)
|
||||
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{ID: "main", Default: true, Name: "Configured Main", Workspace: mainWorkspace},
|
||||
{ID: "support", Workspace: supportWorkspace},
|
||||
})
|
||||
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
descriptors := registry.ListAgents(mainWorkspace)
|
||||
if len(descriptors) != 2 {
|
||||
t.Fatalf("expected 2 descriptors, got %d", len(descriptors))
|
||||
}
|
||||
|
||||
if descriptors[0].ID != "main" {
|
||||
t.Fatalf("expected current workspace agent first, got %q", descriptors[0].ID)
|
||||
}
|
||||
if descriptors[0].Name != "Main Frontmatter Name" {
|
||||
t.Fatalf("expected frontmatter name to drive discovery, got %q", descriptors[0].Name)
|
||||
}
|
||||
if descriptors[0].Description != "Structured main agent" {
|
||||
t.Fatalf("expected frontmatter description, got %q", descriptors[0].Description)
|
||||
}
|
||||
|
||||
support, ok := registry.GetAgentDescriptor("support")
|
||||
if !ok || support == nil {
|
||||
t.Fatal("expected support descriptor lookup to succeed")
|
||||
}
|
||||
if support.Name != "Support Frontmatter Name" {
|
||||
t.Fatalf("expected support frontmatter name, got %q", support.Name)
|
||||
}
|
||||
if support.Description != "Support frontmatter description" {
|
||||
t.Fatalf("expected support frontmatter description, got %q", support.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_ListSpawnableAgentsRespectsPermissions(t *testing.T) {
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{
|
||||
ID: "parent",
|
||||
Default: true,
|
||||
Subagents: &config.SubagentsConfig{
|
||||
AllowAgents: []string{"child2", "child1"},
|
||||
},
|
||||
},
|
||||
{ID: "child1"},
|
||||
{ID: "child2"},
|
||||
{ID: "restricted"},
|
||||
})
|
||||
cfg.Tools.Spawn.Enabled = true
|
||||
cfg.Tools.Subagent.Enabled = true
|
||||
|
||||
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{})
|
||||
defer al.Close()
|
||||
|
||||
descriptors := al.GetRegistry().ListSpawnableAgents("parent")
|
||||
if len(descriptors) != 2 {
|
||||
t.Fatalf("expected 2 spawnable descriptors, got %d: %+v", len(descriptors), descriptors)
|
||||
}
|
||||
if descriptors[0].ID != "child1" || descriptors[1].ID != "child2" {
|
||||
t.Fatalf("expected sorted spawnable peers only, got %+v", descriptors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_ListSpawnableAgentsRequiresSpawnTool(t *testing.T) {
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{
|
||||
ID: "parent",
|
||||
Default: true,
|
||||
Subagents: &config.SubagentsConfig{
|
||||
AllowAgents: []string{"child"},
|
||||
},
|
||||
},
|
||||
{ID: "child"},
|
||||
})
|
||||
cfg.Tools.Subagent.Enabled = true
|
||||
|
||||
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{})
|
||||
defer al.Close()
|
||||
|
||||
if descriptors := al.GetRegistry().ListSpawnableAgents("parent"); len(descriptors) != 0 {
|
||||
t.Fatalf("expected no spawnable descriptors without spawn tool, got %+v", descriptors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_BuildMessagesIncludesAgentDiscoverySection(t *testing.T) {
|
||||
mainWorkspace := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": `---
|
||||
description: Main agent
|
||||
---
|
||||
# Agent
|
||||
|
||||
Generalist.
|
||||
`,
|
||||
})
|
||||
defer cleanupWorkspace(t, mainWorkspace)
|
||||
|
||||
researchWorkspace := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": `---
|
||||
name: Research Agent
|
||||
description: Research specialist
|
||||
---
|
||||
# Agent
|
||||
|
||||
Investigate deeply.
|
||||
`,
|
||||
})
|
||||
defer cleanupWorkspace(t, researchWorkspace)
|
||||
|
||||
restrictedWorkspace := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": `---
|
||||
name: Restricted Agent
|
||||
description: Restricted specialist
|
||||
---
|
||||
# Agent
|
||||
|
||||
Handle restricted work.
|
||||
`,
|
||||
})
|
||||
defer cleanupWorkspace(t, restrictedWorkspace)
|
||||
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{
|
||||
ID: "main",
|
||||
Default: true,
|
||||
Workspace: mainWorkspace,
|
||||
Subagents: &config.SubagentsConfig{
|
||||
AllowAgents: []string{"research"},
|
||||
},
|
||||
},
|
||||
{ID: "research", Workspace: researchWorkspace},
|
||||
{ID: "restricted", Workspace: restrictedWorkspace},
|
||||
})
|
||||
cfg.Tools.ReadFile.Enabled = true
|
||||
cfg.Tools.WriteFile.Enabled = true
|
||||
cfg.Tools.Spawn.Enabled = true
|
||||
cfg.Tools.Subagent.Enabled = true
|
||||
|
||||
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{})
|
||||
defer al.Close()
|
||||
|
||||
mainAgent, ok := al.GetRegistry().GetAgent("main")
|
||||
if !ok || mainAgent == nil {
|
||||
t.Fatal("expected main agent")
|
||||
}
|
||||
|
||||
messages := mainAgent.ContextBuilder.BuildMessages(
|
||||
nil,
|
||||
"",
|
||||
"delegate wisely",
|
||||
nil,
|
||||
"telegram",
|
||||
"chat-1",
|
||||
"",
|
||||
"",
|
||||
)
|
||||
if len(messages) == 0 {
|
||||
t.Fatal("expected messages")
|
||||
}
|
||||
|
||||
systemPrompt := messages[0].Content
|
||||
if !strings.Contains(systemPrompt, "# Agent Discovery") {
|
||||
t.Fatalf("expected discovery section in system prompt, got %q", systemPrompt)
|
||||
}
|
||||
if strings.Contains(systemPrompt, `"id": "main"`) {
|
||||
t.Fatalf("did not expect self descriptor in discovery section, got %q", systemPrompt)
|
||||
}
|
||||
if !strings.Contains(systemPrompt, `"id": "research"`) ||
|
||||
!strings.Contains(systemPrompt, `"description": "Research specialist"`) {
|
||||
t.Fatalf("expected allowed peer descriptor in discovery section, got %q", systemPrompt)
|
||||
}
|
||||
if strings.Contains(systemPrompt, `"id": "restricted"`) ||
|
||||
strings.Contains(systemPrompt, `"description": "Restricted specialist"`) {
|
||||
t.Fatalf("did not expect restricted peer descriptor in discovery section, got %q", systemPrompt)
|
||||
}
|
||||
for _, forbidden := range []string{`"current_agent_id"`, `"available_tools"`, `"model"`, `"channels"`, `"skills"`, `"mcpServers"`, `"tools"`} {
|
||||
if strings.Contains(systemPrompt, forbidden) {
|
||||
t.Fatalf("did not expect %s in discovery section, got %q", forbidden, systemPrompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_BuildMessagesOmitsAgentDiscoveryWithoutSpawnPermissions(t *testing.T) {
|
||||
mainWorkspace := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": `---
|
||||
description: Main agent
|
||||
---
|
||||
# Agent
|
||||
|
||||
Generalist.
|
||||
`,
|
||||
})
|
||||
defer cleanupWorkspace(t, mainWorkspace)
|
||||
|
||||
researchWorkspace := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": `---
|
||||
description: Research specialist
|
||||
---
|
||||
# Agent
|
||||
|
||||
Investigate deeply.
|
||||
`,
|
||||
})
|
||||
defer cleanupWorkspace(t, researchWorkspace)
|
||||
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{ID: "main", Default: true, Workspace: mainWorkspace},
|
||||
{ID: "research", Workspace: researchWorkspace},
|
||||
})
|
||||
cfg.Tools.ReadFile.Enabled = true
|
||||
cfg.Tools.Spawn.Enabled = true
|
||||
cfg.Tools.Subagent.Enabled = true
|
||||
|
||||
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{})
|
||||
defer al.Close()
|
||||
|
||||
mainAgent, ok := al.GetRegistry().GetAgent("main")
|
||||
if !ok || mainAgent == nil {
|
||||
t.Fatal("expected main agent")
|
||||
}
|
||||
|
||||
messages := mainAgent.ContextBuilder.BuildMessages(
|
||||
nil,
|
||||
"",
|
||||
"handle locally",
|
||||
nil,
|
||||
"telegram",
|
||||
"chat-1",
|
||||
"",
|
||||
"",
|
||||
)
|
||||
if len(messages) == 0 {
|
||||
t.Fatal("expected messages")
|
||||
}
|
||||
|
||||
systemPrompt := messages[0].Content
|
||||
if strings.Contains(systemPrompt, "# Agent Discovery") {
|
||||
t.Fatalf("did not expect discovery section without spawn permissions, got %q", systemPrompt)
|
||||
}
|
||||
if strings.Contains(systemPrompt, `"id": "research"`) {
|
||||
t.Fatalf("did not expect unauthorized peer identity in system prompt, got %q", systemPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_BuildMessagesOmitsAgentDiscoveryWithoutSpawnTool(t *testing.T) {
|
||||
mainWorkspace := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": `---
|
||||
description: Main agent
|
||||
tools: [read_file]
|
||||
---
|
||||
# Agent
|
||||
|
||||
Generalist.
|
||||
`,
|
||||
})
|
||||
defer cleanupWorkspace(t, mainWorkspace)
|
||||
|
||||
researchWorkspace := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": `---
|
||||
description: Research specialist
|
||||
---
|
||||
# Agent
|
||||
|
||||
Investigate deeply.
|
||||
`,
|
||||
})
|
||||
defer cleanupWorkspace(t, researchWorkspace)
|
||||
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{
|
||||
ID: "main",
|
||||
Default: true,
|
||||
Workspace: mainWorkspace,
|
||||
Subagents: &config.SubagentsConfig{
|
||||
AllowAgents: []string{"research"},
|
||||
},
|
||||
},
|
||||
{ID: "research", Workspace: researchWorkspace},
|
||||
})
|
||||
cfg.Tools.ReadFile.Enabled = true
|
||||
cfg.Tools.Spawn.Enabled = true
|
||||
cfg.Tools.Subagent.Enabled = true
|
||||
|
||||
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{})
|
||||
defer al.Close()
|
||||
|
||||
mainAgent, ok := al.GetRegistry().GetAgent("main")
|
||||
if !ok || mainAgent == nil {
|
||||
t.Fatal("expected main agent")
|
||||
}
|
||||
|
||||
messages := mainAgent.ContextBuilder.BuildMessages(
|
||||
nil,
|
||||
"",
|
||||
"handle locally",
|
||||
nil,
|
||||
"telegram",
|
||||
"chat-1",
|
||||
"",
|
||||
"",
|
||||
)
|
||||
if len(messages) == 0 {
|
||||
t.Fatal("expected messages")
|
||||
}
|
||||
|
||||
systemPrompt := messages[0].Content
|
||||
if strings.Contains(systemPrompt, "# Agent Discovery") {
|
||||
t.Fatalf("did not expect discovery section without spawn tool, got %q", systemPrompt)
|
||||
}
|
||||
if strings.Contains(systemPrompt, `"id": "research"`) {
|
||||
t.Fatalf("did not expect peer identity without spawn tool, got %q", systemPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_BuildMessagesOmitsAgentDiscoverySectionForSingleton(t *testing.T) {
|
||||
mainWorkspace := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": `---
|
||||
description: Main agent
|
||||
---
|
||||
# Agent
|
||||
|
||||
Generalist.
|
||||
`,
|
||||
})
|
||||
defer cleanupWorkspace(t, mainWorkspace)
|
||||
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{ID: "main", Default: true, Workspace: mainWorkspace},
|
||||
})
|
||||
cfg.Tools.ReadFile.Enabled = true
|
||||
cfg.Tools.Spawn.Enabled = true
|
||||
cfg.Tools.Subagent.Enabled = true
|
||||
|
||||
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{})
|
||||
defer al.Close()
|
||||
|
||||
mainAgent, ok := al.GetRegistry().GetAgent("main")
|
||||
if !ok || mainAgent == nil {
|
||||
t.Fatal("expected main agent")
|
||||
}
|
||||
|
||||
messages := mainAgent.ContextBuilder.BuildMessages(
|
||||
nil,
|
||||
"",
|
||||
"handle locally",
|
||||
nil,
|
||||
"telegram",
|
||||
"chat-1",
|
||||
"",
|
||||
"",
|
||||
)
|
||||
if len(messages) == 0 {
|
||||
t.Fatal("expected messages")
|
||||
}
|
||||
|
||||
systemPrompt := messages[0].Content
|
||||
if strings.Contains(systemPrompt, "# Agent Discovery") {
|
||||
t.Fatalf("did not expect discovery section for singleton registry, got %q", systemPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_ListAgentsFallsBackToFirstNonEmptyAgentLine(t *testing.T) {
|
||||
workspace := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": `---
|
||||
name: Research Agent
|
||||
---
|
||||
|
||||
|
||||
First useful line.
|
||||
Second line.
|
||||
`,
|
||||
})
|
||||
defer cleanupWorkspace(t, workspace)
|
||||
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{ID: "research", Default: true, Workspace: workspace},
|
||||
})
|
||||
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
descriptor, ok := registry.GetAgentDescriptor("research")
|
||||
if !ok || descriptor == nil {
|
||||
t.Fatal("expected research descriptor lookup to succeed")
|
||||
}
|
||||
if descriptor.Description != "First useful line." {
|
||||
t.Fatalf("descriptor.Description = %q, want %q", descriptor.Description, "First useful line.")
|
||||
}
|
||||
}
|
||||
198
pkg/agent/event_payloads.go
Normal file
198
pkg/agent/event_payloads.go
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
package agent
|
||||
|
||||
import "time"
|
||||
|
||||
// TurnEndStatus describes the terminal state of a turn.
|
||||
type TurnEndStatus string
|
||||
|
||||
const (
|
||||
// TurnEndStatusCompleted indicates the turn finished normally.
|
||||
TurnEndStatusCompleted TurnEndStatus = "completed"
|
||||
// TurnEndStatusError indicates the turn ended because of an error.
|
||||
TurnEndStatusError TurnEndStatus = "error"
|
||||
// TurnEndStatusAborted indicates the turn was hard-aborted and rolled back.
|
||||
TurnEndStatusAborted TurnEndStatus = "aborted"
|
||||
)
|
||||
|
||||
// TurnStartPayload describes the start of a turn.
|
||||
type TurnStartPayload struct {
|
||||
UserMessage string
|
||||
MediaCount int
|
||||
}
|
||||
|
||||
const (
|
||||
skillContextTriggerInitialBuild = "initial_build"
|
||||
skillContextTriggerContextRetryRebuild = "context_retry_rebuild"
|
||||
)
|
||||
|
||||
type SkillContextSnapshot struct {
|
||||
Sequence int `json:"sequence"`
|
||||
Trigger string `json:"trigger"`
|
||||
SkillNames []string `json:"skill_names,omitempty"`
|
||||
}
|
||||
|
||||
type ToolExecutionRecord struct {
|
||||
Name string `json:"name"`
|
||||
Success bool `json:"success"`
|
||||
ErrorSummary string `json:"error_summary,omitempty"`
|
||||
SkillNames []string `json:"skill_names,omitempty"`
|
||||
}
|
||||
|
||||
// TurnEndPayload describes the completion of a turn.
|
||||
type TurnEndPayload struct {
|
||||
Status TurnEndStatus
|
||||
Workspace string
|
||||
Iterations int
|
||||
Duration time.Duration
|
||||
FinalContentLen int
|
||||
UserMessage string
|
||||
FinalContent string
|
||||
ActiveSkills []string
|
||||
AttemptedSkills []string
|
||||
FinalSuccessfulPath []string
|
||||
SkillContextSnapshots []SkillContextSnapshot
|
||||
ToolKinds []string
|
||||
ToolExecutions []ToolExecutionRecord
|
||||
}
|
||||
|
||||
// LLMRequestPayload describes an outbound LLM request.
|
||||
type LLMRequestPayload struct {
|
||||
Model string
|
||||
MessagesCount int
|
||||
ToolsCount int
|
||||
MaxTokens int
|
||||
Temperature float64
|
||||
}
|
||||
|
||||
// LLMResponsePayload describes an inbound LLM response.
|
||||
type LLMResponsePayload struct {
|
||||
ContentLen int
|
||||
ToolCalls int
|
||||
HasReasoning bool
|
||||
}
|
||||
|
||||
// LLMDeltaPayload describes a streamed LLM delta.
|
||||
type LLMDeltaPayload struct {
|
||||
ContentDeltaLen int
|
||||
ReasoningDeltaLen int
|
||||
}
|
||||
|
||||
// LLMRetryPayload describes a retry of an LLM request.
|
||||
type LLMRetryPayload struct {
|
||||
Attempt int
|
||||
MaxRetries int
|
||||
Reason string
|
||||
Error string
|
||||
Backoff time.Duration
|
||||
}
|
||||
|
||||
// ContextCompressReason identifies why emergency compression ran.
|
||||
type ContextCompressReason string
|
||||
|
||||
const (
|
||||
// ContextCompressReasonProactive indicates compression before the first LLM call.
|
||||
ContextCompressReasonProactive ContextCompressReason = "proactive_budget"
|
||||
// ContextCompressReasonRetry indicates compression during context-error retry handling.
|
||||
ContextCompressReasonRetry ContextCompressReason = "llm_retry"
|
||||
// ContextCompressReasonSummarize indicates post-turn async summarization.
|
||||
ContextCompressReasonSummarize ContextCompressReason = "summarize"
|
||||
)
|
||||
|
||||
// ContextCompressPayload describes a forced history compression.
|
||||
type ContextCompressPayload struct {
|
||||
Reason ContextCompressReason
|
||||
DroppedMessages int
|
||||
RemainingMessages int
|
||||
}
|
||||
|
||||
// SessionSummarizePayload describes a completed async session summarization.
|
||||
type SessionSummarizePayload struct {
|
||||
SummarizedMessages int
|
||||
KeptMessages int
|
||||
SummaryLen int
|
||||
OmittedOversized bool
|
||||
}
|
||||
|
||||
// ToolExecStartPayload describes a tool execution request.
|
||||
type ToolExecStartPayload struct {
|
||||
Tool string
|
||||
Arguments map[string]any
|
||||
}
|
||||
|
||||
// ToolExecEndPayload describes the outcome of a tool execution.
|
||||
type ToolExecEndPayload struct {
|
||||
Tool string
|
||||
Duration time.Duration
|
||||
ForLLMLen int
|
||||
ForUserLen int
|
||||
IsError bool
|
||||
Async bool
|
||||
}
|
||||
|
||||
// ToolExecSkippedPayload describes a skipped tool call.
|
||||
type ToolExecSkippedPayload struct {
|
||||
Tool string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// SteeringInjectedPayload describes steering messages appended before the next LLM call.
|
||||
type SteeringInjectedPayload struct {
|
||||
Count int
|
||||
TotalContentLen int
|
||||
}
|
||||
|
||||
// FollowUpQueuedPayload describes an async follow-up queued back into the inbound bus.
|
||||
type FollowUpQueuedPayload struct {
|
||||
SourceTool string
|
||||
ContentLen int
|
||||
}
|
||||
|
||||
type InterruptKind string
|
||||
|
||||
const (
|
||||
InterruptKindSteering InterruptKind = "steering"
|
||||
InterruptKindGraceful InterruptKind = "graceful"
|
||||
InterruptKindHard InterruptKind = "hard_abort"
|
||||
)
|
||||
|
||||
// InterruptReceivedPayload describes accepted turn-control input.
|
||||
type InterruptReceivedPayload struct {
|
||||
Kind InterruptKind
|
||||
Role string
|
||||
ContentLen int
|
||||
QueueDepth int
|
||||
HintLen int
|
||||
}
|
||||
|
||||
// SubTurnSpawnPayload describes the creation of a child turn.
|
||||
type SubTurnSpawnPayload struct {
|
||||
AgentID string
|
||||
Label string
|
||||
ParentTurnID string
|
||||
}
|
||||
|
||||
// SubTurnEndPayload describes the completion of a child turn.
|
||||
type SubTurnEndPayload struct {
|
||||
AgentID string
|
||||
Status string
|
||||
}
|
||||
|
||||
// SubTurnResultDeliveredPayload describes delivery of a sub-turn result.
|
||||
type SubTurnResultDeliveredPayload struct {
|
||||
TargetChannel string
|
||||
TargetChatID string
|
||||
ContentLen int
|
||||
}
|
||||
|
||||
// SubTurnOrphanPayload describes a sub-turn result that could not be delivered.
|
||||
type SubTurnOrphanPayload struct {
|
||||
ParentTurnID string
|
||||
ChildTurnID string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ErrorPayload describes an execution error inside the agent loop.
|
||||
type ErrorPayload struct {
|
||||
Stage string
|
||||
Message string
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultEventSubscriberBuffer = 16
|
||||
|
||||
// EventSubscription identifies a subscriber channel returned by EventBus.Subscribe.
|
||||
type EventSubscription struct {
|
||||
ID uint64
|
||||
C <-chan Event
|
||||
}
|
||||
|
||||
type eventSubscriber struct {
|
||||
ch chan Event
|
||||
}
|
||||
|
||||
// EventBus is a lightweight multi-subscriber broadcaster for agent-loop events.
|
||||
type EventBus struct {
|
||||
mu sync.RWMutex
|
||||
subs map[uint64]eventSubscriber
|
||||
nextID uint64
|
||||
closed bool
|
||||
dropped [eventKindCount]atomic.Int64
|
||||
}
|
||||
|
||||
// NewEventBus creates a new in-process event broadcaster.
|
||||
func NewEventBus() *EventBus {
|
||||
return &EventBus{
|
||||
subs: make(map[uint64]eventSubscriber),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a new subscriber with the requested channel buffer size.
|
||||
// A non-positive buffer uses the default size.
|
||||
func (b *EventBus) Subscribe(buffer int) EventSubscription {
|
||||
if buffer <= 0 {
|
||||
buffer = defaultEventSubscriberBuffer
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.closed {
|
||||
ch := make(chan Event)
|
||||
close(ch)
|
||||
return EventSubscription{C: ch}
|
||||
}
|
||||
|
||||
b.nextID++
|
||||
id := b.nextID
|
||||
ch := make(chan Event, buffer)
|
||||
b.subs[id] = eventSubscriber{ch: ch}
|
||||
return EventSubscription{ID: id, C: ch}
|
||||
}
|
||||
|
||||
// Unsubscribe removes a subscriber and closes its channel.
|
||||
func (b *EventBus) Unsubscribe(id uint64) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
sub, ok := b.subs[id]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
delete(b.subs, id)
|
||||
close(sub.ch)
|
||||
}
|
||||
|
||||
// Emit broadcasts an event to all current subscribers without blocking.
|
||||
// When a subscriber channel is full, the event is dropped for that subscriber.
|
||||
func (b *EventBus) Emit(evt Event) {
|
||||
if evt.Time.IsZero() {
|
||||
evt.Time = time.Now()
|
||||
}
|
||||
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
if b.closed {
|
||||
return
|
||||
}
|
||||
|
||||
for _, sub := range b.subs {
|
||||
select {
|
||||
case sub.ch <- evt:
|
||||
default:
|
||||
if evt.Kind < eventKindCount {
|
||||
b.dropped[evt.Kind].Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dropped returns the number of dropped events for a given kind.
|
||||
func (b *EventBus) Dropped(kind EventKind) int64 {
|
||||
if kind >= eventKindCount {
|
||||
return 0
|
||||
}
|
||||
return b.dropped[kind].Load()
|
||||
}
|
||||
|
||||
// Close closes all subscriber channels and stops future broadcasts.
|
||||
func (b *EventBus) Close() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.closed {
|
||||
return
|
||||
}
|
||||
|
||||
b.closed = true
|
||||
for id, sub := range b.subs {
|
||||
close(sub.ch)
|
||||
delete(b.subs, id)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue