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
33 changes: 32 additions & 1 deletion cmd/picoclaw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ package main
import (
"fmt"
"os"
"strings"

"github.com/spf13/cobra"
"golang.org/x/term"

"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
Expand Down Expand Up @@ -64,8 +66,37 @@ const (
"\033[0m\r\n"
)

const noBannerEnv = "PICOCLAW_NO_BANNER"

func bannerDisabledByEnv() bool {
value := strings.TrimSpace(strings.ToLower(os.Getenv(noBannerEnv)))
switch value {
case "", "0", "false", "no", "off":
return false
default:
return true
}
}

func shouldPrintBanner(args []string, stdoutIsTerminal bool) bool {
if bannerDisabledByEnv() || !stdoutIsTerminal {
return false
}

if len(args) > 1 {
switch args[1] {
case "completion", cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd:
return false
}
}

return true
}

func main() {
fmt.Printf("%s", banner)
if shouldPrintBanner(os.Args, term.IsTerminal(int(os.Stdout.Fd()))) {
fmt.Printf("%s", banner)
}
cmd := NewPicoclawCommand()
if err := cmd.Execute(); err != nil {
os.Exit(1)
Expand Down
44 changes: 44 additions & 0 deletions cmd/picoclaw/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"slices"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -56,3 +57,46 @@ func TestNewPicoclawCommand(t *testing.T) {
assert.False(t, subcmd.Hidden)
}
}

func TestShouldPrintBanner(t *testing.T) {
t.Run("interactive command prints banner", func(t *testing.T) {
t.Setenv(noBannerEnv, "")
assert.True(t, shouldPrintBanner([]string{"picoclaw", "agent"}, true))
})

t.Run("redirected stdout suppresses banner", func(t *testing.T) {
t.Setenv(noBannerEnv, "")
assert.False(t, shouldPrintBanner([]string{"picoclaw", "agent"}, false))
})

t.Run("completion command suppresses banner", func(t *testing.T) {
t.Setenv(noBannerEnv, "")
assert.False(t, shouldPrintBanner([]string{"picoclaw", "completion", "zsh"}, true))
assert.False(t, shouldPrintBanner([]string{"picoclaw", cobra.ShellCompRequestCmd}, true))
assert.False(t, shouldPrintBanner([]string{"picoclaw", cobra.ShellCompNoDescRequestCmd}, true))
})

t.Run("env disables banner", func(t *testing.T) {
t.Setenv(noBannerEnv, "1")
assert.False(t, shouldPrintBanner([]string{"picoclaw", "agent"}, true))
})

t.Run("truthy env values disable banner after normalization", func(t *testing.T) {
for _, value := range []string{"true", "yes", "on", " TrUe ", "\tON\n"} {
t.Run(fmt.Sprintf("%q", value), func(t *testing.T) {
t.Setenv(noBannerEnv, value)
assert.False(t, shouldPrintBanner([]string{"picoclaw", "agent"}, true))
})
}
})

t.Run("root command still prints banner in terminal", func(t *testing.T) {
t.Setenv(noBannerEnv, "")
assert.True(t, shouldPrintBanner([]string{"picoclaw"}, true))
})

t.Run("unknown subcommand still prints banner in terminal", func(t *testing.T) {
t.Setenv(noBannerEnv, "")
assert.True(t, shouldPrintBanner([]string{"picoclaw", "unknown"}, true))
})
}
7 changes: 7 additions & 0 deletions pkg/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,13 @@ func registerSharedTools(
if cfg.Tools.IsToolEnabled("subagent") {
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
// Set model resolver so spawn can use target agent's model
subagentManager.SetModelResolver(func(targetAgentID string) string {
if targetAgent, ok := registry.GetAgent(targetAgentID); ok {
return targetAgent.Model
}
return ""
})
spawnTool := tools.NewSpawnTool(subagentManager)
currentAgentID := agentID
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
Expand Down
4 changes: 4 additions & 0 deletions pkg/providers/openai_compat/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,10 @@ func normalizeModel(model, apiBase string) string {
return model
}

if strings.HasPrefix(strings.ToLower(model), "openrouter/") {
return model
}

if strings.Contains(strings.ToLower(apiBase), "openrouter.ai") {
return model
}
Expand Down
3 changes: 3 additions & 0 deletions pkg/providers/openai_compat/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,9 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) {
if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" {
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
}
if got := normalizeModel("openrouter/free", "https://gateway.example.com/v1"); got != "openrouter/free" {
t.Fatalf("normalizeModel(openrouter custom api_base) = %q, want %q", got, "openrouter/free")
}
if got := normalizeModel("vivgrid/managed", "https://api.vivgrid.com/v1"); got != "managed" {
t.Fatalf("normalizeModel(vivgrid) = %q, want %q", got, "managed")
}
Expand Down
35 changes: 35 additions & 0 deletions pkg/tools/spawn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,38 @@ func TestSpawnTool_Execute_NilManager(t *testing.T) {
t.Errorf("Error message should mention manager not configured, got: %s", result.ForLLM)
}
}

func TestSubagentManager_ModelResolver(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "default-model", "/tmp/test")

// Set up model resolver
resolvedAgentID := ""
manager.SetModelResolver(func(agentID string) string {
resolvedAgentID = agentID
if agentID == "premium-agent" {
return "gpt-4"
}
return ""
})

// Verify resolver is set
if manager.modelResolver == nil {
t.Fatal("Model resolver should be set")
}

// Test resolver is called with correct agent ID
result := manager.modelResolver("premium-agent")
if resolvedAgentID != "premium-agent" {
t.Errorf("Expected resolver to be called with 'premium-agent', got '%s'", resolvedAgentID)
}
if result != "gpt-4" {
t.Errorf("Expected 'gpt-4', got '%s'", result)
}

// Test fallback for unknown agent
result = manager.modelResolver("unknown-agent")
if result != "" {
t.Errorf("Expected empty string for unknown agent, got '%s'", result)
}
}
25 changes: 24 additions & 1 deletion pkg/tools/subagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ type SubagentManager struct {
hasMaxTokens bool
hasTemperature bool
nextID int
// modelResolver resolves agentID to model name.
// Returns empty string if agent not found (falls back to defaultModel).
modelResolver func(agentID string) string
}

func NewSubagentManager(
Expand Down Expand Up @@ -61,6 +64,16 @@ func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
sm.hasTemperature = true
}

// SetModelResolver sets a function to resolve agentID to model name.
// When spawn is called with agent_id, this resolver is used to get the
// target agent's configured model. If the resolver returns empty string
// or is not set, falls back to the defaultModel.
func (sm *SubagentManager) SetModelResolver(resolver func(agentID string) string) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.modelResolver = resolver
}

// 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 @@ -147,8 +160,18 @@ After completing the task, provide a clear summary of what was done.`
temperature := sm.temperature
hasMaxTokens := sm.hasMaxTokens
hasTemperature := sm.hasTemperature
modelResolver := sm.modelResolver
defaultModel := sm.defaultModel
sm.mu.RUnlock()

// Resolve target agent model if agentID is specified
model := defaultModel
if task.AgentID != "" && modelResolver != nil {
if resolvedModel := modelResolver(task.AgentID); resolvedModel != "" {
model = resolvedModel
}
}

var llmOptions map[string]any
if hasMaxTokens || hasTemperature {
llmOptions = map[string]any{}
Expand All @@ -162,7 +185,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