Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,534 changes: 1,534 additions & 0 deletions README.es.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
</p>

[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) | **Français**
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) | **Français** | [Español](README.es.md)
</div>

---
Expand Down
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
</p>

[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) | [Español](README.es.md)

</div>

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>

[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
[Español](README.es.md) | [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**

</div>

Expand Down
2 changes: 1 addition & 1 deletion README.pt-br.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
</p>

[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) | [Español](README.es.md)
</div>

---
Expand Down
2 changes: 1 addition & 1 deletion README.vi.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
</p>

[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [English](README.md)
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [English](README.md) | [Español](README.es.md)
</div>

---
Expand Down
2 changes: 1 addition & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
</p>

**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) | [Español](README.es.md)

</div>

Expand Down
6 changes: 6 additions & 0 deletions config/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@
"model": "openai/gpt-5.4",
"api_key": "sk-key2",
"api_base": "https://api2.example.com/v1"
},
{
"model_name": "MiniMax-M2.5",
"model": "modelscope/MiniMax/MiniMax-M2.5",
"api_key": "sk-key3",
"api_base": "https://api-inference.modelscope.cn/v1"
}
],
"channels": {
Expand Down
11 changes: 11 additions & 0 deletions pkg/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,17 @@ func registerSharedTools(
if cfg.Tools.IsToolEnabled("subagent") {
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
subagentManager.SetAgentModelResolver(func(targetAgentID string) (string, bool) {
target, ok := registry.GetAgent(targetAgentID)
if !ok {
return "", false
}
model := strings.TrimSpace(target.Model)
if model == "" {
return "", false
}
return model, true
})
spawnTool := tools.NewSpawnTool(subagentManager)
currentAgentID := agentID
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
Expand Down
9 changes: 9 additions & 0 deletions pkg/cron/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"log"
"os"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -286,6 +287,14 @@ func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int6

// Use gronx to calculate next run time
now := time.UnixMilli(nowMS)
if tz := strings.TrimSpace(schedule.TZ); tz != "" {
loc, err := time.LoadLocation(tz)
if err != nil {
log.Printf("[cron] failed to load timezone %q: %v", tz, err)
} else {
now = now.In(loc)
}
}
nextTime, err := gronx.NextTickAfter(schedule.Expr, now, false)
if err != nil {
log.Printf("[cron] failed to compute next run for expr '%s': %v", schedule.Expr, err)
Expand Down
43 changes: 43 additions & 0 deletions pkg/cron/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"path/filepath"
"runtime"
"testing"
"time"
)

func TestSaveStore_FilePermissions(t *testing.T) {
Expand Down Expand Up @@ -33,6 +34,48 @@ func TestSaveStore_FilePermissions(t *testing.T) {
}
}

func TestComputeNextRun_UsesScheduleTimeZone(t *testing.T) {
cs := NewCronService(filepath.Join(t.TempDir(), "cron", "jobs.json"), nil)
now := time.Date(2026, time.March, 13, 12, 30, 0, 0, time.UTC).UnixMilli()
baseline := cs.computeNextRun(&CronSchedule{Kind: "cron", Expr: "0 9 * * *"}, now)
if baseline == nil {
t.Fatal("baseline computeNextRun() returned nil")
}

t.Run("uses explicit timezone", func(t *testing.T) {
next := cs.computeNextRun(&CronSchedule{
Kind: "cron",
Expr: "0 9 * * *",
TZ: "America/New_York",
}, now)
if next == nil {
t.Fatal("computeNextRun() returned nil")
}

wantNext := time.Date(2026, time.March, 13, 13, 0, 0, 0, time.UTC).UnixMilli()
if *next != wantNext {
t.Fatalf("computeNextRun() = %d, want %d", *next, wantNext)
}
if *next == *baseline {
t.Fatal("explicit timezone should change the computed next run for this fixture")
}
})

t.Run("falls back to baseline on invalid timezone", func(t *testing.T) {
next := cs.computeNextRun(&CronSchedule{
Kind: "cron",
Expr: "0 9 * * *",
TZ: "Mars/OlympusMons",
}, now)
if next == nil {
t.Fatal("computeNextRun() returned nil")
}
if *next != *baseline {
t.Fatalf("computeNextRun() = %d, want baseline %d", *next, *baseline)
}
})
}

func int64Ptr(v int64) *int64 {
return &v
}
2 changes: 2 additions & 0 deletions pkg/providers/openai_compat/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,8 @@ func normalizeModel(model, apiBase string) string {

if strings.Contains(strings.ToLower(apiBase), "openrouter.ai") {
return model
} else if strings.Contains(strings.ToLower(apiBase), "api-inference.modelscope.cn") {
return model
}

prefix := strings.ToLower(before)
Expand Down
43 changes: 43 additions & 0 deletions pkg/tools/spawn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"strings"
"testing"
"time"
)

func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
Expand Down Expand Up @@ -77,3 +78,45 @@ func TestSpawnTool_Execute_NilManager(t *testing.T) {
t.Errorf("Error message should mention manager not configured, got: %s", result.ForLLM)
}
}

func TestSpawnTool_ExecuteAsync_UsesTargetAgentModel(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "caller-model", "/tmp/test")
manager.SetAgentModelResolver(func(agentID string) (string, bool) {
if agentID == "analyst" {
return "target-model", true
}
return "", false
})
tool := NewSpawnTool(manager)

done := make(chan struct{})
ctx := WithToolContext(context.Background(), "cli", "direct")
args := map[string]any{
"task": "Write a haiku about coding",
"agent_id": "analyst",
}

result := tool.ExecuteAsync(ctx, args, func(context.Context, *ToolResult) {
close(done)
})
if result == nil {
t.Fatal("Result should not be nil")
}
if result.IsError {
t.Fatalf("Expected success for valid task, got error: %s", result.ForLLM)
}
if !result.Async {
t.Fatal("SpawnTool should return async result")
}

select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("spawn callback was not invoked")
}

if provider.lastModel != "target-model" {
t.Fatalf("lastModel = %q, want %q", provider.lastModel, "target-model")
}
}
19 changes: 18 additions & 1 deletion pkg/tools/subagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tools
import (
"context"
"fmt"
"strings"
"sync"
"time"

Expand All @@ -26,6 +27,7 @@ type SubagentManager struct {
mu sync.RWMutex
provider providers.LLMProvider
defaultModel string
agentModelFor func(string) (string, bool)
workspace string
tools *ToolRegistry
maxIterations int
Expand Down Expand Up @@ -61,6 +63,13 @@ func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
sm.hasTemperature = true
}

// SetAgentModelResolver resolves the effective model for a targeted subagent.
func (sm *SubagentManager) SetAgentModelResolver(resolve func(string) (string, bool)) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.agentModelFor = resolve
}

