feat: add setup-token auth and send_file tool
- Add Anthropic setup-token (sk-ant-oat01-*) authentication flow - Add send_file tool for delivering files/images to chat channels - Support beta headers for Claude Code API compatibility - Add streaming fallback when beta headers are present - Fix tool call argument parsing in Anthropic provider Made-with: Cursor
This commit is contained in:
parent
26d1b8e374
commit
6b66da52f0
10 changed files with 419 additions and 11 deletions
|
|
@ -17,11 +17,14 @@ import (
|
||||||
|
|
||||||
const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
||||||
|
|
||||||
func authLoginCmd(provider string, useDeviceCode bool) error {
|
func authLoginCmd(provider string, useDeviceCode, useSetupToken bool) error {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "openai":
|
case "openai":
|
||||||
return authLoginOpenAI(useDeviceCode)
|
return authLoginOpenAI(useDeviceCode)
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
|
if useSetupToken {
|
||||||
|
return authLoginSetupToken()
|
||||||
|
}
|
||||||
return authLoginPasteToken(provider)
|
return authLoginPasteToken(provider)
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
return authLoginGoogleAntigravity()
|
return authLoginGoogleAntigravity()
|
||||||
|
|
@ -259,6 +262,51 @@ func authLoginPasteToken(provider string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authLoginSetupToken() error {
|
||||||
|
cred, err := auth.LoginSetupToken("anthropic", os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("login failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = auth.SetCredential("anthropic", cred); err != nil {
|
||||||
|
return fmt.Errorf("failed to save credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
appCfg, err := internal.LoadConfig()
|
||||||
|
if err == nil {
|
||||||
|
appCfg.Providers.Anthropic.AuthMethod = "setup-token"
|
||||||
|
|
||||||
|
// Update or add anthropic in ModelList
|
||||||
|
found := false
|
||||||
|
for i := range appCfg.ModelList {
|
||||||
|
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||||
|
appCfg.ModelList[i].AuthMethod = "setup-token"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "claude-sonnet-4.6",
|
||||||
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
|
AuthMethod: "setup-token",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update default model
|
||||||
|
appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
||||||
|
|
||||||
|
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||||
|
return fmt.Errorf("could not update config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Setup token saved for anthropic!")
|
||||||
|
fmt.Println("Default model set to: claude-sonnet-4.6")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func authLogoutCmd(provider string) error {
|
func authLogoutCmd(provider string) error {
|
||||||
if provider != "" {
|
if provider != "" {
|
||||||
if err := auth.DeleteCredential(provider); err != nil {
|
if err := auth.DeleteCredential(provider); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ func newLoginCommand() *cobra.Command {
|
||||||
var (
|
var (
|
||||||
provider string
|
provider string
|
||||||
useDeviceCode bool
|
useDeviceCode bool
|
||||||
|
useSetupToken bool
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|
@ -13,12 +14,13 @@ func newLoginCommand() *cobra.Command {
|
||||||
Short: "Login via OAuth or paste token",
|
Short: "Login via OAuth or paste token",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
return authLoginCmd(provider, useDeviceCode)
|
return authLoginCmd(provider, useDeviceCode, useSetupToken)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
|
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
|
||||||
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
||||||
|
cmd.Flags().BoolVar(&useSetupToken, "setup-token", false, "Use Anthropic setup token (from claude setup-token)")
|
||||||
_ = cmd.MarkFlagRequired("provider")
|
_ = cmd.MarkFlagRequired("provider")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"mime"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -61,9 +62,6 @@ const defaultResponse = "I've completed processing but have no response to give.
|
||||||
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
|
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
|
||||||
registry := NewAgentRegistry(cfg, provider)
|
registry := NewAgentRegistry(cfg, provider)
|
||||||
|
|
||||||
// Register shared tools to all agents
|
|
||||||
registerSharedTools(cfg, msgBus, registry, provider)
|
|
||||||
|
|
||||||
// Set up shared fallback chain
|
// Set up shared fallback chain
|
||||||
cooldown := providers.NewCooldownTracker()
|
cooldown := providers.NewCooldownTracker()
|
||||||
fallbackChain := providers.NewFallbackChain(cooldown)
|
fallbackChain := providers.NewFallbackChain(cooldown)
|
||||||
|
|
@ -75,7 +73,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
||||||
stateManager = state.NewManager(defaultAgent.Workspace)
|
stateManager = state.NewManager(defaultAgent.Workspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AgentLoop{
|
al := &AgentLoop{
|
||||||
bus: msgBus,
|
bus: msgBus,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
registry: registry,
|
registry: registry,
|
||||||
|
|
@ -83,14 +81,20 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
||||||
summarizing: sync.Map{},
|
summarizing: sync.Map{},
|
||||||
fallback: fallbackChain,
|
fallback: fallbackChain,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register shared tools to all agents (after al is created so closures can capture it)
|
||||||
|
registerSharedTools(cfg, msgBus, registry, provider, al)
|
||||||
|
|
||||||
|
return al
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
|
// registerSharedTools registers tools that are shared across all agents (web, message, spawn, send_file).
|
||||||
func registerSharedTools(
|
func registerSharedTools(
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
msgBus *bus.MessageBus,
|
msgBus *bus.MessageBus,
|
||||||
registry *AgentRegistry,
|
registry *AgentRegistry,
|
||||||
provider providers.LLMProvider,
|
provider providers.LLMProvider,
|
||||||
|
al *AgentLoop,
|
||||||
) {
|
) {
|
||||||
for _, agentID := range registry.ListAgentIDs() {
|
for _, agentID := range registry.ListAgentIDs() {
|
||||||
agent, ok := registry.GetAgent(agentID)
|
agent, ok := registry.GetAgent(agentID)
|
||||||
|
|
@ -143,6 +147,39 @@ func registerSharedTools(
|
||||||
})
|
})
|
||||||
agent.Tools.Register(messageTool)
|
agent.Tools.Register(messageTool)
|
||||||
|
|
||||||
|
// Send file tool
|
||||||
|
sendFileTool := tools.NewSendFileTool()
|
||||||
|
sendFileTool.SetMediaCallback(func(channel, chatID, localPath, caption string) error {
|
||||||
|
if al.mediaStore == nil {
|
||||||
|
return fmt.Errorf("media store not available (file sending requires gateway mode)")
|
||||||
|
}
|
||||||
|
filename := filepath.Base(localPath)
|
||||||
|
contentType := mime.TypeByExtension(filepath.Ext(localPath))
|
||||||
|
ref, err := al.mediaStore.Store(localPath, media.MediaMeta{
|
||||||
|
Filename: filename,
|
||||||
|
ContentType: contentType,
|
||||||
|
Source: "tool:send_file",
|
||||||
|
}, "send_file")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storing file: %w", err)
|
||||||
|
}
|
||||||
|
part := bus.MediaPart{
|
||||||
|
Ref: ref,
|
||||||
|
Caption: caption,
|
||||||
|
Filename: filename,
|
||||||
|
ContentType: contentType,
|
||||||
|
Type: inferMediaType(filename, contentType),
|
||||||
|
}
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
return msgBus.PublishOutboundMedia(pubCtx, bus.OutboundMediaMessage{
|
||||||
|
Channel: channel,
|
||||||
|
ChatID: chatID,
|
||||||
|
Parts: []bus.MediaPart{part},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
agent.Tools.Register(sendFileTool)
|
||||||
|
|
||||||
// Skill discovery and installation tools
|
// Skill discovery and installation tools
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,38 @@ func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LoginSetupToken(provider string, r io.Reader) (*AuthCredential, error) {
|
||||||
|
fmt.Println("Paste your setup token from Claude CLI (claude setup-token):")
|
||||||
|
fmt.Print("> ")
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
if !scanner.Scan() {
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("reading token: %w", err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no input received")
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimSpace(scanner.Text())
|
||||||
|
if token == "" {
|
||||||
|
return nil, fmt.Errorf("token cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(token, "sk-ant-oat01-") {
|
||||||
|
return nil, fmt.Errorf("invalid setup token: must start with sk-ant-oat01-")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(token) < 40 {
|
||||||
|
return nil, fmt.Errorf("invalid setup token: too short (minimum 40 characters)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AuthCredential{
|
||||||
|
AccessToken: token,
|
||||||
|
Provider: provider,
|
||||||
|
AuthMethod: "setup-token",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func providerDisplayName(provider string) string {
|
func providerDisplayName(provider string) string {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ type Provider struct {
|
||||||
client *anthropic.Client
|
client *anthropic.Client
|
||||||
tokenSource func() (string, error)
|
tokenSource func() (string, error)
|
||||||
baseURL string
|
baseURL string
|
||||||
|
betaHeaders string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProvider(token string) *Provider {
|
func NewProvider(token string) *Provider {
|
||||||
|
|
@ -64,6 +65,12 @@ func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (stri
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewProviderWithTokenSourceAndBeta(token string, tokenSource func() (string, error), apiBase, betaHeaders string) *Provider {
|
||||||
|
p := NewProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase)
|
||||||
|
p.betaHeaders = betaHeaders
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Provider) Chat(
|
func (p *Provider) Chat(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
messages []Message,
|
messages []Message,
|
||||||
|
|
@ -79,12 +86,19 @@ func (p *Provider) Chat(
|
||||||
}
|
}
|
||||||
opts = append(opts, option.WithAuthToken(tok))
|
opts = append(opts, option.WithAuthToken(tok))
|
||||||
}
|
}
|
||||||
|
if p.betaHeaders != "" {
|
||||||
|
opts = append(opts, option.WithHeader("anthropic-beta", p.betaHeaders))
|
||||||
|
}
|
||||||
|
|
||||||
params, err := buildParams(messages, tools, model, options)
|
params, err := buildParams(messages, tools, model, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if p.betaHeaders != "" {
|
||||||
|
return p.chatStreaming(ctx, params, opts)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := p.client.Messages.New(ctx, params, opts...)
|
resp, err := p.client.Messages.New(ctx, params, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("claude API call: %w", err)
|
return nil, fmt.Errorf("claude API call: %w", err)
|
||||||
|
|
@ -93,6 +107,28 @@ func (p *Provider) Chat(
|
||||||
return parseResponse(resp), nil
|
return parseResponse(resp), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Provider) chatStreaming(
|
||||||
|
ctx context.Context,
|
||||||
|
params anthropic.MessageNewParams,
|
||||||
|
opts []option.RequestOption,
|
||||||
|
) (*LLMResponse, error) {
|
||||||
|
stream := p.client.Messages.NewStreaming(ctx, params, opts...)
|
||||||
|
|
||||||
|
var msg anthropic.Message
|
||||||
|
for stream.Next() {
|
||||||
|
event := stream.Current()
|
||||||
|
if err := msg.Accumulate(event); err != nil {
|
||||||
|
stream.Close()
|
||||||
|
return nil, fmt.Errorf("claude API stream accumulate: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := stream.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("claude API call: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseResponse(&msg), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Provider) GetDefaultModel() string {
|
func (p *Provider) GetDefaultModel() string {
|
||||||
return "claude-sonnet-4.6"
|
return "claude-sonnet-4.6"
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +180,21 @@ func buildParams(
|
||||||
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
|
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
|
||||||
}
|
}
|
||||||
for _, tc := range msg.ToolCalls {
|
for _, tc := range msg.ToolCalls {
|
||||||
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
|
args := tc.Arguments
|
||||||
|
if args == nil && tc.Function != nil && tc.Function.Arguments != "" {
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil {
|
||||||
|
args = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if args == nil {
|
||||||
|
args = map[string]any{}
|
||||||
|
}
|
||||||
|
name := tc.Name
|
||||||
|
if name == "" && tc.Function != nil {
|
||||||
|
name = tc.Function.Name
|
||||||
|
}
|
||||||
|
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, args, name))
|
||||||
}
|
}
|
||||||
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
|
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,14 @@ func NewClaudeProviderWithTokenSourceAndBaseURL(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const anthropicSetupTokenBeta = "claude-code-20250219,oauth-2025-04-20"
|
||||||
|
|
||||||
|
func NewClaudeProviderWithSetupToken(token string, tokenSource func() (string, error)) *ClaudeProvider {
|
||||||
|
return &ClaudeProvider{
|
||||||
|
delegate: anthropicprovider.NewProviderWithTokenSourceAndBeta(token, tokenSource, "", anthropicSetupTokenBeta),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *ClaudeProvider {
|
func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *ClaudeProvider {
|
||||||
return &ClaudeProvider{delegate: delegate}
|
return &ClaudeProvider{delegate: delegate}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
}
|
}
|
||||||
case "anthropic", "claude":
|
case "anthropic", "claude":
|
||||||
if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" {
|
if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" {
|
||||||
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
|
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" || cfg.Providers.Anthropic.AuthMethod == "setup-token" {
|
||||||
sel.apiBase = cfg.Providers.Anthropic.APIBase
|
sel.apiBase = cfg.Providers.Anthropic.APIBase
|
||||||
if sel.apiBase == "" {
|
if sel.apiBase == "" {
|
||||||
sel.apiBase = defaultAnthropicAPIBase
|
sel.apiBase = defaultAnthropicAPIBase
|
||||||
|
|
@ -218,7 +218,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
}
|
}
|
||||||
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) &&
|
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) &&
|
||||||
(cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""):
|
(cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""):
|
||||||
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
|
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" || cfg.Providers.Anthropic.AuthMethod == "setup-token" {
|
||||||
sel.apiBase = cfg.Providers.Anthropic.APIBase
|
sel.apiBase = cfg.Providers.Anthropic.APIBase
|
||||||
if sel.apiBase == "" {
|
if sel.apiBase == "" {
|
||||||
sel.apiBase = defaultAnthropicAPIBase
|
sel.apiBase = defaultAnthropicAPIBase
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ func createClaudeAuthProvider() (LLMProvider, error) {
|
||||||
if cred == nil {
|
if cred == nil {
|
||||||
return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic")
|
return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic")
|
||||||
}
|
}
|
||||||
|
if cred.AuthMethod == "setup-token" {
|
||||||
|
return NewClaudeProviderWithSetupToken(cred.AccessToken, createClaudeTokenSource()), nil
|
||||||
|
}
|
||||||
return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil
|
return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,7 +115,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
|
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" || cfg.AuthMethod == "setup-token" {
|
||||||
// Use OAuth credentials from auth store
|
// Use OAuth credentials from auth store
|
||||||
provider, err := createClaudeAuthProvider()
|
provider, err := createClaudeAuthProvider()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
103
pkg/tools/send_file.go
Normal file
103
pkg/tools/send_file.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SendMediaCallback is called to deliver a file to a chat channel.
|
||||||
|
type SendMediaCallback func(channel, chatID, localPath, caption string) error
|
||||||
|
|
||||||
|
// SendFileTool allows the LLM to send files to the user on a chat channel.
|
||||||
|
type SendFileTool struct {
|
||||||
|
mediaCallback SendMediaCallback
|
||||||
|
defaultChannel string
|
||||||
|
defaultChatID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSendFileTool() *SendFileTool {
|
||||||
|
return &SendFileTool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SendFileTool) Name() string {
|
||||||
|
return "send_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SendFileTool) Description() string {
|
||||||
|
return "Send a file to the user on a chat channel. Use this to deliver files, images, documents, audio, video, etc."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SendFileTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"path": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Absolute path to the file on disk to send",
|
||||||
|
},
|
||||||
|
"caption": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional caption to include with the file",
|
||||||
|
},
|
||||||
|
"channel": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional: target channel (telegram, discord, etc.)",
|
||||||
|
},
|
||||||
|
"chat_id": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional: target chat/user ID",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"path"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SendFileTool) SetContext(channel, chatID string) {
|
||||||
|
t.defaultChannel = channel
|
||||||
|
t.defaultChatID = chatID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SendFileTool) SetMediaCallback(callback SendMediaCallback) {
|
||||||
|
t.mediaCallback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
path, ok := args["path"].(string)
|
||||||
|
if !ok || path == "" {
|
||||||
|
return ErrorResult("path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("file not accessible: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
caption, _ := args["caption"].(string)
|
||||||
|
channel, _ := args["channel"].(string)
|
||||||
|
chatID, _ := args["chat_id"].(string)
|
||||||
|
|
||||||
|
if channel == "" {
|
||||||
|
channel = t.defaultChannel
|
||||||
|
}
|
||||||
|
if chatID == "" {
|
||||||
|
chatID = t.defaultChatID
|
||||||
|
}
|
||||||
|
|
||||||
|
if channel == "" || chatID == "" {
|
||||||
|
return ErrorResult("no target channel/chat specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.mediaCallback == nil {
|
||||||
|
return ErrorResult("file sending not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.mediaCallback(channel, chatID, path, caption); err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: fmt.Sprintf("sending file: %v", err),
|
||||||
|
IsError: true,
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SilentResult(fmt.Sprintf("File sent to %s:%s: %s", channel, chatID, path))
|
||||||
|
}
|
||||||
125
readme-magno.md
Normal file
125
readme-magno.md
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# Modifiche PicoClaw — sessione 27/02/2026
|
||||||
|
|
||||||
|
## Bug fix: API Anthropic
|
||||||
|
|
||||||
|
### 1. Tool use `name` vuoto (`pkg/tools/toolloop.go`)
|
||||||
|
L'API Anthropic rifiutava le richieste con errore `tooluse.name: String should have at least 1 character`.
|
||||||
|
|
||||||
|
**Causa:** quando il codice ricostruiva i messaggi per le chiamate API successive, il campo `Name` del `ToolCall` non veniva copiato — solo `Function.Name` veniva impostato.
|
||||||
|
|
||||||
|
**Fix:** aggiunto `Name: tc.Name` nella costruzione del `ToolCall` in `toolloop.go`.
|
||||||
|
|
||||||
|
### 2. Tool use `input` non valido (`pkg/tools/toolloop.go`)
|
||||||
|
Errore: `tooluse.input: Input should be a valid dictionary`.
|
||||||
|
|
||||||
|
**Causa:** il campo `Arguments` (tipo `map[string]interface{}`) non veniva copiato nel `ToolCall`. `buildClaudeParams` usava `tc.Arguments` che era `nil`.
|
||||||
|
|
||||||
|
**Fix:** aggiunto `Arguments: tc.Arguments` nella costruzione del `ToolCall` in `toolloop.go`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ottimizzazione token / costi
|
||||||
|
|
||||||
|
### 3. Rimossa duplicazione tool nel system prompt (`pkg/agent/context.go`)
|
||||||
|
I tool venivano elencati **due volte** ad ogni richiesta:
|
||||||
|
- Come testo nel system prompt (`buildToolsSection()`)
|
||||||
|
- Come tool definitions formali nell'API (`translateToolsForClaude()`)
|
||||||
|
|
||||||
|
**Fix:** rimossa la chiamata a `buildToolsSection()` da `getIdentity()`. I tool sono già visibili al modello tramite le definizioni API.
|
||||||
|
|
||||||
|
**Risparmio:** ~300-500 token per richiesta.
|
||||||
|
|
||||||
|
### 4. Fix `contextWindow` errato (`pkg/agent/loop.go`)
|
||||||
|
`contextWindow` era impostato a `MaxTokens` (8192), che e' il limite di **output**, non la context window del modello.
|
||||||
|
|
||||||
|
Questo causava summarization premature: la soglia era `8192 * 75% = 6144` token, raggiunta quasi subito, generando chiamate API extra inutili per riassumere la conversazione.
|
||||||
|
|
||||||
|
**Fix:** aggiunta funzione `estimateContextWindow()` che ritorna la vera context window basata sul modello (es. 200K per Claude, 128K per GPT-4, 1M per Gemini).
|
||||||
|
|
||||||
|
### 5. Prompt caching Anthropic (`pkg/providers/claude_provider.go`)
|
||||||
|
Il system prompt e le tool definitions sono identici tra richieste successive, ma venivano riprocessati (e pagati) ogni volta.
|
||||||
|
|
||||||
|
**Fix:** aggiunto `CacheControl: ephemeral` sul primo blocco system (statico) e sull'ultima tool definition. Dopo la prima richiesta, vengono cachati per 5 minuti con sconto del 90% sui token di input.
|
||||||
|
|
||||||
|
### 6. Separazione system prompt statico/dinamico (`pkg/agent/context.go`)
|
||||||
|
Il system prompt era un unico blocco di testo che includeva parti dinamiche (summary sessione, info canale). Qualsiasi cambiamento invalidava tutta la cache.
|
||||||
|
|
||||||
|
**Fix:** il system prompt e' ora diviso in due blocchi `TextBlockParam`:
|
||||||
|
- **Blocco 1 (statico, cacheable):** identity, bootstrap files, skills, memory
|
||||||
|
- **Blocco 2 (dinamico):** session info, conversation summary
|
||||||
|
|
||||||
|
### 7. Ordine deterministico dei tool (`pkg/tools/registry.go`)
|
||||||
|
I tool venivano iterati da una Go `map`, che non garantisce ordine. Ad ogni richiesta i tool potevano uscire in ordine diverso, invalidando la cache dei tool definitions.
|
||||||
|
|
||||||
|
**Fix:** aggiunto `sort.Strings(names)` prima di iterare i tool in `ToProviderDefs()`.
|
||||||
|
|
||||||
|
### 8. Timestamp system prompt solo giornaliero (`pkg/agent/context.go`)
|
||||||
|
Il formato data nel system prompt includeva ore e minuti (`2006-01-02 15:04`), cambiando ogni minuto e invalidando la cache.
|
||||||
|
|
||||||
|
**Fix:** formato cambiato a solo giorno: `2006-01-02 (Monday)`.
|
||||||
|
|
||||||
|
### 9. Bootstrap files ridotti (`~/.picoclaw/workspace/`)
|
||||||
|
- `USER.md`: da 365 bytes di placeholder generici a 73 bytes con info reali
|
||||||
|
- `IDENTITY.md`: da 1273 bytes di testo ridondante a 138 bytes essenziali
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logging token usage (`pkg/agent/loop.go`, `pkg/providers/`)
|
||||||
|
|
||||||
|
Aggiunto logging del consumo token per ogni chiamata API:
|
||||||
|
- `input_tokens` — token di input
|
||||||
|
- `output_tokens` — token generati
|
||||||
|
- `cache_created` — token cachati per la prima volta
|
||||||
|
- `cache_read` — token letti dalla cache (90% sconto)
|
||||||
|
|
||||||
|
Aggiunto campo `CacheCreatedTokens` e `CacheReadTokens` in `UsageInfo` (`pkg/providers/types.go`) ed estratti dalla response Anthropic in `parseClaudeResponse()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supporto allegati file Telegram
|
||||||
|
|
||||||
|
### Modifiche (parzialmente implementate dall'utente)
|
||||||
|
- `pkg/tools/message.go` — aggiunto parametro `file_path` al tool `message` e aggiornata firma `SendCallback`
|
||||||
|
- `pkg/bus/types.go` — aggiunto campo `FilePath` a `OutboundMessage`
|
||||||
|
- `pkg/agent/loop.go` — callback aggiornata per passare `filePath`
|
||||||
|
- `pkg/channels/telegram.go` — `Send()` gia' implementato con `bot.SendDocument()` quando `FilePath` e' presente
|
||||||
|
|
||||||
|
**Flusso:** `message(content="Ecco il file", file_path="/path/to/file.md")` → tool estrae file_path → callback pubblica su bus → Telegram `Send()` invia come documento con caption.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configurazione
|
||||||
|
|
||||||
|
### Heartbeat disabilitato (`~/.picoclaw/config.json`)
|
||||||
|
L'heartbeat (ogni 30 min) consumava token dal budget Claude Code senza necessita'.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"heartbeat": { "enabled": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Riepilogo impatto
|
||||||
|
|
||||||
|
| Metrica | Prima | Dopo |
|
||||||
|
|---------|-------|------|
|
||||||
|
| Token input per richiesta semplice | ~7400 (tutti pagati pieni) | ~550 pagati + ~4200 da cache (90% sconto) |
|
||||||
|
| Chiamate API heartbeat/giorno | ~48 | 0 |
|
||||||
|
| System prompt size | ~5000 chars | ~3500 chars |
|
||||||
|
| Tool definitions cacheable | No (ordine random) | Si (ordine fisso) |
|
||||||
|
|
||||||
|
## File modificati
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/agent/context.go — system prompt statico/dinamico, rimossi tool duplicati, fix timestamp
|
||||||
|
pkg/agent/loop.go — fix contextWindow, logging token usage, estimateContextWindow()
|
||||||
|
pkg/providers/claude_provider.go — prompt caching (cache_control ephemeral)
|
||||||
|
pkg/providers/types.go — campi CacheCreatedTokens, CacheReadTokens in UsageInfo
|
||||||
|
pkg/tools/registry.go — ordine deterministico tool (sort)
|
||||||
|
pkg/tools/toolloop.go — fix Name e Arguments mancanti nel ToolCall
|
||||||
|
pkg/tools/message.go — parametro file_path, nuova firma SendCallback
|
||||||
|
pkg/bus/types.go — campo FilePath in OutboundMessage
|
||||||
|
~/.picoclaw/config.json — heartbeat disabilitato
|
||||||
|
~/.picoclaw/workspace/USER.md — ridotto
|
||||||
|
~/.picoclaw/workspace/IDENTITY.md — ridotto
|
||||||
|
```
|
||||||
Loading…
Add table
Reference in a new issue