This commit is contained in:
Keith Ammon 2026-02-17 21:49:13 -05:00 committed by GitHub
commit 2a5e5aea13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 343 additions and 16 deletions

2
.gitignore vendored
View file

@ -44,3 +44,5 @@ tasks/
# Added by goreleaser init:
dist/
.env
config/config.json

View file

@ -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)

View 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

View file

@ -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",

View file

@ -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
})

View file

@ -14,6 +14,7 @@ type OutboundMessage struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
Media []string `json:"media,omitempty"`
}
type MessageHandler func(InboundMessage) error

View file

@ -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
}

View file

@ -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{

View file

@ -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)
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
View 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")
}

View 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")
}
})
}

View file

@ -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,

View file

@ -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
})

View file

@ -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:"-"`

View file

@ -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.