// SetTools sets the tool registry for subagent execution.
// If not set, subagent will have access to the provided tools.
func (sm *SubagentManager) SetTools(tools *ToolRegistry) {
Expand Down Expand Up @@ -142,13 +151,21 @@ After completing the task, provide a clear summary of what was done.`
// Run tool loop with access to tools
sm.mu.RLock()
tools := sm.tools
model := sm.defaultModel
agentModelFor := sm.agentModelFor
maxIter := sm.maxIterations
maxTokens := sm.maxTokens
temperature := sm.temperature
hasMaxTokens := sm.hasMaxTokens
hasTemperature := sm.hasTemperature
sm.mu.RUnlock()

if task.AgentID != "" && agentModelFor != nil {
if resolvedModel, ok := agentModelFor(task.AgentID); ok && strings.TrimSpace(resolvedModel) != "" {
model = strings.TrimSpace(resolvedModel)
}
}

var llmOptions map[string]any
if hasMaxTokens || hasTemperature {
llmOptions = map[string]any{}
Expand All @@ -162,7 +179,7 @@ After completing the task, provide a clear summary of what was done.`

loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
Provider: sm.provider,
Model: sm.defaultModel,
Model: model,
Tools: tools,
MaxIterations: maxIter,
LLMOptions: llmOptions,
Expand Down
30 changes: 30 additions & 0 deletions pkg/tools/subagent_tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
// MockLLMProvider is a test implementation of LLMProvider
type MockLLMProvider struct {
lastOptions map[string]any
lastModel string
}

func (m *MockLLMProvider) Chat(
Expand All @@ -21,6 +22,7 @@ func (m *MockLLMProvider) Chat(
options map[string]any,
) (*providers.LLMResponse, error) {
m.lastOptions = options
m.lastModel = model
// Find the last user message to generate a response
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
Expand Down Expand Up @@ -69,6 +71,34 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
}
}

func TestSubagentManager_RunTask_UsesResolvedTargetAgentModel(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "caller-model", "/tmp/test")
manager.SetAgentModelResolver(func(agentID string) (string, bool) {
if agentID == "analyst" {
return "target-model", true
}
return "", false
})

task := &SubagentTask{
ID: "subagent-1",
Task: "Do something",
AgentID: "analyst",
OriginChannel: "cli",
OriginChatID: "direct",
}

manager.runTask(context.Background(), task, nil)

if provider.lastModel != "target-model" {
t.Fatalf("lastModel = %q, want %q", provider.lastModel, "target-model")
}
if task.Status != "completed" {
t.Fatalf("task.Status = %q, want %q", task.Status, "completed")
}
}

// TestSubagentTool_Name verifies tool name
func TestSubagentTool_Name(t *testing.T) {
provider := &MockLLMProvider{}
Expand Down
2 changes: 2 additions & 0 deletions web/frontend/src/components/models/provider-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const PROVIDER_ICON_SLUGS: Record<string, string> = {
ollama: "ollama",
mistral: "mistralai",
zhipu: "zhipu",
modelscope: "modelscope",
}

const PROVIDER_DOMAINS: Record<string, string> = {
Expand All @@ -37,6 +38,7 @@ const PROVIDER_DOMAINS: Record<string, string> = {
avian: "avian.io",
vllm: "vllm.ai",
zhipu: "zhipuai.cn",
modelscope: "modelscope.cn",
}

interface ProviderIconProps {
Expand Down