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"
|
||||
|
||||
func authLoginCmd(provider string, useDeviceCode bool) error {
|
||||
func authLoginCmd(provider string, useDeviceCode, useSetupToken bool) error {
|
||||
switch provider {
|
||||
case "openai":
|
||||
return authLoginOpenAI(useDeviceCode)
|
||||
case "anthropic":
|
||||
if useSetupToken {
|
||||
return authLoginSetupToken()
|
||||
}
|
||||
return authLoginPasteToken(provider)
|
||||
case "google-antigravity", "antigravity":
|
||||
return authLoginGoogleAntigravity()
|
||||
|
|
@ -259,6 +262,51 @@ func authLoginPasteToken(provider string) error {
|
|||
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 {
|
||||
if provider != "" {
|
||||
if err := auth.DeleteCredential(provider); err != nil {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ func newLoginCommand() *cobra.Command {
|
|||
var (
|
||||
provider string
|
||||
useDeviceCode bool
|
||||
useSetupToken bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
|
|
@ -13,12 +14,13 @@ func newLoginCommand() *cobra.Command {
|
|||
Short: "Login via OAuth or paste token",
|
||||
Args: cobra.NoArgs,
|
||||
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().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")
|
||||
|
||||
return cmd
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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 {
|
||||
registry := NewAgentRegistry(cfg, provider)
|
||||
|
||||
// Register shared tools to all agents
|
||||
registerSharedTools(cfg, msgBus, registry, provider)
|
||||
|
||||
// Set up shared fallback chain
|
||||
cooldown := providers.NewCooldownTracker()
|
||||
fallbackChain := providers.NewFallbackChain(cooldown)
|
||||
|
|
@ -75,7 +73,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
|||
stateManager = state.NewManager(defaultAgent.Workspace)
|
||||
}
|
||||
|
||||
return &AgentLoop{
|
||||
al := &AgentLoop{
|
||||
bus: msgBus,
|
||||
cfg: cfg,
|
||||
registry: registry,
|
||||
|
|
@ -83,14 +81,20 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
|||
summarizing: sync.Map{},
|
||||
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(
|
||||
cfg *config.Config,
|
||||
msgBus *bus.MessageBus,
|
||||
registry *AgentRegistry,
|
||||
provider providers.LLMProvider,
|
||||
al *AgentLoop,
|
||||
) {
|
||||
for _, agentID := range registry.ListAgentIDs() {
|
||||
agent, ok := registry.GetAgent(agentID)
|
||||
|
|
@ -143,6 +147,39 @@ func registerSharedTools(
|
|||
})
|
||||
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
|
||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,38 @@ func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) {
|
|||
}, 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 {
|
||||
switch provider {
|
||||
case "anthropic":
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ type Provider struct {
|
|||
client *anthropic.Client
|
||||
tokenSource func() (string, error)
|
||||
baseURL string
|
||||
betaHeaders string
|
||||
}
|
||||
|
||||
func NewProvider(token string) *Provider {
|
||||
|
|
@ -64,6 +65,12 @@ func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (stri
|
|||
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(
|
||||
ctx context.Context,
|
||||
messages []Message,
|
||||
|
|
@ -79,12 +86,19 @@ func (p *Provider) Chat(
|
|||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.betaHeaders != "" {
|
||||
return p.chatStreaming(ctx, params, opts)
|
||||
}
|
||||
|
||||
resp, err := p.client.Messages.New(ctx, params, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claude API call: %w", err)
|
||||
|
|
@ -93,6 +107,28 @@ func (p *Provider) Chat(
|
|||
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 {
|
||||
return "claude-sonnet-4.6"
|
||||
}
|
||||
|
|
@ -144,7 +180,21 @@ func buildParams(
|
|||
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
|
||||
}
|
||||
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...))
|
||||
} 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 {
|
||||
return &ClaudeProvider{delegate: delegate}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
|||
}
|
||||
case "anthropic", "claude":
|
||||
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
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = defaultAnthropicAPIBase
|
||||
|
|
@ -218,7 +218,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
|||
}
|
||||
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) &&
|
||||
(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
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = defaultAnthropicAPIBase
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ func createClaudeAuthProvider() (LLMProvider, error) {
|
|||
if cred == nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +115,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
), modelID, nil
|
||||
|
||||
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
|
||||
provider, err := createClaudeAuthProvider()
|
||||
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