Merge branch 'sipeed:main' into feat/mutil_keys
This commit is contained in:
commit
1ad18ba0d1
36 changed files with 936 additions and 293 deletions
19
.github/workflows/pr.yml
vendored
19
.github/workflows/pr.yml
vendored
|
|
@ -24,6 +24,25 @@ jobs:
|
||||||
with:
|
with:
|
||||||
version: v2.10.1
|
version: v2.10.1
|
||||||
|
|
||||||
|
vuln_check:
|
||||||
|
name: Security Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Run Govulncheck
|
||||||
|
uses: golang/govulncheck-action@v1
|
||||||
|
with:
|
||||||
|
go-package: ./...
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Tests
|
name: Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
|
|
@ -827,7 +827,7 @@ Le sous-agent a accès aux outils (message, web_search, etc.) et peut communique
|
||||||
### Fournisseurs
|
### Fournisseurs
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages vocaux Telegram seront automatiquement transcrits.
|
> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages audio de n'importe quel canal seront automatiquement transcrits au niveau de l'agent.
|
||||||
|
|
||||||
| Fournisseur | Utilisation | Obtenir une Clé API |
|
| Fournisseur | Utilisation | Obtenir une Clé API |
|
||||||
| ------------------------ | ---------------------------------------- | ------------------------------------------------------ |
|
| ------------------------ | ---------------------------------------- | ------------------------------------------------------ |
|
||||||
|
|
|
||||||
|
|
@ -785,7 +785,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
### プロバイダー
|
### プロバイダー
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、Telegram の音声メッセージが自動的に文字起こしされます。
|
> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、あらゆるチャンネルからの音声メッセージがエージェントレベルで自動的に文字起こしされます。
|
||||||
|
|
||||||
| プロバイダー | 用途 | API キー取得先 |
|
| プロバイダー | 用途 | API キー取得先 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@
|
||||||
|
|
||||||
## 📢 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/ROADMAP.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](ROADMAP.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 coming 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.
|
||||||
|
|
@ -911,7 +911,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
||||||
### Providers
|
### Providers
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> 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, audio messages from any channel will be automatically transcribed at the agent level.
|
||||||
|
|
||||||
| Provider | Purpose | Get API Key |
|
| Provider | Purpose | Get API Key |
|
||||||
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
|
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
|
||||||
|
|
|
||||||
|
|
@ -823,7 +823,7 @@ O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se com
|
||||||
### Provedores
|
### Provedores
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serão automaticamente transcritas.
|
> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de áudio de qualquer canal serão automaticamente transcritas no nível do agente.
|
||||||
|
|
||||||
| Provedor | Finalidade | Obter API Key |
|
| Provedor | Finalidade | Obter API Key |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
|
||||||
|
|
@ -795,7 +795,7 @@ Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và
|
||||||
### Nhà cung cấp (Providers)
|
### Nhà cung cấp (Providers)
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn thoại trên Telegram sẽ được tự động chuyển thành văn bản.
|
> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn âm thanh từ bất kỳ kênh nào sẽ được tự động chuyển thành văn bản ở cấp độ agent.
|
||||||
|
|
||||||
| Nhà cung cấp | Mục đích | Lấy API Key |
|
| Nhà cung cấp | Mục đích | Lấy API Key |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
|
||||||
|
|
@ -459,7 +459,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
### 提供商 (Providers)
|
### 提供商 (Providers)
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
|
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,任意渠道的音频消息都将在 Agent 层面自动转录为文字。
|
||||||
|
|
||||||
| 提供商 | 用途 | 获取 API Key |
|
| 提供商 | 用途 | 获取 API Key |
|
||||||
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
func gatewayCmd(debug bool) error {
|
func gatewayCmd(debug bool) error {
|
||||||
|
|
@ -134,6 +135,12 @@ func gatewayCmd(debug bool) error {
|
||||||
agentLoop.SetChannelManager(channelManager)
|
agentLoop.SetChannelManager(channelManager)
|
||||||
agentLoop.SetMediaStore(mediaStore)
|
agentLoop.SetMediaStore(mediaStore)
|
||||||
|
|
||||||
|
// Wire up voice transcription if a supported provider is configured.
|
||||||
|
if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
|
||||||
|
agentLoop.SetTranscriber(transcriber)
|
||||||
|
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
|
||||||
|
}
|
||||||
|
|
||||||
enabledChannels := channelManager.GetEnabledChannels()
|
enabledChannels := channelManager.GetEnabledChannels()
|
||||||
if len(enabledChannels) > 0 {
|
if len(enabledChannels) > 0 {
|
||||||
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20,
|
||||||
|
"summarize_message_threshold": 20,
|
||||||
|
"summarize_token_percent": 75
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"model_list": [
|
"model_list": [
|
||||||
|
|
@ -20,7 +22,8 @@
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key",
|
"api_key": "sk-ant-your-key",
|
||||||
"api_base": "https://api.anthropic.com/v1"
|
"api_base": "https://api.anthropic.com/v1",
|
||||||
|
"thinking_level": "high"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gemini",
|
"model_name": "gemini",
|
||||||
|
|
@ -222,6 +225,10 @@
|
||||||
"mistral": {
|
"mistral": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"api_base": "https://api.mistral.ai/v1"
|
"api_base": "https://api.mistral.ai/v1"
|
||||||
|
},
|
||||||
|
"avian": {
|
||||||
|
"api_key": "",
|
||||||
|
"api_base": "https://api.avian.io/v1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
|
||||||
1
go.mod
1
go.mod
|
|
@ -37,7 +37,6 @@ require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
github.com/gdamore/encoding v1.0.1 // indirect
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
github.com/gdamore/tcell/v2 v2.13.8 // indirect
|
|
||||||
github.com/h2non/filetype v1.1.3 // indirect
|
github.com/h2non/filetype v1.1.3 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ type AgentInstance struct {
|
||||||
MaxIterations int
|
MaxIterations int
|
||||||
MaxTokens int
|
MaxTokens int
|
||||||
Temperature float64
|
Temperature float64
|
||||||
|
ThinkingLevel ThinkingLevel
|
||||||
ContextWindow int
|
ContextWindow int
|
||||||
SummarizeMessageThreshold int
|
SummarizeMessageThreshold int
|
||||||
SummarizeTokenPercent int
|
SummarizeTokenPercent int
|
||||||
|
|
@ -103,6 +104,12 @@ func NewAgentInstance(
|
||||||
temperature = *defaults.Temperature
|
temperature = *defaults.Temperature
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var thinkingLevelStr string
|
||||||
|
if mc, err := cfg.GetModelConfig(model); err == nil {
|
||||||
|
thinkingLevelStr = mc.ThinkingLevel
|
||||||
|
}
|
||||||
|
thinkingLevel := parseThinkingLevel(thinkingLevelStr)
|
||||||
|
|
||||||
summarizeMessageThreshold := defaults.SummarizeMessageThreshold
|
summarizeMessageThreshold := defaults.SummarizeMessageThreshold
|
||||||
if summarizeMessageThreshold == 0 {
|
if summarizeMessageThreshold == 0 {
|
||||||
summarizeMessageThreshold = 20
|
summarizeMessageThreshold = 20
|
||||||
|
|
@ -169,6 +176,7 @@ func NewAgentInstance(
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
MaxTokens: maxTokens,
|
MaxTokens: maxTokens,
|
||||||
Temperature: temperature,
|
Temperature: temperature,
|
||||||
|
ThinkingLevel: thinkingLevel,
|
||||||
ContextWindow: maxTokens,
|
ContextWindow: maxTokens,
|
||||||
SummarizeMessageThreshold: summarizeMessageThreshold,
|
SummarizeMessageThreshold: summarizeMessageThreshold,
|
||||||
SummarizeTokenPercent: summarizeTokenPercent,
|
SummarizeTokenPercent: summarizeTokenPercent,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
@ -31,6 +32,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AgentLoop struct {
|
type AgentLoop struct {
|
||||||
|
|
@ -43,6 +45,7 @@ type AgentLoop struct {
|
||||||
fallback *providers.FallbackChain
|
fallback *providers.FallbackChain
|
||||||
channelManager *channels.Manager
|
channelManager *channels.Manager
|
||||||
mediaStore media.MediaStore
|
mediaStore media.MediaStore
|
||||||
|
transcriber voice.Transcriber
|
||||||
}
|
}
|
||||||
|
|
||||||
// processOptions configures how a message is processed
|
// processOptions configures how a message is processed
|
||||||
|
|
@ -342,6 +345,64 @@ func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
||||||
al.mediaStore = s
|
al.mediaStore = s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTranscriber injects a voice transcriber for agent-level audio transcription.
|
||||||
|
func (al *AgentLoop) SetTranscriber(t voice.Transcriber) {
|
||||||
|
al.transcriber = t
|
||||||
|
}
|
||||||
|
|
||||||
|
var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
|
||||||
|
|
||||||
|
// transcribeAudioInMessage resolves audio media refs, transcribes them, and
|
||||||
|
// replaces audio annotations in msg.Content with the transcribed text.
|
||||||
|
func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) bus.InboundMessage {
|
||||||
|
if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcribe each audio media ref in order.
|
||||||
|
var transcriptions []string
|
||||||
|
for _, ref := range msg.Media {
|
||||||
|
path, meta, err := al.mediaStore.ResolveWithMeta(ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !utils.IsAudioFile(meta.Filename, meta.ContentType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, err := al.transcriber.Transcribe(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err})
|
||||||
|
transcriptions = append(transcriptions, "")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
transcriptions = append(transcriptions, result.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(transcriptions) == 0 {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace audio annotations sequentially with transcriptions.
|
||||||
|
idx := 0
|
||||||
|
newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string {
|
||||||
|
if idx >= len(transcriptions) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
text := transcriptions[idx]
|
||||||
|
idx++
|
||||||
|
return "[voice: " + text + "]"
|
||||||
|
})
|
||||||
|
|
||||||
|
// Append any remaining transcriptions not matched by an annotation.
|
||||||
|
for ; idx < len(transcriptions); idx++ {
|
||||||
|
newContent += "\n[voice: " + transcriptions[idx] + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Content = newContent
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
// inferMediaType determines the media type ("image", "audio", "video", "file")
|
// inferMediaType determines the media type ("image", "audio", "video", "file")
|
||||||
// from a filename and MIME content type.
|
// from a filename and MIME content type.
|
||||||
func inferMediaType(filename, contentType string) string {
|
func inferMediaType(filename, contentType string) string {
|
||||||
|
|
@ -453,6 +514,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
msg = al.transcribeAudioInMessage(ctx, msg)
|
||||||
|
|
||||||
// Route system messages to processSystemMessage
|
// Route system messages to processSystemMessage
|
||||||
if msg.Channel == "system" {
|
if msg.Channel == "system" {
|
||||||
return al.processSystemMessage(ctx, msg)
|
return al.processSystemMessage(ctx, msg)
|
||||||
|
|
@ -483,8 +546,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
|
|
||||||
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
|
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
|
||||||
if tool, ok := agent.Tools.Get("message"); ok {
|
if tool, ok := agent.Tools.Get("message"); ok {
|
||||||
if mt, ok := tool.(tools.ContextualTool); ok {
|
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
|
||||||
mt.SetContext(msg.Channel, msg.ChatID)
|
resetter.ResetSentInRound()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -599,10 +662,7 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Update tool contexts
|
// 1. Build messages (skip history for heartbeat)
|
||||||
al.updateToolContexts(agent, opts.Channel, opts.ChatID)
|
|
||||||
|
|
||||||
// 2. Build messages (skip history for heartbeat)
|
|
||||||
var history []providers.Message
|
var history []providers.Message
|
||||||
var summary string
|
var summary string
|
||||||
if !opts.NoHistory {
|
if !opts.NoHistory {
|
||||||
|
|
@ -622,10 +682,10 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize()
|
maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize()
|
||||||
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
||||||
|
|
||||||
// 3. Save user message to session
|
// 2. Save user message to session
|
||||||
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
||||||
|
|
||||||
// 4. Run LLM iteration loop
|
// 3. Run LLM iteration loop
|
||||||
finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
|
finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -634,21 +694,21 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
// If last tool had ForUser content and we already sent it, we might not need to send final response
|
// If last tool had ForUser content and we already sent it, we might not need to send final response
|
||||||
// This is controlled by the tool's Silent flag and ForUser content
|
// This is controlled by the tool's Silent flag and ForUser content
|
||||||
|
|
||||||
// 5. Handle empty response
|
// 4. Handle empty response
|
||||||
if finalContent == "" {
|
if finalContent == "" {
|
||||||
finalContent = opts.DefaultResponse
|
finalContent = opts.DefaultResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Save final assistant message to session
|
// 5. Save final assistant message to session
|
||||||
agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
|
agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
|
||||||
agent.Sessions.Save(opts.SessionKey)
|
agent.Sessions.Save(opts.SessionKey)
|
||||||
|
|
||||||
// 7. Optional: summarization
|
// 6. Optional: summarization
|
||||||
if opts.EnableSummary {
|
if opts.EnableSummary {
|
||||||
al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
|
al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Optional: send response via bus
|
// 7. Optional: send response via bus
|
||||||
if opts.SendResponse {
|
if opts.SendResponse {
|
||||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
Channel: opts.Channel,
|
Channel: opts.Channel,
|
||||||
|
|
@ -657,7 +717,7 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Log response
|
// 8. Log response
|
||||||
responsePreview := utils.Truncate(finalContent, 120)
|
responsePreview := utils.Truncate(finalContent, 120)
|
||||||
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
|
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
|
||||||
map[string]any{
|
map[string]any{
|
||||||
|
|
@ -774,23 +834,29 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
var response *providers.LLMResponse
|
var response *providers.LLMResponse
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
llmOpts := map[string]any{
|
||||||
|
"max_tokens": agent.MaxTokens,
|
||||||
|
"temperature": agent.Temperature,
|
||||||
|
"prompt_cache_key": agent.ID,
|
||||||
|
}
|
||||||
|
// parseThinkingLevel guarantees ThinkingOff for empty/unknown values,
|
||||||
|
// so checking != ThinkingOff is sufficient.
|
||||||
|
if agent.ThinkingLevel != ThinkingOff {
|
||||||
|
if tc, ok := agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() {
|
||||||
|
llmOpts["thinking_level"] = string(agent.ThinkingLevel)
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring",
|
||||||
|
map[string]any{"agent_id": agent.ID, "thinking_level": string(agent.ThinkingLevel)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
callLLM := func() (*providers.LLMResponse, error) {
|
callLLM := func() (*providers.LLMResponse, error) {
|
||||||
if len(agent.Candidates) > 1 && al.fallback != nil {
|
if len(agent.Candidates) > 1 && al.fallback != nil {
|
||||||
fbResult, fbErr := al.fallback.Execute(
|
fbResult, fbErr := al.fallback.Execute(
|
||||||
ctx,
|
ctx,
|
||||||
agent.Candidates,
|
agent.Candidates,
|
||||||
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
||||||
return agent.Provider.Chat(
|
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, llmOpts)
|
||||||
ctx,
|
|
||||||
messages,
|
|
||||||
providerToolDefs,
|
|
||||||
model,
|
|
||||||
map[string]any{
|
|
||||||
"max_tokens": agent.MaxTokens,
|
|
||||||
"temperature": agent.Temperature,
|
|
||||||
"prompt_cache_key": agent.ID,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if fbErr != nil {
|
if fbErr != nil {
|
||||||
|
|
@ -806,11 +872,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
}
|
}
|
||||||
return fbResult.Response, nil
|
return fbResult.Response, nil
|
||||||
}
|
}
|
||||||
return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{
|
return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, llmOpts)
|
||||||
"max_tokens": agent.MaxTokens,
|
|
||||||
"temperature": agent.Temperature,
|
|
||||||
"prompt_cache_key": agent.ID,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry loop for context/token errors
|
// Retry loop for context/token errors
|
||||||
|
|
@ -997,7 +1059,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create async callback for tools that implement AsyncTool
|
// Create async callback for tools that implement AsyncExecutor
|
||||||
asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) {
|
asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) {
|
||||||
if !result.Silent && result.ForUser != "" {
|
if !result.Silent && result.ForUser != "" {
|
||||||
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
|
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
|
||||||
|
|
@ -1079,26 +1141,6 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
return finalContent, iteration, nil
|
return finalContent, iteration, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateToolContexts updates the context for tools that need channel/chatID info.
|
|
||||||
func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
|
|
||||||
// Use ContextualTool interface instead of type assertions
|
|
||||||
if tool, ok := agent.Tools.Get("message"); ok {
|
|
||||||
if mt, ok := tool.(tools.ContextualTool); ok {
|
|
||||||
mt.SetContext(channel, chatID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if tool, ok := agent.Tools.Get("spawn"); ok {
|
|
||||||
if st, ok := tool.(tools.ContextualTool); ok {
|
|
||||||
st.SetContext(channel, chatID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if tool, ok := agent.Tools.Get("subagent"); ok {
|
|
||||||
if st, ok := tool.(tools.ContextualTool); ok {
|
|
||||||
st.SetContext(channel, chatID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
||||||
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
|
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
|
||||||
newHistory := agent.Sessions.GetHistory(sessionKey)
|
newHistory := agent.Sessions.GetHistory(sessionKey)
|
||||||
|
|
|
||||||
|
|
@ -164,35 +164,21 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestToolContext_Updates verifies tool context is updated with channel/chatID
|
// TestToolContext_Updates verifies tool context helpers work correctly
|
||||||
func TestToolContext_Updates(t *testing.T) {
|
func TestToolContext_Updates(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
ctx := tools.WithToolContext(context.Background(), "telegram", "chat-42")
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(tmpDir)
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
if got := tools.ToolChannel(ctx); got != "telegram" {
|
||||||
Agents: config.AgentsConfig{
|
t.Errorf("expected channel 'telegram', got %q", got)
|
||||||
Defaults: config.AgentDefaults{
|
}
|
||||||
Workspace: tmpDir,
|
if got := tools.ToolChatID(ctx); got != "chat-42" {
|
||||||
Model: "test-model",
|
t.Errorf("expected chatID 'chat-42', got %q", got)
|
||||||
MaxTokens: 4096,
|
|
||||||
MaxToolIterations: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
// Empty context returns empty strings
|
||||||
provider := &simpleMockProvider{response: "OK"}
|
if got := tools.ToolChannel(context.Background()); got != "" {
|
||||||
_ = NewAgentLoop(cfg, msgBus, provider)
|
t.Errorf("expected empty channel from bare context, got %q", got)
|
||||||
|
}
|
||||||
// Verify that ContextualTool interface is defined and can be implemented
|
|
||||||
// This test validates the interface contract exists
|
|
||||||
ctxTool := &mockContextualTool{}
|
|
||||||
|
|
||||||
// Verify the tool implements the interface correctly
|
|
||||||
var _ tools.ContextualTool = ctxTool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved
|
// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved
|
||||||
|
|
@ -359,36 +345,6 @@ func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tool
|
||||||
return tools.SilentResult("Custom tool executed")
|
return tools.SilentResult("Custom tool executed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// mockContextualTool tracks context updates
|
|
||||||
type mockContextualTool struct {
|
|
||||||
lastChannel string
|
|
||||||
lastChatID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Name() string {
|
|
||||||
return "mock_contextual"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Description() string {
|
|
||||||
return "Mock contextual tool"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Parameters() map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
|
||||||
return tools.SilentResult("Contextual tool executed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) SetContext(channel, chatID string) {
|
|
||||||
m.lastChannel = channel
|
|
||||||
m.lastChatID = chatID
|
|
||||||
}
|
|
||||||
|
|
||||||
// testHelper executes a message and returns the response
|
// testHelper executes a message and returns the response
|
||||||
type testHelper struct {
|
type testHelper struct {
|
||||||
al *AgentLoop
|
al *AgentLoop
|
||||||
|
|
|
||||||
39
pkg/agent/thinking.go
Normal file
39
pkg/agent/thinking.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// ThinkingLevel controls how the provider sends thinking parameters.
|
||||||
|
//
|
||||||
|
// - "adaptive": sends {thinking: {type: "adaptive"}} + output_config.effort (Claude 4.6+)
|
||||||
|
// - "low"/"medium"/"high"/"xhigh": sends {thinking: {type: "enabled", budget_tokens: N}} (all models)
|
||||||
|
// - "off": disables thinking
|
||||||
|
type ThinkingLevel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ThinkingOff ThinkingLevel = "off"
|
||||||
|
ThinkingLow ThinkingLevel = "low"
|
||||||
|
ThinkingMedium ThinkingLevel = "medium"
|
||||||
|
ThinkingHigh ThinkingLevel = "high"
|
||||||
|
ThinkingXHigh ThinkingLevel = "xhigh"
|
||||||
|
ThinkingAdaptive ThinkingLevel = "adaptive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseThinkingLevel normalizes a config string to a ThinkingLevel.
|
||||||
|
// Case-insensitive and whitespace-tolerant for user-facing config values.
|
||||||
|
// Returns ThinkingOff for unknown or empty values.
|
||||||
|
func parseThinkingLevel(level string) ThinkingLevel {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||||
|
case "adaptive":
|
||||||
|
return ThinkingAdaptive
|
||||||
|
case "low":
|
||||||
|
return ThinkingLow
|
||||||
|
case "medium":
|
||||||
|
return ThinkingMedium
|
||||||
|
case "high":
|
||||||
|
return ThinkingHigh
|
||||||
|
case "xhigh":
|
||||||
|
return ThinkingXHigh
|
||||||
|
default:
|
||||||
|
return ThinkingOff
|
||||||
|
}
|
||||||
|
}
|
||||||
35
pkg/agent/thinking_test.go
Normal file
35
pkg/agent/thinking_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseThinkingLevel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want ThinkingLevel
|
||||||
|
}{
|
||||||
|
{"off", "off", ThinkingOff},
|
||||||
|
{"empty", "", ThinkingOff},
|
||||||
|
{"low", "low", ThinkingLow},
|
||||||
|
{"medium", "medium", ThinkingMedium},
|
||||||
|
{"high", "high", ThinkingHigh},
|
||||||
|
{"xhigh", "xhigh", ThinkingXHigh},
|
||||||
|
{"adaptive", "adaptive", ThinkingAdaptive},
|
||||||
|
{"unknown", "unknown", ThinkingOff},
|
||||||
|
// Case-insensitive and whitespace-tolerant
|
||||||
|
{"upper_Medium", "Medium", ThinkingMedium},
|
||||||
|
{"upper_HIGH", "HIGH", ThinkingHigh},
|
||||||
|
{"mixed_Adaptive", "Adaptive", ThinkingAdaptive},
|
||||||
|
{"leading_space", " high", ThinkingHigh},
|
||||||
|
{"trailing_space", "low ", ThinkingLow},
|
||||||
|
{"both_spaces", " medium ", ThinkingMedium},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := parseThinkingLevel(tt.input); got != tt.want {
|
||||||
|
t.Errorf("parseThinkingLevel(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -432,6 +432,7 @@ type ProvidersConfig struct {
|
||||||
Antigravity ProviderConfig `json:"antigravity"`
|
Antigravity ProviderConfig `json:"antigravity"`
|
||||||
Qwen ProviderConfig `json:"qwen"`
|
Qwen ProviderConfig `json:"qwen"`
|
||||||
Mistral ProviderConfig `json:"mistral"`
|
Mistral ProviderConfig `json:"mistral"`
|
||||||
|
Avian ProviderConfig `json:"avian"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
||||||
|
|
@ -455,7 +456,8 @@ func (p ProvidersConfig) IsEmpty() bool {
|
||||||
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
||||||
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
||||||
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
||||||
p.Mistral.APIKey == "" && p.Mistral.APIBase == ""
|
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
|
||||||
|
p.Avian.APIKey == "" && p.Avian.APIBase == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||||
|
|
@ -506,6 +508,7 @@ type ModelConfig struct {
|
||||||
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
||||||
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
||||||
RequestTimeout int `json:"request_timeout,omitempty"`
|
RequestTimeout int `json:"request_timeout,omitempty"`
|
||||||
|
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if the ModelConfig has all required fields.
|
// Validate checks if the ModelConfig has all required fields.
|
||||||
|
|
|
||||||
|
|
@ -308,6 +308,20 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Avian - https://avian.io
|
||||||
|
{
|
||||||
|
ModelName: "deepseek-v3.2",
|
||||||
|
Model: "avian/deepseek/deepseek-v3.2",
|
||||||
|
APIBase: "https://api.avian.io/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "kimi-k2.5",
|
||||||
|
Model: "avian/moonshotai/kimi-k2.5",
|
||||||
|
APIBase: "https://api.avian.io/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
// VLLM (local) - http://localhost:8000
|
// VLLM (local) - http://localhost:8000
|
||||||
{
|
{
|
||||||
ModelName: "local-model",
|
ModelName: "local-model",
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"avian"},
|
||||||
|
protocol: "avian",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.Avian.APIKey == "" && p.Avian.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "avian",
|
||||||
|
Model: "avian/deepseek/deepseek-v3.2",
|
||||||
|
APIKey: p.Avian.APIKey,
|
||||||
|
APIBase: p.Avian.APIBase,
|
||||||
|
Proxy: p.Avian.Proxy,
|
||||||
|
RequestTimeout: p.Avian.RequestTimeout,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each provider migration
|
// Process each provider migration
|
||||||
|
|
|
||||||
|
|
@ -160,14 +160,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
||||||
Antigravity: ProviderConfig{AuthMethod: "oauth"},
|
Antigravity: ProviderConfig{AuthMethod: "oauth"},
|
||||||
Qwen: ProviderConfig{APIKey: "key17"},
|
Qwen: ProviderConfig{APIKey: "key17"},
|
||||||
Mistral: ProviderConfig{APIKey: "key18"},
|
Mistral: ProviderConfig{APIKey: "key18"},
|
||||||
|
Avian: ProviderConfig{APIKey: "key19"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := ConvertProvidersToModelList(cfg)
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
// All 19 providers should be converted
|
// All 20 providers should be converted
|
||||||
if len(result) != 19 {
|
if len(result) != 20 {
|
||||||
t.Errorf("len(result) = %d, want 19", len(result))
|
t.Errorf("len(result) = %d, want 20", len(result))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ type Provider struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SupportsThinking implements providers.ThinkingCapable.
|
||||||
|
func (p *Provider) SupportsThinking() bool { return true }
|
||||||
|
|
||||||
func NewProvider(token string) *Provider {
|
func NewProvider(token string) *Provider {
|
||||||
return NewProviderWithBaseURL(token, "")
|
return NewProviderWithBaseURL(token, "")
|
||||||
}
|
}
|
||||||
|
|
@ -182,9 +185,80 @@ func buildParams(
|
||||||
params.Tools = translateTools(tools)
|
params.Tools = translateTools(tools)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extended Thinking / Adaptive Thinking
|
||||||
|
// The thinking_level value directly determines the API parameter format:
|
||||||
|
// "adaptive" → {thinking: {type: "adaptive"}} + output_config.effort
|
||||||
|
// "low/medium/high/xhigh" → {thinking: {type: "enabled", budget_tokens: N}}
|
||||||
|
if level, ok := options["thinking_level"].(string); ok && level != "" && level != "off" {
|
||||||
|
applyThinkingConfig(¶ms, level)
|
||||||
|
}
|
||||||
|
|
||||||
return params, nil
|
return params, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyThinkingConfig sets thinking parameters based on the level value.
|
||||||
|
// "adaptive" uses the adaptive thinking API (Claude 4.6+).
|
||||||
|
// All other levels use budget_tokens which is universally supported.
|
||||||
|
//
|
||||||
|
// Anthropic API constraint: temperature must not be set when thinking is enabled.
|
||||||
|
// budget_tokens must be strictly less than max_tokens.
|
||||||
|
func applyThinkingConfig(params *anthropic.MessageNewParams, level string) {
|
||||||
|
// Anthropic API rejects requests with temperature set alongside thinking.
|
||||||
|
// Reset to zero value (omitted from JSON serialization).
|
||||||
|
if params.Temperature.Valid() {
|
||||||
|
log.Printf("anthropic: temperature cleared because thinking is enabled (level=%s)", level)
|
||||||
|
}
|
||||||
|
params.Temperature = anthropic.MessageNewParams{}.Temperature
|
||||||
|
|
||||||
|
if level == "adaptive" {
|
||||||
|
adaptive := anthropic.NewThinkingConfigAdaptiveParam()
|
||||||
|
params.Thinking = anthropic.ThinkingConfigParamUnion{OfAdaptive: &adaptive}
|
||||||
|
params.OutputConfig = anthropic.OutputConfigParam{
|
||||||
|
Effort: anthropic.OutputConfigEffortHigh,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
budget := int64(levelToBudget(level))
|
||||||
|
if budget <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// budget_tokens must be < max_tokens; clamp to respect user's max_tokens setting.
|
||||||
|
if budget >= params.MaxTokens {
|
||||||
|
log.Printf("anthropic: budget_tokens (%d) clamped to %d (max_tokens-1)", budget, params.MaxTokens-1)
|
||||||
|
budget = params.MaxTokens - 1
|
||||||
|
} else if budget > params.MaxTokens*80/100 {
|
||||||
|
log.Printf("anthropic: thinking budget (%d) exceeds 80%% of max_tokens (%d), output may be truncated",
|
||||||
|
budget, params.MaxTokens)
|
||||||
|
}
|
||||||
|
params.Thinking = anthropic.ThinkingConfigParamOfEnabled(budget)
|
||||||
|
}
|
||||||
|
|
||||||
|
// levelToBudget maps a thinking level to budget_tokens.
|
||||||
|
// Values are based on Anthropic's recommendations and community best practices:
|
||||||
|
//
|
||||||
|
// low = 4,096 — simple reasoning, quick debugging (Claude Code "think")
|
||||||
|
// medium = 16,384 — Anthropic recommended sweet spot for most tasks
|
||||||
|
// high = 32,000 — complex architecture, deep analysis (diminishing returns above this)
|
||||||
|
// xhigh = 64,000 — extreme reasoning, research problems, benchmarks
|
||||||
|
//
|
||||||
|
// Note: For Claude 4.6+, prefer adaptive thinking over manual budget_tokens.
|
||||||
|
func levelToBudget(level string) int {
|
||||||
|
switch level {
|
||||||
|
case "low":
|
||||||
|
return 4096
|
||||||
|
case "medium":
|
||||||
|
return 16384
|
||||||
|
case "high":
|
||||||
|
return 32000
|
||||||
|
case "xhigh":
|
||||||
|
return 64000
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
||||||
result := make([]anthropic.ToolUnionParam, 0, len(tools))
|
result := make([]anthropic.ToolUnionParam, 0, len(tools))
|
||||||
for _, t := range tools {
|
for _, t := range tools {
|
||||||
|
|
@ -213,10 +287,14 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
||||||
|
|
||||||
func parseResponse(resp *anthropic.Message) *LLMResponse {
|
func parseResponse(resp *anthropic.Message) *LLMResponse {
|
||||||
var content strings.Builder
|
var content strings.Builder
|
||||||
|
var reasoning strings.Builder
|
||||||
var toolCalls []ToolCall
|
var toolCalls []ToolCall
|
||||||
|
|
||||||
for _, block := range resp.Content {
|
for _, block := range resp.Content {
|
||||||
switch block.Type {
|
switch block.Type {
|
||||||
|
case "thinking":
|
||||||
|
tb := block.AsThinking()
|
||||||
|
reasoning.WriteString(tb.Thinking)
|
||||||
case "text":
|
case "text":
|
||||||
tb := block.AsText()
|
tb := block.AsText()
|
||||||
content.WriteString(tb.Text)
|
content.WriteString(tb.Text)
|
||||||
|
|
@ -247,6 +325,7 @@ func parseResponse(resp *anthropic.Message) *LLMResponse {
|
||||||
|
|
||||||
return &LLMResponse{
|
return &LLMResponse{
|
||||||
Content: content.String(),
|
Content: content.String(),
|
||||||
|
Reasoning: reasoning.String(),
|
||||||
ToolCalls: toolCalls,
|
ToolCalls: toolCalls,
|
||||||
FinishReason: finishReason,
|
FinishReason: finishReason,
|
||||||
Usage: &UsageInfo{
|
Usage: &UsageInfo{
|
||||||
|
|
|
||||||
212
pkg/providers/anthropic/thinking_test.go
Normal file
212
pkg/providers/anthropic/thinking_test.go
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
package anthropicprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplyThinkingConfig_Adaptive(t *testing.T) {
|
||||||
|
params := anthropic.MessageNewParams{
|
||||||
|
MaxTokens: 16000,
|
||||||
|
Temperature: anthropic.Float(0.7),
|
||||||
|
}
|
||||||
|
applyThinkingConfig(¶ms, "adaptive")
|
||||||
|
|
||||||
|
if params.Thinking.OfAdaptive == nil {
|
||||||
|
t.Fatal("expected adaptive thinking")
|
||||||
|
}
|
||||||
|
if params.Thinking.OfEnabled != nil {
|
||||||
|
t.Error("should not set enabled thinking in adaptive mode")
|
||||||
|
}
|
||||||
|
if params.OutputConfig.Effort != anthropic.OutputConfigEffortHigh {
|
||||||
|
t.Errorf("effort = %q, want %q", params.OutputConfig.Effort, anthropic.OutputConfigEffortHigh)
|
||||||
|
}
|
||||||
|
if params.Temperature.Valid() {
|
||||||
|
t.Error("temperature should be cleared when thinking is enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyThinkingConfig_BudgetLevels(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
level string
|
||||||
|
wantBudget int64
|
||||||
|
}{
|
||||||
|
{"low", 4096},
|
||||||
|
{"medium", 16384},
|
||||||
|
{"high", 32000},
|
||||||
|
{"xhigh", 64000},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.level, func(t *testing.T) {
|
||||||
|
params := anthropic.MessageNewParams{
|
||||||
|
MaxTokens: 200000,
|
||||||
|
Temperature: anthropic.Float(0.5),
|
||||||
|
}
|
||||||
|
applyThinkingConfig(¶ms, tt.level)
|
||||||
|
|
||||||
|
if params.Thinking.OfEnabled == nil {
|
||||||
|
t.Fatal("expected enabled thinking")
|
||||||
|
}
|
||||||
|
if params.Thinking.OfAdaptive != nil {
|
||||||
|
t.Error("should not set adaptive thinking")
|
||||||
|
}
|
||||||
|
if params.Thinking.OfEnabled.BudgetTokens != tt.wantBudget {
|
||||||
|
t.Errorf("budget_tokens = %d, want %d", params.Thinking.OfEnabled.BudgetTokens, tt.wantBudget)
|
||||||
|
}
|
||||||
|
if params.OutputConfig.Effort != "" {
|
||||||
|
t.Errorf("effort = %q, want empty", params.OutputConfig.Effort)
|
||||||
|
}
|
||||||
|
if params.Temperature.Valid() {
|
||||||
|
t.Error("temperature should be cleared when thinking is enabled")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyThinkingConfig_BudgetClamp(t *testing.T) {
|
||||||
|
// budget_tokens must be < max_tokens; clamp budget down to respect user's max_tokens.
|
||||||
|
params := anthropic.MessageNewParams{MaxTokens: 4096}
|
||||||
|
applyThinkingConfig(¶ms, "high") // budget=32000 > maxTokens=4096
|
||||||
|
|
||||||
|
if params.Thinking.OfEnabled == nil {
|
||||||
|
t.Fatal("expected enabled thinking")
|
||||||
|
}
|
||||||
|
if params.Thinking.OfEnabled.BudgetTokens != 4095 {
|
||||||
|
t.Errorf("budget_tokens = %d, want 4095 (maxTokens-1)", params.Thinking.OfEnabled.BudgetTokens)
|
||||||
|
}
|
||||||
|
if params.MaxTokens != 4096 {
|
||||||
|
t.Errorf("max_tokens should not be modified, got %d", params.MaxTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyThinkingConfig_UnknownLevel(t *testing.T) {
|
||||||
|
params := anthropic.MessageNewParams{MaxTokens: 16000}
|
||||||
|
applyThinkingConfig(¶ms, "unknown")
|
||||||
|
|
||||||
|
if params.Thinking.OfEnabled != nil {
|
||||||
|
t.Error("should not set enabled thinking for unknown level")
|
||||||
|
}
|
||||||
|
if params.Thinking.OfAdaptive != nil {
|
||||||
|
t.Error("should not set adaptive thinking for unknown level")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLevelToBudget(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
level string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"low", "low", 4096},
|
||||||
|
{"medium", "medium", 16384},
|
||||||
|
{"high", "high", 32000},
|
||||||
|
{"xhigh", "xhigh", 64000},
|
||||||
|
{"off", "off", 0},
|
||||||
|
{"empty", "", 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := levelToBudget(tt.level); got != tt.want {
|
||||||
|
t.Errorf("levelToBudget(%q) = %d, want %d", tt.level, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildParams_ThinkingClearsTemperature(t *testing.T) {
|
||||||
|
msgs := []Message{{Role: "user", Content: "hello"}}
|
||||||
|
opts := map[string]any{
|
||||||
|
"max_tokens": 200000,
|
||||||
|
"temperature": 0.8,
|
||||||
|
"thinking_level": "medium",
|
||||||
|
}
|
||||||
|
|
||||||
|
params, err := buildParams(msgs, nil, "claude-sonnet-4-6", opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Temperature.Valid() {
|
||||||
|
t.Error("temperature should be cleared when thinking_level is set")
|
||||||
|
}
|
||||||
|
if params.Thinking.OfEnabled == nil {
|
||||||
|
t.Fatal("expected enabled thinking")
|
||||||
|
}
|
||||||
|
if params.Thinking.OfEnabled.BudgetTokens != 16384 {
|
||||||
|
t.Errorf("budget_tokens = %d, want 16384", params.Thinking.OfEnabled.BudgetTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmarshalBlocks constructs []ContentBlockUnion via JSON round-trip so that
|
||||||
|
// the internal JSON.raw field is populated (required by AsText/AsThinking).
|
||||||
|
func unmarshalBlocks(t *testing.T, jsonStr string) []anthropic.ContentBlockUnion {
|
||||||
|
t.Helper()
|
||||||
|
var blocks []anthropic.ContentBlockUnion
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &blocks); err != nil {
|
||||||
|
t.Fatalf("unmarshalBlocks: %v", err)
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseResponse_ThinkingBlock(t *testing.T) {
|
||||||
|
resp := &anthropic.Message{
|
||||||
|
Content: unmarshalBlocks(t, `[
|
||||||
|
{"type":"thinking","thinking":"Let me reason step by step...","signature":"sig"},
|
||||||
|
{"type":"text","text":"The answer is 42."}
|
||||||
|
]`),
|
||||||
|
StopReason: anthropic.StopReasonEndTurn,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := parseResponse(resp)
|
||||||
|
|
||||||
|
if result.Reasoning != "Let me reason step by step..." {
|
||||||
|
t.Errorf("Reasoning = %q, want thinking content", result.Reasoning)
|
||||||
|
}
|
||||||
|
if result.Content != "The answer is 42." {
|
||||||
|
t.Errorf("Content = %q, want text content", result.Content)
|
||||||
|
}
|
||||||
|
if result.FinishReason != "stop" {
|
||||||
|
t.Errorf("FinishReason = %q, want stop", result.FinishReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseResponse_NoThinkingBlock(t *testing.T) {
|
||||||
|
resp := &anthropic.Message{
|
||||||
|
Content: unmarshalBlocks(t, `[
|
||||||
|
{"type":"text","text":"Just a normal response."}
|
||||||
|
]`),
|
||||||
|
StopReason: anthropic.StopReasonEndTurn,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := parseResponse(resp)
|
||||||
|
|
||||||
|
if result.Reasoning != "" {
|
||||||
|
t.Errorf("Reasoning = %q, want empty", result.Reasoning)
|
||||||
|
}
|
||||||
|
if result.Content != "Just a normal response." {
|
||||||
|
t.Errorf("Content = %q, want text content", result.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildParams_NoThinkingKeepsTemperature(t *testing.T) {
|
||||||
|
msgs := []Message{{Role: "user", Content: "hello"}}
|
||||||
|
opts := map[string]any{
|
||||||
|
"temperature": 0.8,
|
||||||
|
}
|
||||||
|
|
||||||
|
params, err := buildParams(msgs, nil, "claude-sonnet-4-6", opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !params.Temperature.Valid() {
|
||||||
|
t.Error("temperature should be preserved when thinking is not set")
|
||||||
|
}
|
||||||
|
if params.Temperature.Value != 0.8 {
|
||||||
|
t.Errorf("temperature = %f, want 0.8", params.Temperature.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -181,6 +181,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
sel.model = "deepseek-chat"
|
sel.model = "deepseek-chat"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "avian":
|
||||||
|
if cfg.Providers.Avian.APIKey != "" {
|
||||||
|
sel.apiKey = cfg.Providers.Avian.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Avian.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Avian.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://api.avian.io/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
case "mistral":
|
case "mistral":
|
||||||
if cfg.Providers.Mistral.APIKey != "" {
|
if cfg.Providers.Mistral.APIKey != "" {
|
||||||
sel.apiKey = cfg.Providers.Mistral.APIKey
|
sel.apiKey = cfg.Providers.Mistral.APIKey
|
||||||
|
|
@ -300,6 +309,13 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
if sel.apiBase == "" {
|
if sel.apiBase == "" {
|
||||||
sel.apiBase = "https://api.mistral.ai/v1"
|
sel.apiBase = "https://api.mistral.ai/v1"
|
||||||
}
|
}
|
||||||
|
case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "":
|
||||||
|
sel.apiKey = cfg.Providers.Avian.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Avian.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Avian.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://api.avian.io/v1"
|
||||||
|
}
|
||||||
case cfg.Providers.VLLM.APIBase != "":
|
case cfg.Providers.VLLM.APIBase != "":
|
||||||
sel.apiKey = cfg.Providers.VLLM.APIKey
|
sel.apiKey = cfg.Providers.VLLM.APIKey
|
||||||
sel.apiBase = cfg.Providers.VLLM.APIBase
|
sel.apiBase = cfg.Providers.VLLM.APIBase
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
|
|
||||||
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||||
"volcengine", "vllm", "qwen", "mistral":
|
"volcengine", "vllm", "qwen", "mistral", "avian":
|
||||||
// All other OpenAI-compatible HTTP providers
|
// All other OpenAI-compatible HTTP providers
|
||||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||||
|
|
@ -208,6 +208,8 @@ func getDefaultAPIBase(protocol string) string {
|
||||||
return "http://localhost:8000/v1"
|
return "http://localhost:8000/v1"
|
||||||
case "mistral":
|
case "mistral":
|
||||||
return "https://api.mistral.ai/v1"
|
return "https://api.mistral.ai/v1"
|
||||||
|
case "avian":
|
||||||
|
return "https://api.avian.io/v1"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -323,12 +323,14 @@ func serializeMessages(messages []Message) []any {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, mediaURL := range m.Media {
|
for _, mediaURL := range m.Media {
|
||||||
parts = append(parts, map[string]any{
|
if strings.HasPrefix(mediaURL, "data:image/") {
|
||||||
"type": "image_url",
|
parts = append(parts, map[string]any{
|
||||||
"image_url": map[string]any{
|
"type": "image_url",
|
||||||
"url": mediaURL,
|
"image_url": map[string]any{
|
||||||
},
|
"url": mediaURL,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := map[string]any{
|
msg := map[string]any{
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,13 @@ type StatefulProvider interface {
|
||||||
Close()
|
Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ThinkingCapable is an optional interface for providers that support
|
||||||
|
// extended thinking (e.g. Anthropic). Used by the agent loop to warn
|
||||||
|
// when thinking_level is configured but the active provider cannot use it.
|
||||||
|
type ThinkingCapable interface {
|
||||||
|
SupportsThinking() bool
|
||||||
|
}
|
||||||
|
|
||||||
// FailoverReason classifies why an LLM request failed for fallback decisions.
|
// FailoverReason classifies why an LLM request failed for fallback decisions.
|
||||||
type FailoverReason string
|
type FailoverReason string
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,38 @@ type Tool interface {
|
||||||
Execute(ctx context.Context, args map[string]any) *ToolResult
|
Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContextualTool is an optional interface that tools can implement
|
// --- Request-scoped tool context (channel / chatID) ---
|
||||||
// to receive the current message context (channel, chatID)
|
//
|
||||||
type ContextualTool interface {
|
// Carried via context.Value so that concurrent tool calls each receive
|
||||||
Tool
|
// their own immutable copy — no mutable state on singleton tool instances.
|
||||||
SetContext(channel, chatID string)
|
//
|
||||||
|
// Keys are unexported pointer-typed vars — guaranteed collision-free,
|
||||||
|
// and only accessible through the helper functions below.
|
||||||
|
|
||||||
|
type toolCtxKey struct{ name string }
|
||||||
|
|
||||||
|
var (
|
||||||
|
ctxKeyChannel = &toolCtxKey{"channel"}
|
||||||
|
ctxKeyChatID = &toolCtxKey{"chatID"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// WithToolContext returns a child context carrying channel and chatID.
|
||||||
|
func WithToolContext(ctx context.Context, channel, chatID string) context.Context {
|
||||||
|
ctx = context.WithValue(ctx, ctxKeyChannel, channel)
|
||||||
|
ctx = context.WithValue(ctx, ctxKeyChatID, chatID)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolChannel extracts the channel from ctx, or "" if unset.
|
||||||
|
func ToolChannel(ctx context.Context) string {
|
||||||
|
v, _ := ctx.Value(ctxKeyChannel).(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolChatID extracts the chatID from ctx, or "" if unset.
|
||||||
|
func ToolChatID(ctx context.Context) string {
|
||||||
|
v, _ := ctx.Value(ctxKeyChatID).(string)
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// AsyncCallback is a function type that async tools use to notify completion.
|
// AsyncCallback is a function type that async tools use to notify completion.
|
||||||
|
|
@ -22,51 +49,36 @@ type ContextualTool interface {
|
||||||
//
|
//
|
||||||
// The ctx parameter allows the callback to be canceled if the agent is shutting down.
|
// The ctx parameter allows the callback to be canceled if the agent is shutting down.
|
||||||
// The result parameter contains the tool's execution result.
|
// The result parameter contains the tool's execution result.
|
||||||
//
|
|
||||||
// Example usage in an async tool:
|
|
||||||
//
|
|
||||||
// func (t *MyAsyncTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
|
||||||
// // Start async work in background
|
|
||||||
// go func() {
|
|
||||||
// result := doAsyncWork()
|
|
||||||
// if t.callback != nil {
|
|
||||||
// t.callback(ctx, result)
|
|
||||||
// }
|
|
||||||
// }()
|
|
||||||
// return AsyncResult("Async task started")
|
|
||||||
// }
|
|
||||||
type AsyncCallback func(ctx context.Context, result *ToolResult)
|
type AsyncCallback func(ctx context.Context, result *ToolResult)
|
||||||
|
|
||||||
// AsyncTool is an optional interface that tools can implement to support
|
// AsyncExecutor is an optional interface that tools can implement to support
|
||||||
// asynchronous execution with completion callbacks.
|
// asynchronous execution with completion callbacks.
|
||||||
//
|
//
|
||||||
// Async tools return immediately with an AsyncResult, then notify completion
|
// Unlike the old AsyncTool pattern (SetCallback + Execute), AsyncExecutor
|
||||||
// via the callback set by SetCallback.
|
// receives the callback as a parameter of ExecuteAsync. This eliminates the
|
||||||
|
// data race where concurrent calls could overwrite each other's callbacks
|
||||||
|
// on a shared tool instance.
|
||||||
//
|
//
|
||||||
// This is useful for:
|
// This is useful for:
|
||||||
// - Long-running operations that shouldn't block the agent loop
|
// - Long-running operations that shouldn't block the agent loop
|
||||||
// - Subagent spawns that complete independently
|
// - Subagent spawns that complete independently
|
||||||
// - Background tasks that need to report results later
|
// - Background tasks that need to report results later
|
||||||
//
|
//
|
||||||
// Example:
|
// Example:
|
||||||
//
|
//
|
||||||
// type SpawnTool struct {
|
// func (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult {
|
||||||
// callback AsyncCallback
|
// go func() {
|
||||||
// }
|
// result := t.runSubagent(ctx, args)
|
||||||
//
|
// if cb != nil { cb(ctx, result) }
|
||||||
// func (t *SpawnTool) SetCallback(cb AsyncCallback) {
|
// }()
|
||||||
// t.callback = cb
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
|
||||||
// go t.runSubagent(ctx, args)
|
|
||||||
// return AsyncResult("Subagent spawned, will report back")
|
// return AsyncResult("Subagent spawned, will report back")
|
||||||
// }
|
// }
|
||||||
type AsyncTool interface {
|
type AsyncExecutor interface {
|
||||||
Tool
|
Tool
|
||||||
// SetCallback registers a callback function to be invoked when the async operation completes.
|
// ExecuteAsync runs the tool asynchronously. The callback cb will be
|
||||||
// The callback will be called from a goroutine and should handle thread-safety if needed.
|
// invoked (possibly from another goroutine) when the async operation
|
||||||
SetCallback(cb AsyncCallback)
|
// completes. cb is guaranteed to be non-nil by the caller (registry).
|
||||||
|
ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult
|
||||||
}
|
}
|
||||||
|
|
||||||
func ToolToSchema(tool Tool) map[string]any {
|
func ToolToSchema(tool Tool) map[string]any {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
|
@ -24,9 +23,6 @@ type CronTool struct {
|
||||||
executor JobExecutor
|
executor JobExecutor
|
||||||
msgBus *bus.MessageBus
|
msgBus *bus.MessageBus
|
||||||
execTool *ExecTool
|
execTool *ExecTool
|
||||||
channel string
|
|
||||||
chatID string
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCronTool creates a new CronTool
|
// NewCronTool creates a new CronTool
|
||||||
|
|
@ -102,14 +98,6 @@ func (t *CronTool) Parameters() map[string]any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetContext sets the current session context for job creation
|
|
||||||
func (t *CronTool) SetContext(channel, chatID string) {
|
|
||||||
t.mu.Lock()
|
|
||||||
defer t.mu.Unlock()
|
|
||||||
t.channel = channel
|
|
||||||
t.chatID = chatID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute runs the tool with the given arguments
|
// Execute runs the tool with the given arguments
|
||||||
func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
action, ok := args["action"].(string)
|
action, ok := args["action"].(string)
|
||||||
|
|
@ -119,7 +107,7 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case "add":
|
case "add":
|
||||||
return t.addJob(args)
|
return t.addJob(ctx, args)
|
||||||
case "list":
|
case "list":
|
||||||
return t.listJobs()
|
return t.listJobs()
|
||||||
case "remove":
|
case "remove":
|
||||||
|
|
@ -133,11 +121,9 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *CronTool) addJob(args map[string]any) *ToolResult {
|
func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
t.mu.RLock()
|
channel := ToolChannel(ctx)
|
||||||
channel := t.channel
|
chatID := ToolChatID(ctx)
|
||||||
chatID := t.chatID
|
|
||||||
t.mu.RUnlock()
|
|
||||||
|
|
||||||
if channel == "" || chatID == "" {
|
if channel == "" || chatID == "" {
|
||||||
return ErrorResult("no session context (channel/chat_id not set). Use this tool in an active conversation.")
|
return ErrorResult("no session context (channel/chat_id not set). Use this tool in an active conversation.")
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,8 @@ import (
|
||||||
type SendCallback func(channel, chatID, content string) error
|
type SendCallback func(channel, chatID, content string) error
|
||||||
|
|
||||||
type MessageTool struct {
|
type MessageTool struct {
|
||||||
sendCallback SendCallback
|
sendCallback SendCallback
|
||||||
defaultChannel string
|
sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round
|
||||||
defaultChatID string
|
|
||||||
sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMessageTool() *MessageTool {
|
func NewMessageTool() *MessageTool {
|
||||||
|
|
@ -48,10 +46,10 @@ func (t *MessageTool) Parameters() map[string]any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *MessageTool) SetContext(channel, chatID string) {
|
// ResetSentInRound resets the per-round send tracker.
|
||||||
t.defaultChannel = channel
|
// Called by the agent loop at the start of each inbound message processing round.
|
||||||
t.defaultChatID = chatID
|
func (t *MessageTool) ResetSentInRound() {
|
||||||
t.sentInRound.Store(false) // Reset send tracking for new processing round
|
t.sentInRound.Store(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasSentInRound returns true if the message tool sent a message during the current round.
|
// HasSentInRound returns true if the message tool sent a message during the current round.
|
||||||
|
|
@ -73,10 +71,10 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
|
||||||
chatID, _ := args["chat_id"].(string)
|
chatID, _ := args["chat_id"].(string)
|
||||||
|
|
||||||
if channel == "" {
|
if channel == "" {
|
||||||
channel = t.defaultChannel
|
channel = ToolChannel(ctx)
|
||||||
}
|
}
|
||||||
if chatID == "" {
|
if chatID == "" {
|
||||||
chatID = t.defaultChatID
|
chatID = ToolChatID(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
if channel == "" || chatID == "" {
|
if channel == "" || chatID == "" {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import (
|
||||||
|
|
||||||
func TestMessageTool_Execute_Success(t *testing.T) {
|
func TestMessageTool_Execute_Success(t *testing.T) {
|
||||||
tool := NewMessageTool()
|
tool := NewMessageTool()
|
||||||
tool.SetContext("test-channel", "test-chat-id")
|
|
||||||
|
|
||||||
var sentChannel, sentChatID, sentContent string
|
var sentChannel, sentChatID, sentContent string
|
||||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
tool.SetSendCallback(func(channel, chatID, content string) error {
|
||||||
|
|
@ -18,7 +17,7 @@ func TestMessageTool_Execute_Success(t *testing.T) {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
"content": "Hello, world!",
|
"content": "Hello, world!",
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +59,6 @@ func TestMessageTool_Execute_Success(t *testing.T) {
|
||||||
|
|
||||||
func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
|
func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
|
||||||
tool := NewMessageTool()
|
tool := NewMessageTool()
|
||||||
tool.SetContext("default-channel", "default-chat-id")
|
|
||||||
|
|
||||||
var sentChannel, sentChatID string
|
var sentChannel, sentChatID string
|
||||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
tool.SetSendCallback(func(channel, chatID, content string) error {
|
||||||
|
|
@ -69,7 +67,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := WithToolContext(context.Background(), "default-channel", "default-chat-id")
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
"content": "Test message",
|
"content": "Test message",
|
||||||
"channel": "custom-channel",
|
"channel": "custom-channel",
|
||||||
|
|
@ -96,14 +94,13 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
|
||||||
|
|
||||||
func TestMessageTool_Execute_SendFailure(t *testing.T) {
|
func TestMessageTool_Execute_SendFailure(t *testing.T) {
|
||||||
tool := NewMessageTool()
|
tool := NewMessageTool()
|
||||||
tool.SetContext("test-channel", "test-chat-id")
|
|
||||||
|
|
||||||
sendErr := errors.New("network error")
|
sendErr := errors.New("network error")
|
||||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
tool.SetSendCallback(func(channel, chatID, content string) error {
|
||||||
return sendErr
|
return sendErr
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
"content": "Test message",
|
"content": "Test message",
|
||||||
}
|
}
|
||||||
|
|
@ -133,9 +130,8 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
|
||||||
|
|
||||||
func TestMessageTool_Execute_MissingContent(t *testing.T) {
|
func TestMessageTool_Execute_MissingContent(t *testing.T) {
|
||||||
tool := NewMessageTool()
|
tool := NewMessageTool()
|
||||||
tool.SetContext("test-channel", "test-chat-id")
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||||
args := map[string]any{} // content missing
|
args := map[string]any{} // content missing
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
result := tool.Execute(ctx, args)
|
||||||
|
|
@ -151,7 +147,7 @@ func TestMessageTool_Execute_MissingContent(t *testing.T) {
|
||||||
|
|
||||||
func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
|
func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
|
||||||
tool := NewMessageTool()
|
tool := NewMessageTool()
|
||||||
// No SetContext called, so defaultChannel and defaultChatID are empty
|
// No WithToolContext — channel/chatID are empty
|
||||||
|
|
||||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
tool.SetSendCallback(func(channel, chatID, content string) error {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -175,10 +171,9 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
|
||||||
|
|
||||||
func TestMessageTool_Execute_NotConfigured(t *testing.T) {
|
func TestMessageTool_Execute_NotConfigured(t *testing.T) {
|
||||||
tool := NewMessageTool()
|
tool := NewMessageTool()
|
||||||
tool.SetContext("test-channel", "test-chat-id")
|
|
||||||
// No SetSendCallback called
|
// No SetSendCallback called
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
"content": "Test message",
|
"content": "Test message",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,9 @@ func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteWithContext executes a tool with channel/chatID context and optional async callback.
|
// ExecuteWithContext executes a tool with channel/chatID context and optional async callback.
|
||||||
// If the tool implements AsyncTool and a non-nil callback is provided,
|
// If the tool implements AsyncExecutor and a non-nil callback is provided,
|
||||||
// the callback will be set on the tool before execution.
|
// ExecuteAsync is called instead of Execute — the callback is a parameter,
|
||||||
|
// never stored as mutable state on the tool.
|
||||||
func (r *ToolRegistry) ExecuteWithContext(
|
func (r *ToolRegistry) ExecuteWithContext(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
name string,
|
name string,
|
||||||
|
|
@ -69,22 +70,23 @@ func (r *ToolRegistry) ExecuteWithContext(
|
||||||
return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found"))
|
return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// If tool implements ContextualTool, set context
|
// Inject channel/chatID into ctx so tools read them via ToolChannel(ctx)/ToolChatID(ctx).
|
||||||
if contextualTool, ok := tool.(ContextualTool); ok && channel != "" && chatID != "" {
|
// Always inject — tools validate what they require.
|
||||||
contextualTool.SetContext(channel, chatID)
|
ctx = WithToolContext(ctx, channel, chatID)
|
||||||
}
|
|
||||||
|
|
||||||
// If tool implements AsyncTool and callback is provided, set callback
|
// If tool implements AsyncExecutor and callback is provided, use ExecuteAsync.
|
||||||
if asyncTool, ok := tool.(AsyncTool); ok && asyncCallback != nil {
|
// The callback is a call parameter, not mutable state on the tool instance.
|
||||||
asyncTool.SetCallback(asyncCallback)
|
var result *ToolResult
|
||||||
logger.DebugCF("tool", "Async callback injected",
|
start := time.Now()
|
||||||
|
if asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil {
|
||||||
|
logger.DebugCF("tool", "Executing async tool via ExecuteAsync",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"tool": name,
|
"tool": name,
|
||||||
})
|
})
|
||||||
|
result = asyncExec.ExecuteAsync(ctx, args, asyncCallback)
|
||||||
|
} else {
|
||||||
|
result = tool.Execute(ctx, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
duration := time.Since(start)
|
duration := time.Since(start)
|
||||||
|
|
||||||
// Log based on result type
|
// Log based on result type
|
||||||
|
|
|
||||||
|
|
@ -25,24 +25,24 @@ func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]any) *ToolRes
|
||||||
return m.result
|
return m.result
|
||||||
}
|
}
|
||||||
|
|
||||||
type mockCtxTool struct {
|
type mockContextAwareTool struct {
|
||||||
mockRegistryTool
|
mockRegistryTool
|
||||||
channel string
|
lastCtx context.Context
|
||||||
chatID string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockCtxTool) SetContext(channel, chatID string) {
|
func (m *mockContextAwareTool) Execute(ctx context.Context, _ map[string]any) *ToolResult {
|
||||||
m.channel = channel
|
m.lastCtx = ctx
|
||||||
m.chatID = chatID
|
return m.result
|
||||||
}
|
}
|
||||||
|
|
||||||
type mockAsyncRegistryTool struct {
|
type mockAsyncRegistryTool struct {
|
||||||
mockRegistryTool
|
mockRegistryTool
|
||||||
cb AsyncCallback
|
lastCB AsyncCallback
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockAsyncRegistryTool) SetCallback(cb AsyncCallback) {
|
func (m *mockAsyncRegistryTool) ExecuteAsync(_ context.Context, args map[string]any, cb AsyncCallback) *ToolResult {
|
||||||
m.cb = cb
|
m.lastCB = cb
|
||||||
|
return m.result
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
@ -136,34 +136,44 @@ func TestToolRegistry_Execute_NotFound(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) {
|
func TestToolRegistry_ExecuteWithContext_InjectsToolContext(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewToolRegistry()
|
||||||
ct := &mockCtxTool{
|
ct := &mockContextAwareTool{
|
||||||
mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
|
mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
|
||||||
}
|
}
|
||||||
r.Register(ct)
|
r.Register(ct)
|
||||||
|
|
||||||
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil)
|
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil)
|
||||||
|
|
||||||
if ct.channel != "telegram" {
|
if ct.lastCtx == nil {
|
||||||
t.Errorf("expected channel 'telegram', got %q", ct.channel)
|
t.Fatal("expected Execute to be called")
|
||||||
}
|
}
|
||||||
if ct.chatID != "chat-42" {
|
if got := ToolChannel(ct.lastCtx); got != "telegram" {
|
||||||
t.Errorf("expected chatID 'chat-42', got %q", ct.chatID)
|
t.Errorf("expected channel 'telegram', got %q", got)
|
||||||
|
}
|
||||||
|
if got := ToolChatID(ct.lastCtx); got != "chat-42" {
|
||||||
|
t.Errorf("expected chatID 'chat-42', got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) {
|
func TestToolRegistry_ExecuteWithContext_EmptyContext(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewToolRegistry()
|
||||||
ct := &mockCtxTool{
|
ct := &mockContextAwareTool{
|
||||||
mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
|
mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
|
||||||
}
|
}
|
||||||
r.Register(ct)
|
r.Register(ct)
|
||||||
|
|
||||||
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil)
|
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil)
|
||||||
|
|
||||||
if ct.channel != "" || ct.chatID != "" {
|
if ct.lastCtx == nil {
|
||||||
t.Error("SetContext should not be called with empty channel/chatID")
|
t.Fatal("expected Execute to be called")
|
||||||
|
}
|
||||||
|
// Empty values are still injected; tools decide what to do with them.
|
||||||
|
if got := ToolChannel(ct.lastCtx); got != "" {
|
||||||
|
t.Errorf("expected empty channel, got %q", got)
|
||||||
|
}
|
||||||
|
if got := ToolChatID(ct.lastCtx); got != "" {
|
||||||
|
t.Errorf("expected empty chatID, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,14 +189,14 @@ func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
|
||||||
cb := func(_ context.Context, _ *ToolResult) { called = true }
|
cb := func(_ context.Context, _ *ToolResult) { called = true }
|
||||||
|
|
||||||
result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb)
|
result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb)
|
||||||
if at.cb == nil {
|
if at.lastCB == nil {
|
||||||
t.Error("expected SetCallback to have been called")
|
t.Error("expected ExecuteAsync to have received a callback")
|
||||||
}
|
}
|
||||||
if !result.Async {
|
if !result.Async {
|
||||||
t.Error("expected async result")
|
t.Error("expected async result")
|
||||||
}
|
}
|
||||||
|
|
||||||
at.cb(context.Background(), SilentResult("done"))
|
at.lastCB(context.Background(), SilentResult("done"))
|
||||||
if !called {
|
if !called {
|
||||||
t.Error("expected callback to be invoked")
|
t.Error("expected callback to be invoked")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,25 +8,18 @@ import (
|
||||||
|
|
||||||
type SpawnTool struct {
|
type SpawnTool struct {
|
||||||
manager *SubagentManager
|
manager *SubagentManager
|
||||||
originChannel string
|
|
||||||
originChatID string
|
|
||||||
allowlistCheck func(targetAgentID string) bool
|
allowlistCheck func(targetAgentID string) bool
|
||||||
callback AsyncCallback // For async completion notification
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compile-time check: SpawnTool implements AsyncExecutor.
|
||||||
|
var _ AsyncExecutor = (*SpawnTool)(nil)
|
||||||
|
|
||||||
func NewSpawnTool(manager *SubagentManager) *SpawnTool {
|
func NewSpawnTool(manager *SubagentManager) *SpawnTool {
|
||||||
return &SpawnTool{
|
return &SpawnTool{
|
||||||
manager: manager,
|
manager: manager,
|
||||||
originChannel: "cli",
|
|
||||||
originChatID: "direct",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCallback implements AsyncTool interface for async completion notification
|
|
||||||
func (t *SpawnTool) SetCallback(cb AsyncCallback) {
|
|
||||||
t.callback = cb
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *SpawnTool) Name() string {
|
func (t *SpawnTool) Name() string {
|
||||||
return "spawn"
|
return "spawn"
|
||||||
}
|
}
|
||||||
|
|
@ -56,16 +49,21 @@ func (t *SpawnTool) Parameters() map[string]any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *SpawnTool) SetContext(channel, chatID string) {
|
|
||||||
t.originChannel = channel
|
|
||||||
t.originChatID = chatID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
|
func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
|
||||||
t.allowlistCheck = check
|
t.allowlistCheck = check
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
return t.execute(ctx, args, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteAsync implements AsyncExecutor. The callback is passed through to the
|
||||||
|
// subagent manager as a call parameter — never stored on the SpawnTool instance.
|
||||||
|
func (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult {
|
||||||
|
return t.execute(ctx, args, cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SpawnTool) execute(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult {
|
||||||
task, ok := args["task"].(string)
|
task, ok := args["task"].(string)
|
||||||
if !ok || strings.TrimSpace(task) == "" {
|
if !ok || strings.TrimSpace(task) == "" {
|
||||||
return ErrorResult("task is required and must be a non-empty string")
|
return ErrorResult("task is required and must be a non-empty string")
|
||||||
|
|
@ -85,8 +83,20 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul
|
||||||
return ErrorResult("Subagent manager not configured")
|
return ErrorResult("Subagent manager not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read channel/chatID from context (injected by registry).
|
||||||
|
// Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests)
|
||||||
|
// to preserve the same defaults as the original NewSpawnTool constructor.
|
||||||
|
channel := ToolChannel(ctx)
|
||||||
|
if channel == "" {
|
||||||
|
channel = "cli"
|
||||||
|
}
|
||||||
|
chatID := ToolChatID(ctx)
|
||||||
|
if chatID == "" {
|
||||||
|
chatID = "direct"
|
||||||
|
}
|
||||||
|
|
||||||
// Pass callback to manager for async completion notification
|
// Pass callback to manager for async completion notification
|
||||||
result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, t.callback)
|
result, err := t.manager.Spawn(ctx, task, label, agentID, channel, chatID, cb)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err))
|
return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -252,16 +252,12 @@ func (sm *SubagentManager) ListTasks() []*SubagentTask {
|
||||||
// Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion
|
// Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion
|
||||||
// and returns the result directly in the ToolResult.
|
// and returns the result directly in the ToolResult.
|
||||||
type SubagentTool struct {
|
type SubagentTool struct {
|
||||||
manager *SubagentManager
|
manager *SubagentManager
|
||||||
originChannel string
|
|
||||||
originChatID string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSubagentTool(manager *SubagentManager) *SubagentTool {
|
func NewSubagentTool(manager *SubagentManager) *SubagentTool {
|
||||||
return &SubagentTool{
|
return &SubagentTool{
|
||||||
manager: manager,
|
manager: manager,
|
||||||
originChannel: "cli",
|
|
||||||
originChatID: "direct",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,11 +286,6 @@ func (t *SubagentTool) Parameters() map[string]any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *SubagentTool) SetContext(channel, chatID string) {
|
|
||||||
t.originChannel = channel
|
|
||||||
t.originChatID = chatID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
task, ok := args["task"].(string)
|
task, ok := args["task"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -341,13 +332,24 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests)
|
||||||
|
// to preserve the same defaults as the original NewSubagentTool constructor.
|
||||||
|
channel := ToolChannel(ctx)
|
||||||
|
if channel == "" {
|
||||||
|
channel = "cli"
|
||||||
|
}
|
||||||
|
chatID := ToolChatID(ctx)
|
||||||
|
if chatID == "" {
|
||||||
|
chatID = "direct"
|
||||||
|
}
|
||||||
|
|
||||||
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
|
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
|
||||||
Provider: sm.provider,
|
Provider: sm.provider,
|
||||||
Model: sm.defaultModel,
|
Model: sm.defaultModel,
|
||||||
Tools: tools,
|
Tools: tools,
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
LLMOptions: llmOptions,
|
LLMOptions: llmOptions,
|
||||||
}, messages, t.originChannel, t.originChatID)
|
}, messages, channel, chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
|
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,8 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
|
||||||
manager.SetLLMOptions(2048, 0.6)
|
manager.SetLLMOptions(2048, 0.6)
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
tool.SetContext("cli", "direct")
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := WithToolContext(context.Background(), "cli", "direct")
|
||||||
args := map[string]any{"task": "Do something"}
|
args := map[string]any{"task": "Do something"}
|
||||||
result := tool.Execute(ctx, args)
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
|
@ -147,28 +146,14 @@ func TestSubagentTool_Parameters(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSubagentTool_SetContext verifies context setting
|
|
||||||
func TestSubagentTool_SetContext(t *testing.T) {
|
|
||||||
provider := &MockLLMProvider{}
|
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
|
|
||||||
tool := NewSubagentTool(manager)
|
|
||||||
|
|
||||||
tool.SetContext("test-channel", "test-chat")
|
|
||||||
|
|
||||||
// Verify context is set (we can't directly access private fields,
|
|
||||||
// but we can verify it doesn't crash)
|
|
||||||
// The actual context usage is tested in Execute tests
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSubagentTool_Execute_Success tests successful execution
|
// TestSubagentTool_Execute_Success tests successful execution
|
||||||
func TestSubagentTool_Execute_Success(t *testing.T) {
|
func TestSubagentTool_Execute_Success(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
tool.SetContext("telegram", "chat-123")
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := WithToolContext(context.Background(), "telegram", "chat-123")
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
"task": "Write a haiku about coding",
|
"task": "Write a haiku about coding",
|
||||||
"label": "haiku-task",
|
"label": "haiku-task",
|
||||||
|
|
@ -297,12 +282,9 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
// Set context
|
|
||||||
channel := "test-channel"
|
channel := "test-channel"
|
||||||
chatID := "test-chat"
|
chatID := "test-chat"
|
||||||
tool.SetContext(channel, chatID)
|
ctx := WithToolContext(context.Background(), channel, chatID)
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
"task": "Test context passing",
|
"task": "Test context passing",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,19 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Transcriber interface {
|
||||||
|
Name() string
|
||||||
|
Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
type GroqTranscriber struct {
|
type GroqTranscriber struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
apiBase string
|
apiBase string
|
||||||
|
|
@ -152,8 +159,22 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string)
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *GroqTranscriber) IsAvailable() bool {
|
func (t *GroqTranscriber) Name() string {
|
||||||
available := t.apiKey != ""
|
return "groq"
|
||||||
logger.DebugCF("voice", "Checking transcriber availability", map[string]any{"available": available})
|
}
|
||||||
return available
|
|
||||||
|
// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or
|
||||||
|
// nil if no supported transcription provider is configured.
|
||||||
|
func DetectTranscriber(cfg *config.Config) Transcriber {
|
||||||
|
// Direct Groq provider config takes priority.
|
||||||
|
if key := cfg.Providers.Groq.APIKey; key != "" {
|
||||||
|
return NewGroqTranscriber(key)
|
||||||
|
}
|
||||||
|
// Fall back to any model-list entry that uses the groq/ protocol.
|
||||||
|
for _, mc := range cfg.ModelList {
|
||||||
|
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
|
||||||
|
return NewGroqTranscriber(mc.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
160
pkg/voice/transcriber_test.go
Normal file
160
pkg/voice/transcriber_test.go
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
package voice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure GroqTranscriber satisfies the Transcriber interface at compile time.
|
||||||
|
var _ Transcriber = (*GroqTranscriber)(nil)
|
||||||
|
|
||||||
|
func TestGroqTranscriberName(t *testing.T) {
|
||||||
|
tr := NewGroqTranscriber("sk-test")
|
||||||
|
if got := tr.Name(); got != "groq" {
|
||||||
|
t.Errorf("Name() = %q, want %q", got, "groq")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectTranscriber(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg *config.Config
|
||||||
|
wantNil bool
|
||||||
|
wantName string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no config",
|
||||||
|
cfg: &config.Config{},
|
||||||
|
wantNil: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "groq provider key",
|
||||||
|
cfg: &config.Config{
|
||||||
|
Providers: config.ProvidersConfig{
|
||||||
|
Groq: config.ProviderConfig{APIKey: "sk-groq-direct"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantName: "groq",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "groq via model list",
|
||||||
|
cfg: &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{Model: "openai/gpt-4o", APIKey: "sk-openai"},
|
||||||
|
{Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantName: "groq",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "groq model list entry without key is skipped",
|
||||||
|
cfg: &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{Model: "groq/llama-3.3-70b", APIKey: ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantNil: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "provider key takes priority over model list",
|
||||||
|
cfg: &config.Config{
|
||||||
|
Providers: config.ProvidersConfig{
|
||||||
|
Groq: config.ProviderConfig{APIKey: "sk-groq-direct"},
|
||||||
|
},
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantName: "groq",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
tr := DetectTranscriber(tc.cfg)
|
||||||
|
if tc.wantNil {
|
||||||
|
if tr != nil {
|
||||||
|
t.Errorf("DetectTranscriber() = %v, want nil", tr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tr == nil {
|
||||||
|
t.Fatal("DetectTranscriber() = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if got := tr.Name(); got != tc.wantName {
|
||||||
|
t.Errorf("Name() = %q, want %q", got, tc.wantName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranscribe(t *testing.T) {
|
||||||
|
// Write a minimal fake audio file so the transcriber can open and send it.
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
audioPath := filepath.Join(tmpDir, "clip.ogg")
|
||||||
|
if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write fake audio file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/audio/transcriptions" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "Bearer sk-test" {
|
||||||
|
t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(TranscriptionResponse{
|
||||||
|
Text: "hello world",
|
||||||
|
Language: "en",
|
||||||
|
Duration: 1.5,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tr := NewGroqTranscriber("sk-test")
|
||||||
|
tr.apiBase = srv.URL
|
||||||
|
|
||||||
|
resp, err := tr.Transcribe(context.Background(), audioPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Transcribe() error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Text != "hello world" {
|
||||||
|
t.Errorf("Text = %q, want %q", resp.Text, "hello world")
|
||||||
|
}
|
||||||
|
if resp.Language != "en" {
|
||||||
|
t.Errorf("Language = %q, want %q", resp.Language, "en")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("api error", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tr := NewGroqTranscriber("sk-bad")
|
||||||
|
tr.apiBase = srv.URL
|
||||||
|
|
||||||
|
_, err := tr.Transcribe(context.Background(), audioPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-200 response, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file", func(t *testing.T) {
|
||||||
|
tr := NewGroqTranscriber("sk-test")
|
||||||
|
_, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing file, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue