Merge branch 'sipeed:main' into main
This commit is contained in:
commit
f79a880efa
5 changed files with 538 additions and 65 deletions
44
README.md
44
README.md
|
|
@ -15,6 +15,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -42,16 +43,17 @@
|
||||||
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
||||||
>
|
>
|
||||||
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
|
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
|
||||||
|
>
|
||||||
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
|
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
|
||||||
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
|
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
|
||||||
> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
|
> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
|
||||||
> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
|
> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
|
||||||
|
|
||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/picoclaw_community_roadmap_260216.md) —we can’t wait to have you on board!
|
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/picoclaw_community_roadmap_260216.md) —we can’t wait to have you on board!
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs&issues come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
||||||
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
|
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
|
||||||
|
|
||||||
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
|
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
|
||||||
|
|
@ -100,9 +102,12 @@
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
### 📱 Run on old Android Phones
|
### 📱 Run on old Android Phones
|
||||||
|
|
||||||
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
|
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
|
||||||
|
|
||||||
1. **Install Termux** (Available on F-Droid or Google Play).
|
1. **Install Termux** (Available on F-Droid or Google Play).
|
||||||
2. **Execute cmds**
|
2. **Execute cmds**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Note: Replace v0.1.1 with the latest version from the Releases page
|
# Note: Replace v0.1.1 with the latest version from the Releases page
|
||||||
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
||||||
|
|
@ -110,6 +115,7 @@ chmod +x picoclaw-linux-arm64
|
||||||
pkg install proot
|
pkg install proot
|
||||||
termux-chroot ./picoclaw-linux-arm64 onboard
|
termux-chroot ./picoclaw-linux-arm64 onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
And then follow the instructions in the "Quick Start" section to complete the configuration!
|
And then follow the instructions in the "Quick Start" section to complete the configuration!
|
||||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
|
|
@ -323,7 +329,6 @@ picoclaw gateway
|
||||||
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
|
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
|
||||||
|
|
||||||
**3. Get your User ID**
|
**3. Get your User ID**
|
||||||
|
|
||||||
* Discord Settings → Advanced → enable **Developer Mode**
|
* Discord Settings → Advanced → enable **Developer Mode**
|
||||||
* Right-click your avatar → **Copy User ID**
|
* Right-click your avatar → **Copy User ID**
|
||||||
|
|
||||||
|
|
@ -425,7 +430,6 @@ picoclaw gateway
|
||||||
```bash
|
```bash
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
@ -521,7 +525,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
||||||
* Go to WeCom Admin Console → App Management → Create App
|
* Go to WeCom Admin Console → App Management → Create App
|
||||||
* Copy **AgentId** and **Secret**
|
* Copy **AgentId** and **Secret**
|
||||||
* Go to "My Company" page, copy **CorpID**
|
* Go to "My Company" page, copy **CorpID**
|
||||||
|
|
||||||
**2. Configure receive message**
|
**2. Configure receive message**
|
||||||
|
|
||||||
* In App details, click "Receive Message" → "Set API"
|
* In App details, click "Receive Message" → "Set API"
|
||||||
|
|
@ -606,7 +609,7 @@ PicoClaw runs in a sandboxed environment by default. The agent can only access f
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
| ----------------------- | ----------------------- | ----------------------------------------- |
|
||||||
| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
|
| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
|
||||||
| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
|
| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
|
||||||
|
|
||||||
|
|
@ -615,7 +618,7 @@ PicoClaw runs in a sandboxed environment by default. The agent can only access f
|
||||||
When `restrict_to_workspace: true`, the following tools are sandboxed:
|
When `restrict_to_workspace: true`, the following tools are sandboxed:
|
||||||
|
|
||||||
| Tool | Function | Restriction |
|
| Tool | Function | Restriction |
|
||||||
|------|----------|-------------|
|
| ------------- | ---------------- | -------------------------------------- |
|
||||||
| `read_file` | Read files | Only files within workspace |
|
| `read_file` | Read files | Only files within workspace |
|
||||||
| `write_file` | Write files | Only files within workspace |
|
| `write_file` | Write files | Only files within workspace |
|
||||||
| `list_dir` | List directories | Only directories within workspace |
|
| `list_dir` | List directories | Only directories within workspace |
|
||||||
|
|
@ -675,7 +678,7 @@ export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||||
The `restrict_to_workspace` setting applies consistently across all execution paths:
|
The `restrict_to_workspace` setting applies consistently across all execution paths:
|
||||||
|
|
||||||
| Execution Path | Security Boundary |
|
| Execution Path | Security Boundary |
|
||||||
|----------------|-------------------|
|
| ---------------- | ---------------------------- |
|
||||||
| Main Agent | `restrict_to_workspace` ✅ |
|
| Main Agent | `restrict_to_workspace` ✅ |
|
||||||
| Subagent / Spawn | Inherits same restriction ✅ |
|
| Subagent / Spawn | Inherits same restriction ✅ |
|
||||||
| Heartbeat tasks | Inherits same restriction ✅ |
|
| Heartbeat tasks | Inherits same restriction ✅ |
|
||||||
|
|
@ -704,9 +707,11 @@ For long-running tasks (web search, API calls), use the `spawn` tool to create a
|
||||||
# Periodic Tasks
|
# Periodic Tasks
|
||||||
|
|
||||||
## Quick Tasks (respond directly)
|
## Quick Tasks (respond directly)
|
||||||
|
|
||||||
- Report current time
|
- Report current time
|
||||||
|
|
||||||
## Long Tasks (use spawn for async)
|
## Long Tasks (use spawn for async)
|
||||||
|
|
||||||
- Search the web for AI news and summarize
|
- Search the web for AI news and summarize
|
||||||
- Check email and report important messages
|
- Check email and report important messages
|
||||||
```
|
```
|
||||||
|
|
@ -714,7 +719,7 @@ For long-running tasks (web search, API calls), use the `spawn` tool to create a
|
||||||
**Key behaviors:**
|
**Key behaviors:**
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
|---------|-------------|
|
| ----------------------- | --------------------------------------------------------- |
|
||||||
| **spawn** | Creates async subagent, doesn't block heartbeat |
|
| **spawn** | Creates async subagent, doesn't block heartbeat |
|
||||||
| **Independent context** | Subagent has its own context, no session history |
|
| **Independent context** | Subagent has its own context, no session history |
|
||||||
| **message tool** | Subagent communicates with user directly via message tool |
|
| **message tool** | Subagent communicates with user directly via message tool |
|
||||||
|
|
@ -750,7 +755,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
| ---------- | ------- | ---------------------------------- |
|
||||||
| `enabled` | `true` | Enable/disable heartbeat |
|
| `enabled` | `true` | Enable/disable heartbeat |
|
||||||
| `interval` | `30` | Check interval in minutes (min: 5) |
|
| `interval` | `30` | Check interval in minutes (min: 5) |
|
||||||
|
|
||||||
|
|
@ -765,9 +770,9 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
||||||
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
||||||
|
|
||||||
| Provider | Purpose | Get API Key |
|
| Provider | Purpose | Get API Key |
|
||||||
| -------------------------- | --------------------------------------- | ------------------------------------------------------ |
|
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
|
||||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
|
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
|
||||||
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||||
|
|
@ -790,7 +795,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
#### 📋 All Supported Vendors
|
#### 📋 All Supported Vendors
|
||||||
|
|
||||||
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
||||||
|--------|----------------|------------------|----------|---------|
|
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
|
||||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
|
|
@ -841,6 +846,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
#### Vendor-Specific Examples
|
#### Vendor-Specific Examples
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.2",
|
||||||
|
|
@ -850,6 +856,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
```
|
```
|
||||||
|
|
||||||
**智谱 AI (GLM)**
|
**智谱 AI (GLM)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
|
|
@ -859,6 +866,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
```
|
```
|
||||||
|
|
||||||
**DeepSeek**
|
**DeepSeek**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "deepseek-chat",
|
"model_name": "deepseek-chat",
|
||||||
|
|
@ -868,6 +876,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
```
|
```
|
||||||
|
|
||||||
**Anthropic (with API key)**
|
**Anthropic (with API key)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
|
@ -875,9 +884,11 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
"api_key": "sk-ant-your-key"
|
"api_key": "sk-ant-your-key"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> Run `picoclaw auth login --provider anthropic` to paste your API token.
|
> Run `picoclaw auth login --provider anthropic` to paste your API token.
|
||||||
|
|
||||||
**Ollama (local)**
|
**Ollama (local)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "llama3",
|
"model_name": "llama3",
|
||||||
|
|
@ -886,6 +897,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
```
|
```
|
||||||
|
|
||||||
**Custom Proxy/API**
|
**Custom Proxy/API**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
|
|
@ -923,6 +935,7 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
|
||||||
The old `providers` configuration is **deprecated** but still supported for backward compatibility.
|
The old `providers` configuration is **deprecated** but still supported for backward compatibility.
|
||||||
|
|
||||||
**Old Config (deprecated):**
|
**Old Config (deprecated):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
@ -941,6 +954,7 @@ The old `providers` configuration is **deprecated** but still supported for back
|
||||||
```
|
```
|
||||||
|
|
||||||
**New Config (recommended):**
|
**New Config (recommended):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
|
|
@ -1105,9 +1119,9 @@ Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
|
||||||
|
|
||||||
PRs welcome! The codebase is intentionally small and readable. 🤗
|
PRs welcome! The codebase is intentionally small and readable. 🤗
|
||||||
|
|
||||||
Roadmap coming soon...
|
See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).
|
||||||
|
|
||||||
Developer group building, Entry Requirement: At least 1 Merged PR.
|
Developer group building, join after your first merged PR!
|
||||||
|
|
||||||
User Groups:
|
User Groups:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||||
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
for {
|
for {
|
||||||
fmt.Print(fmt.Sprintf("%s You: ", logo))
|
fmt.Printf("%s You: ", logo)
|
||||||
line, err := reader.ReadString('\n')
|
line, err := reader.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
|
|
|
||||||
350
pkg/tools/registry_test.go
Normal file
350
pkg/tools/registry_test.go
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- mock types ---
|
||||||
|
|
||||||
|
type mockRegistryTool struct {
|
||||||
|
name string
|
||||||
|
desc string
|
||||||
|
params map[string]interface{}
|
||||||
|
result *ToolResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRegistryTool) Name() string { return m.name }
|
||||||
|
func (m *mockRegistryTool) Description() string { return m.desc }
|
||||||
|
func (m *mockRegistryTool) Parameters() map[string]interface{} { return m.params }
|
||||||
|
func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]interface{}) *ToolResult {
|
||||||
|
return m.result
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockCtxTool struct {
|
||||||
|
mockRegistryTool
|
||||||
|
channel string
|
||||||
|
chatID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockCtxTool) SetContext(channel, chatID string) {
|
||||||
|
m.channel = channel
|
||||||
|
m.chatID = chatID
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockAsyncRegistryTool struct {
|
||||||
|
mockRegistryTool
|
||||||
|
cb AsyncCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAsyncRegistryTool) SetCallback(cb AsyncCallback) {
|
||||||
|
m.cb = cb
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func newMockTool(name, desc string) *mockRegistryTool {
|
||||||
|
return &mockRegistryTool{
|
||||||
|
name: name,
|
||||||
|
desc: desc,
|
||||||
|
params: map[string]interface{}{"type": "object"},
|
||||||
|
result: SilentResult("ok"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tests ---
|
||||||
|
|
||||||
|
func TestNewToolRegistry(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
if r.Count() != 0 {
|
||||||
|
t.Errorf("expected empty registry, got count %d", r.Count())
|
||||||
|
}
|
||||||
|
if len(r.List()) != 0 {
|
||||||
|
t.Errorf("expected empty list, got %v", r.List())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolRegistry_RegisterAndGet(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
tool := newMockTool("echo", "echoes input")
|
||||||
|
r.Register(tool)
|
||||||
|
|
||||||
|
got, ok := r.Get("echo")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected to find registered tool")
|
||||||
|
}
|
||||||
|
if got.Name() != "echo" {
|
||||||
|
t.Errorf("expected name 'echo', got %q", got.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolRegistry_Get_NotFound(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
_, ok := r.Get("nonexistent")
|
||||||
|
if ok {
|
||||||
|
t.Error("expected ok=false for unregistered tool")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolRegistry_RegisterOverwrite(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
r.Register(newMockTool("dup", "first"))
|
||||||
|
r.Register(newMockTool("dup", "second"))
|
||||||
|
|
||||||
|
if r.Count() != 1 {
|
||||||
|
t.Errorf("expected count 1 after overwrite, got %d", r.Count())
|
||||||
|
}
|
||||||
|
tool, _ := r.Get("dup")
|
||||||
|
if tool.Description() != "second" {
|
||||||
|
t.Errorf("expected overwritten description 'second', got %q", tool.Description())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolRegistry_Execute_Success(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
r.Register(&mockRegistryTool{
|
||||||
|
name: "greet",
|
||||||
|
desc: "says hello",
|
||||||
|
params: map[string]interface{}{},
|
||||||
|
result: SilentResult("hello"),
|
||||||
|
})
|
||||||
|
|
||||||
|
result := r.Execute(context.Background(), "greet", nil)
|
||||||
|
if result.IsError {
|
||||||
|
t.Errorf("expected success, got error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if result.ForLLM != "hello" {
|
||||||
|
t.Errorf("expected ForLLM 'hello', got %q", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolRegistry_Execute_NotFound(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
result := r.Execute(context.Background(), "missing", nil)
|
||||||
|
if !result.IsError {
|
||||||
|
t.Error("expected error for missing tool")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "not found") {
|
||||||
|
t.Errorf("expected 'not found' in error, got %q", result.ForLLM)
|
||||||
|
}
|
||||||
|
if result.Err == nil {
|
||||||
|
t.Error("expected Err to be set via WithError")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
ct := &mockCtxTool{
|
||||||
|
mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
|
||||||
|
}
|
||||||
|
r.Register(ct)
|
||||||
|
|
||||||
|
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil)
|
||||||
|
|
||||||
|
if ct.channel != "telegram" {
|
||||||
|
t.Errorf("expected channel 'telegram', got %q", ct.channel)
|
||||||
|
}
|
||||||
|
if ct.chatID != "chat-42" {
|
||||||
|
t.Errorf("expected chatID 'chat-42', got %q", ct.chatID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
ct := &mockCtxTool{
|
||||||
|
mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
|
||||||
|
}
|
||||||
|
r.Register(ct)
|
||||||
|
|
||||||
|
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil)
|
||||||
|
|
||||||
|
if ct.channel != "" || ct.chatID != "" {
|
||||||
|
t.Error("SetContext should not be called with empty channel/chatID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
at := &mockAsyncRegistryTool{
|
||||||
|
mockRegistryTool: *newMockTool("async_tool", "async work"),
|
||||||
|
}
|
||||||
|
at.result = AsyncResult("started")
|
||||||
|
r.Register(at)
|
||||||
|
|
||||||
|
called := false
|
||||||
|
cb := func(_ context.Context, _ *ToolResult) { called = true }
|
||||||
|
|
||||||
|
result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb)
|
||||||
|
if at.cb == nil {
|
||||||
|
t.Error("expected SetCallback to have been called")
|
||||||
|
}
|
||||||
|
if !result.Async {
|
||||||
|
t.Error("expected async result")
|
||||||
|
}
|
||||||
|
|
||||||
|
at.cb(context.Background(), SilentResult("done"))
|
||||||
|
if !called {
|
||||||
|
t.Error("expected callback to be invoked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolRegistry_GetDefinitions(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
r.Register(newMockTool("alpha", "tool A"))
|
||||||
|
|
||||||
|
defs := r.GetDefinitions()
|
||||||
|
if len(defs) != 1 {
|
||||||
|
t.Fatalf("expected 1 definition, got %d", len(defs))
|
||||||
|
}
|
||||||
|
if defs[0]["type"] != "function" {
|
||||||
|
t.Errorf("expected type 'function', got %v", defs[0]["type"])
|
||||||
|
}
|
||||||
|
fn, ok := defs[0]["function"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected 'function' key to be a map")
|
||||||
|
}
|
||||||
|
if fn["name"] != "alpha" {
|
||||||
|
t.Errorf("expected name 'alpha', got %v", fn["name"])
|
||||||
|
}
|
||||||
|
if fn["description"] != "tool A" {
|
||||||
|
t.Errorf("expected description 'tool A', got %v", fn["description"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolRegistry_ToProviderDefs(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
params := map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}
|
||||||
|
r.Register(&mockRegistryTool{
|
||||||
|
name: "beta",
|
||||||
|
desc: "tool B",
|
||||||
|
params: params,
|
||||||
|
result: SilentResult("ok"),
|
||||||
|
})
|
||||||
|
|
||||||
|
defs := r.ToProviderDefs()
|
||||||
|
if len(defs) != 1 {
|
||||||
|
t.Fatalf("expected 1 provider def, got %d", len(defs))
|
||||||
|
}
|
||||||
|
|
||||||
|
want := providers.ToolDefinition{
|
||||||
|
Type: "function",
|
||||||
|
Function: providers.ToolFunctionDefinition{
|
||||||
|
Name: "beta",
|
||||||
|
Description: "tool B",
|
||||||
|
Parameters: params,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := defs[0]
|
||||||
|
if got.Type != want.Type {
|
||||||
|
t.Errorf("Type: want %q, got %q", want.Type, got.Type)
|
||||||
|
}
|
||||||
|
if got.Function.Name != want.Function.Name {
|
||||||
|
t.Errorf("Name: want %q, got %q", want.Function.Name, got.Function.Name)
|
||||||
|
}
|
||||||
|
if got.Function.Description != want.Function.Description {
|
||||||
|
t.Errorf("Description: want %q, got %q", want.Function.Description, got.Function.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolRegistry_List(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
r.Register(newMockTool("x", ""))
|
||||||
|
r.Register(newMockTool("y", ""))
|
||||||
|
|
||||||
|
names := r.List()
|
||||||
|
if len(names) != 2 {
|
||||||
|
t.Fatalf("expected 2 names, got %d", len(names))
|
||||||
|
}
|
||||||
|
|
||||||
|
nameSet := map[string]bool{}
|
||||||
|
for _, n := range names {
|
||||||
|
nameSet[n] = true
|
||||||
|
}
|
||||||
|
if !nameSet["x"] || !nameSet["y"] {
|
||||||
|
t.Errorf("expected names {x, y}, got %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolRegistry_Count(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
if r.Count() != 0 {
|
||||||
|
t.Errorf("expected 0, got %d", r.Count())
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Register(newMockTool("a", ""))
|
||||||
|
r.Register(newMockTool("b", ""))
|
||||||
|
if r.Count() != 2 {
|
||||||
|
t.Errorf("expected 2, got %d", r.Count())
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Register(newMockTool("a", "replaced"))
|
||||||
|
if r.Count() != 2 {
|
||||||
|
t.Errorf("expected 2 after overwrite, got %d", r.Count())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolRegistry_GetSummaries(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
r.Register(newMockTool("read_file", "Reads a file"))
|
||||||
|
|
||||||
|
summaries := r.GetSummaries()
|
||||||
|
if len(summaries) != 1 {
|
||||||
|
t.Fatalf("expected 1 summary, got %d", len(summaries))
|
||||||
|
}
|
||||||
|
if !strings.Contains(summaries[0], "`read_file`") {
|
||||||
|
t.Errorf("expected backtick-quoted name in summary, got %q", summaries[0])
|
||||||
|
}
|
||||||
|
if !strings.Contains(summaries[0], "Reads a file") {
|
||||||
|
t.Errorf("expected description in summary, got %q", summaries[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolToSchema(t *testing.T) {
|
||||||
|
tool := newMockTool("demo", "demo tool")
|
||||||
|
schema := ToolToSchema(tool)
|
||||||
|
|
||||||
|
if schema["type"] != "function" {
|
||||||
|
t.Errorf("expected type 'function', got %v", schema["type"])
|
||||||
|
}
|
||||||
|
fn, ok := schema["function"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected 'function' to be a map")
|
||||||
|
}
|
||||||
|
if fn["name"] != "demo" {
|
||||||
|
t.Errorf("expected name 'demo', got %v", fn["name"])
|
||||||
|
}
|
||||||
|
if fn["description"] != "demo tool" {
|
||||||
|
t.Errorf("expected description 'demo tool', got %v", fn["description"])
|
||||||
|
}
|
||||||
|
if fn["parameters"] == nil {
|
||||||
|
t.Error("expected parameters to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolRegistry_ConcurrentAccess(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(n int) {
|
||||||
|
defer wg.Done()
|
||||||
|
name := string(rune('A' + n%26))
|
||||||
|
r.Register(newMockTool(name, "concurrent"))
|
||||||
|
r.Get(name)
|
||||||
|
r.Count()
|
||||||
|
r.List()
|
||||||
|
r.GetDefinitions()
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if r.Count() == 0 {
|
||||||
|
t.Error("expected tools to be registered after concurrent access")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,9 @@ package utils
|
||||||
// Handles multi-byte Unicode characters properly.
|
// Handles multi-byte Unicode characters properly.
|
||||||
// If the string is truncated, "..." is appended to indicate truncation.
|
// If the string is truncated, "..." is appended to indicate truncation.
|
||||||
func Truncate(s string, maxLen int) string {
|
func Truncate(s string, maxLen int) string {
|
||||||
|
if maxLen <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
runes := []rune(s)
|
runes := []rune(s)
|
||||||
if len(runes) <= maxLen {
|
if len(runes) <= maxLen {
|
||||||
return s
|
return s
|
||||||
|
|
|
||||||
106
pkg/utils/string_test.go
Normal file
106
pkg/utils/string_test.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTruncate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
maxLen int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "short string unchanged",
|
||||||
|
input: "hi",
|
||||||
|
maxLen: 10,
|
||||||
|
want: "hi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact length unchanged",
|
||||||
|
input: "hello",
|
||||||
|
maxLen: 5,
|
||||||
|
want: "hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "long string truncated with ellipsis",
|
||||||
|
input: "hello world",
|
||||||
|
maxLen: 8,
|
||||||
|
want: "hello...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maxLen equals 4 leaves 1 char plus ellipsis",
|
||||||
|
input: "abcdef",
|
||||||
|
maxLen: 4,
|
||||||
|
want: "a...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maxLen 3 returns first 3 chars without ellipsis",
|
||||||
|
input: "abcdef",
|
||||||
|
maxLen: 3,
|
||||||
|
want: "abc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maxLen 2 returns first 2 chars",
|
||||||
|
input: "abcdef",
|
||||||
|
maxLen: 2,
|
||||||
|
want: "ab",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maxLen 1 returns first char",
|
||||||
|
input: "abcdef",
|
||||||
|
maxLen: 1,
|
||||||
|
want: "a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maxLen 0 returns empty",
|
||||||
|
input: "hello",
|
||||||
|
maxLen: 0,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative maxLen returns empty",
|
||||||
|
input: "hello",
|
||||||
|
maxLen: -1,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string unchanged",
|
||||||
|
input: "",
|
||||||
|
maxLen: 5,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string with zero maxLen",
|
||||||
|
input: "",
|
||||||
|
maxLen: 0,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unicode truncated correctly",
|
||||||
|
input: "\U0001f600\U0001f601\U0001f602\U0001f603\U0001f604",
|
||||||
|
maxLen: 4,
|
||||||
|
want: "\U0001f600...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unicode short enough",
|
||||||
|
input: "\u00e9\u00e8",
|
||||||
|
maxLen: 5,
|
||||||
|
want: "\u00e9\u00e8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed ascii and unicode",
|
||||||
|
input: "Go\U0001f680\U0001f525\U0001f4a5\U0001f30d",
|
||||||
|
maxLen: 5,
|
||||||
|
want: "Go...",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := Truncate(tt.input, tt.maxLen)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("Truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue