Merge a7df4e8cee into 8807d8254f
This commit is contained in:
commit
2a5e5aea13
15 changed files with 343 additions and 16 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -44,3 +44,5 @@ tasks/
|
|||
|
||||
# Added by goreleaser init:
|
||||
dist/
|
||||
.env
|
||||
config/config.json
|
||||
|
|
|
|||
|
|
@ -405,6 +405,9 @@ func agentCmd() {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Apply workspace upgrades before starting the agent
|
||||
migrate.UpgradeWorkspace(cfg.WorkspacePath())
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
|
|
@ -540,6 +543,9 @@ func gatewayCmd() {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Apply workspace upgrades before starting the agent
|
||||
migrate.UpgradeWorkspace(cfg.WorkspacePath())
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
|
|
|
|||
10
docker-compose.override.yml
Normal file
10
docker-compose.override.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# docker-compose.override.yml
|
||||
services:
|
||||
picoclaw-agent:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
cpus: '0.5'
|
||||
picoclaw-gateway:
|
||||
restart: unless-stopped
|
||||
|
|
@ -140,7 +140,7 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
|
|||
|
||||
func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||
bootstrapFiles := []string{
|
||||
"AGENTS.md",
|
||||
"AGENT.md",
|
||||
"SOUL.md",
|
||||
"USER.md",
|
||||
"IDENTITY.md",
|
||||
|
|
|
|||
|
|
@ -94,11 +94,12 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
|
|||
// Message tool - available to both agent and subagent
|
||||
// Subagent uses it to communicate directly with user
|
||||
messageTool := tools.NewMessageTool()
|
||||
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
||||
messageTool.SetSendCallback(func(channel, chatID, content string, media []string) error {
|
||||
msgBus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
Content: content,
|
||||
Media: media,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ type InboundMessage struct {
|
|||
}
|
||||
|
||||
type OutboundMessage struct {
|
||||
Channel string `json:"channel"`
|
||||
ChatID string `json:"chat_id"`
|
||||
Content string `json:"content"`
|
||||
Channel string `json:"channel"`
|
||||
ChatID string `json:"chat_id"`
|
||||
Content string `json:"content"`
|
||||
Media []string `json:"media,omitempty"`
|
||||
}
|
||||
|
||||
type MessageHandler func(InboundMessage) error
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -114,6 +115,32 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
|||
}
|
||||
}
|
||||
|
||||
// Send media files
|
||||
for _, mediaPath := range msg.Media {
|
||||
f, err := os.Open(mediaPath)
|
||||
if err != nil {
|
||||
logger.ErrorCF("discord", "Failed to open media file", map[string]any{
|
||||
"path": mediaPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
msgSend := &discordgo.MessageSend{
|
||||
Files: []*discordgo.File{{
|
||||
Name: filepath.Base(mediaPath),
|
||||
Reader: f,
|
||||
}},
|
||||
}
|
||||
_, sendErr := c.session.ChannelMessageSendComplex(channelID, msgSend)
|
||||
f.Close()
|
||||
if sendErr != nil {
|
||||
logger.ErrorCF("discord", "Failed to send media file", map[string]any{
|
||||
"path": mediaPath,
|
||||
"error": sendErr.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -130,6 +131,40 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
|||
return fmt.Errorf("failed to send slack message: %w", err)
|
||||
}
|
||||
|
||||
// Upload media files
|
||||
for _, mediaPath := range msg.Media {
|
||||
fi, err := os.Stat(mediaPath)
|
||||
if err != nil {
|
||||
logger.ErrorCF("slack", "Failed to stat media file", map[string]interface{}{
|
||||
"path": mediaPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
f, err := os.Open(mediaPath)
|
||||
if err != nil {
|
||||
logger.ErrorCF("slack", "Failed to open media file", map[string]interface{}{
|
||||
"path": mediaPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
_, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{
|
||||
Reader: f,
|
||||
FileSize: int(fi.Size()),
|
||||
Filename: filepath.Base(mediaPath),
|
||||
Channel: channelID,
|
||||
ThreadTimestamp: threadTS,
|
||||
})
|
||||
f.Close()
|
||||
if err != nil {
|
||||
logger.ErrorCF("slack", "Failed to upload media file", map[string]interface{}{
|
||||
"path": mediaPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok {
|
||||
msgRef := ref.(slackMessageRef)
|
||||
c.api.AddReaction("white_check_mark", slack.ItemRef{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
|
@ -180,12 +181,65 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
|||
})
|
||||
tgMsg.ParseMode = ""
|
||||
_, err = c.bot.SendMessage(ctx, tgMsg)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Send media files
|
||||
for _, mediaPath := range msg.Media {
|
||||
if err := c.sendMediaFile(ctx, chatID, mediaPath); err != nil {
|
||||
logger.ErrorCF("telegram", "Failed to send media file", map[string]interface{}{
|
||||
"path": mediaPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) sendMediaFile(ctx context.Context, chatID int64, filePath string) error {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open media file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
|
||||
photo := &telego.SendPhotoParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
Photo: telego.InputFile{File: f},
|
||||
}
|
||||
_, err = c.bot.SendPhoto(ctx, photo)
|
||||
case ".mp4", ".avi", ".mov", ".mkv":
|
||||
video := &telego.SendVideoParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
Video: telego.InputFile{File: f},
|
||||
}
|
||||
_, err = c.bot.SendVideo(ctx, video)
|
||||
case ".mp3", ".ogg", ".wav", ".flac", ".aac", ".m4a":
|
||||
audio := &telego.SendAudioParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
Audio: telego.InputFile{File: f},
|
||||
}
|
||||
_, err = c.bot.SendAudio(ctx, audio)
|
||||
default:
|
||||
doc := &telego.SendDocumentParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
Document: telego.InputFile{File: f},
|
||||
Caption: fileName,
|
||||
}
|
||||
_, err = c.bot.SendDocument(ctx, doc)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
|
||||
if message == nil {
|
||||
return fmt.Errorf("message is nil")
|
||||
|
|
|
|||
96
pkg/migrate/upgrade.go
Normal file
96
pkg/migrate/upgrade.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package migrate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
// UpgradeWorkspace applies incremental upgrades to an existing workspace.
|
||||
// Each upgrade is idempotent — it checks whether it has already been applied
|
||||
// before making changes.
|
||||
func UpgradeWorkspace(workspace string) {
|
||||
upgradeAgentMediaSection(workspace)
|
||||
upgradeAgentGrounding(workspace)
|
||||
}
|
||||
|
||||
// upgradeAgentMediaSection ensures AGENT.md contains the media sending instructions.
|
||||
// Added in v0.x to teach the LLM it can send files via the message tool.
|
||||
func upgradeAgentMediaSection(workspace string) {
|
||||
agentPath := filepath.Join(workspace, "AGENT.md")
|
||||
|
||||
data, err := os.ReadFile(agentPath)
|
||||
if err != nil {
|
||||
return // File doesn't exist or unreadable — skip
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
// Already applied
|
||||
if strings.Contains(content, "## Media & File Sending") {
|
||||
return
|
||||
}
|
||||
|
||||
section := `
|
||||
|
||||
## Media & File Sending
|
||||
|
||||
You CAN send files directly to users. When you need to share a file (image, document, audio, video), use the ` + "`message`" + ` tool with the ` + "`media`" + ` parameter containing the local file path(s). The file will be delivered natively through the user's channel (Telegram, Discord, Slack, etc.). Do NOT tell users you cannot send files — just send them.`
|
||||
|
||||
content += section
|
||||
|
||||
if err := os.WriteFile(agentPath, []byte(content), 0644); err != nil {
|
||||
logger.ErrorCF("migrate", "Failed to upgrade AGENT.md", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.InfoC("migrate", "Upgraded AGENT.md with media sending instructions")
|
||||
}
|
||||
|
||||
// upgradeAgentGrounding ensures AGENT.md tells the LLM it is already connected
|
||||
// and should not suggest manual workarounds like tokens or curl commands.
|
||||
func upgradeAgentGrounding(workspace string) {
|
||||
agentPath := filepath.Join(workspace, "AGENT.md")
|
||||
|
||||
data, err := os.ReadFile(agentPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
// Already applied
|
||||
if strings.Contains(content, "NEVER suggest manual workarounds") {
|
||||
return
|
||||
}
|
||||
|
||||
// Replace the old generic intro with the grounded version
|
||||
oldIntro := "You are a helpful AI assistant. Be concise, accurate, and friendly."
|
||||
newIntro := "You are a helpful AI assistant running inside picoclaw. You are ALREADY connected to the user's chat channel (Telegram, Discord, Slack, etc.). When you use the `message` tool, your message is delivered directly to the user — you do NOT need API keys, bot tokens, or any external setup. Everything is already wired up for you. Just use your tools."
|
||||
|
||||
if strings.Contains(content, oldIntro) {
|
||||
content = strings.Replace(content, oldIntro, newIntro, 1)
|
||||
}
|
||||
|
||||
// Add guardrail lines to guidelines if not present
|
||||
if !strings.Contains(content, "NEVER tell users you lack access") {
|
||||
oldGuideline := "- Learn from user feedback"
|
||||
newGuideline := `- Learn from user feedback
|
||||
- NEVER tell users you lack access to send messages, files, or perform actions — use your tools instead
|
||||
- NEVER suggest manual workarounds (curl commands, scripts, tokens) for things your tools already do`
|
||||
content = strings.Replace(content, oldGuideline, newGuideline, 1)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(agentPath, []byte(content), 0644); err != nil {
|
||||
logger.ErrorCF("migrate", "Failed to upgrade AGENT.md grounding", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.InfoC("migrate", "Upgraded AGENT.md with grounding instructions")
|
||||
}
|
||||
70
pkg/migrate/upgrade_test.go
Normal file
70
pkg/migrate/upgrade_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package migrate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpgradeAgentMediaSection(t *testing.T) {
|
||||
t.Run("appends media section to existing AGENT.md", func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
agentPath := filepath.Join(workspace, "AGENT.md")
|
||||
os.WriteFile(agentPath, []byte("# Agent Instructions\n\nBe helpful."), 0644)
|
||||
|
||||
UpgradeWorkspace(workspace)
|
||||
|
||||
data, err := os.ReadFile(agentPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reading AGENT.md: %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
if !strings.Contains(content, "## Media & File Sending") {
|
||||
t.Error("expected media section to be appended")
|
||||
}
|
||||
if !strings.Contains(content, "You CAN send files directly") {
|
||||
t.Error("expected media instructions in content")
|
||||
}
|
||||
// Original content preserved
|
||||
if !strings.Contains(content, "# Agent Instructions") {
|
||||
t.Error("original content should be preserved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("idempotent — does not duplicate section", func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
agentPath := filepath.Join(workspace, "AGENT.md")
|
||||
os.WriteFile(agentPath, []byte("# Agent Instructions\n\nBe helpful."), 0644)
|
||||
|
||||
UpgradeWorkspace(workspace)
|
||||
UpgradeWorkspace(workspace)
|
||||
|
||||
data, _ := os.ReadFile(agentPath)
|
||||
count := strings.Count(string(data), "## Media & File Sending")
|
||||
if count != 1 {
|
||||
t.Errorf("expected exactly 1 media section, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips when AGENT.md does not exist", func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
// No AGENT.md created — should not panic or error
|
||||
UpgradeWorkspace(workspace)
|
||||
})
|
||||
|
||||
t.Run("skips when section already present", func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
agentPath := filepath.Join(workspace, "AGENT.md")
|
||||
original := "# Agent\n\n## Media & File Sending\n\nAlready here."
|
||||
os.WriteFile(agentPath, []byte(original), 0644)
|
||||
|
||||
UpgradeWorkspace(workspace)
|
||||
|
||||
data, _ := os.ReadFile(agentPath)
|
||||
if string(data) != original {
|
||||
t.Error("file should not be modified when section already exists")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"fmt"
|
||||
)
|
||||
|
||||
type SendCallback func(channel, chatID, content string) error
|
||||
type SendCallback func(channel, chatID, content string, media []string) error
|
||||
|
||||
type MessageTool struct {
|
||||
sendCallback SendCallback
|
||||
|
|
@ -23,7 +23,7 @@ func (t *MessageTool) Name() string {
|
|||
}
|
||||
|
||||
func (t *MessageTool) Description() string {
|
||||
return "Send a message to user on a chat channel. Use this when you want to communicate something."
|
||||
return "Send a message to user on a chat channel. Use this when you want to communicate something. You can also attach local files (images, documents, audio, video) using the media parameter — the files will be delivered natively through the channel."
|
||||
}
|
||||
|
||||
func (t *MessageTool) Parameters() map[string]interface{} {
|
||||
|
|
@ -42,6 +42,13 @@ func (t *MessageTool) Parameters() map[string]interface{} {
|
|||
"type": "string",
|
||||
"description": "Optional: target chat/user ID",
|
||||
},
|
||||
"media": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "Optional: list of local file paths to send as media attachments",
|
||||
"items": map[string]interface{}{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"content"},
|
||||
}
|
||||
|
|
@ -71,6 +78,15 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{})
|
|||
channel, _ := args["channel"].(string)
|
||||
chatID, _ := args["chat_id"].(string)
|
||||
|
||||
var media []string
|
||||
if rawMedia, ok := args["media"].([]interface{}); ok {
|
||||
for _, item := range rawMedia {
|
||||
if path, ok := item.(string); ok {
|
||||
media = append(media, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if channel == "" {
|
||||
channel = t.defaultChannel
|
||||
}
|
||||
|
|
@ -86,7 +102,7 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{})
|
|||
return &ToolResult{ForLLM: "Message sending not configured", IsError: true}
|
||||
}
|
||||
|
||||
if err := t.sendCallback(channel, chatID, content); err != nil {
|
||||
if err := t.sendCallback(channel, chatID, content, media); err != nil {
|
||||
return &ToolResult{
|
||||
ForLLM: fmt.Sprintf("sending message: %v", err),
|
||||
IsError: true,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ func TestMessageTool_Execute_Success(t *testing.T) {
|
|||
tool.SetContext("test-channel", "test-chat-id")
|
||||
|
||||
var sentChannel, sentChatID, sentContent string
|
||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
||||
tool.SetSendCallback(func(channel, chatID, content string, media []string) error {
|
||||
sentChannel = channel
|
||||
sentChatID = chatID
|
||||
sentContent = content
|
||||
|
|
@ -63,7 +63,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
|
|||
tool.SetContext("default-channel", "default-chat-id")
|
||||
|
||||
var sentChannel, sentChatID string
|
||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
||||
tool.SetSendCallback(func(channel, chatID, content string, media []string) error {
|
||||
sentChannel = channel
|
||||
sentChatID = chatID
|
||||
return nil
|
||||
|
|
@ -99,7 +99,7 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
|
|||
tool.SetContext("test-channel", "test-chat-id")
|
||||
|
||||
sendErr := errors.New("network error")
|
||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
||||
tool.SetSendCallback(func(channel, chatID, content string, media []string) error {
|
||||
return sendErr
|
||||
})
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
|
|||
tool := NewMessageTool()
|
||||
// No SetContext called, so defaultChannel and defaultChatID are empty
|
||||
|
||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
||||
tool.SetSendCallback(func(channel, chatID, content string, media []string) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ type ToolResult struct {
|
|||
// When true, the tool will complete later and notify via callback.
|
||||
Async bool `json:"async"`
|
||||
|
||||
// Media contains local file paths to send alongside the message.
|
||||
Media []string `json:"media,omitempty"`
|
||||
|
||||
// Err is the underlying error (not JSON serialized).
|
||||
// Used for internal error handling and logging.
|
||||
Err error `json:"-"`
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
# Agent Instructions
|
||||
|
||||
You are a helpful AI assistant. Be concise, accurate, and friendly.
|
||||
You are a helpful AI assistant running inside picoclaw. You are ALREADY connected to the user's chat channel (Telegram, Discord, Slack, etc.). When you use the `message` tool, your message is delivered directly to the user — you do NOT need API keys, bot tokens, or any external setup. Everything is already wired up for you. Just use your tools.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Always explain what you're doing before taking actions
|
||||
- Ask for clarification when request is ambiguous
|
||||
- Use tools to help accomplish tasks
|
||||
- Use tools to help accomplish tasks — they are your primary way of interacting with the world
|
||||
- Remember important information in your memory files
|
||||
- Be proactive and helpful
|
||||
- Learn from user feedback
|
||||
- NEVER tell users you lack access to send messages, files, or perform actions — use your tools instead
|
||||
- NEVER suggest manual workarounds (curl commands, scripts, tokens) for things your tools already do
|
||||
|
||||
## Media & File Sending
|
||||
|
||||
You CAN send files directly to users. When you need to share a file (image, document, audio, video), use the `message` tool with the `media` parameter containing the local file path(s). The file will be delivered natively through the user's channel (Telegram, Discord, Slack, etc.). Do NOT tell users you cannot send files — just send them.
|
||||
Loading…
Add table
Reference in a new issue