Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
0142783
fix(openai_compat): omit empty content when tool_calls present
securityguy Mar 12, 2026
37d6133
feat(openai_compat): add strict_compat option to strip non-standard f…
securityguy Mar 13, 2026
493e8e8
fix(claude_cli): surface stdout in error when CLI exits non-zero
securityguy Mar 13, 2026
2ca6110
docs: document claude-cli and codex-cli providers in README
securityguy Mar 13, 2026
ab1c122
docs: add pending contributions table to README
securityguy Mar 16, 2026
d68d709
docs: replace README with minimal dev-fork version
securityguy Mar 16, 2026
ade27cc
Update README.md
securityguy Mar 16, 2026
c97f6f6
feat(channels): support multiple named Telegram bots
securityguy Mar 15, 2026
40ef4a5
docs(config): add telegram_bots and bindings examples to config.examp…
securityguy Mar 15, 2026
be68a49
chore: remove dependabot.yml (dev fork, not needed)
securityguy Mar 16, 2026
64b5b37
Update README.md
securityguy Mar 16, 2026
da96f64
feat(providers): add gemini-cli provider
securityguy Mar 16, 2026
2ac7864
docs: add configuration guide for CLI providers and multi-bot Telegram
securityguy Mar 16, 2026
717bb76
Merge feat/gemini-cli-provider into main
securityguy Mar 16, 2026
0c856de
docs: use Alice and Bob as example agent names throughout
securityguy Mar 16, 2026
2a88370
fix(agent): dispatch per-candidate provider in fallback chain
securityguy Mar 16, 2026
8938eb7
Merge fix/provider-dispatch: per-candidate provider dispatch
securityguy Mar 16, 2026
29ec94e
docs: add PR #1637 to contributions table
securityguy Mar 16, 2026
8ac3006
Update README.md
securityguy Mar 17, 2026
da7a4e4
fix(providers): robust CLI tool call extraction and mixed response ha…
securityguy Mar 19, 2026
249a63b
fix(launcher): recognise gemini-cli as a credential-free CLI provider
securityguy Mar 19, 2026
afa7fe3
fix(launcher): detect and display externally-managed gateway as running
securityguy Mar 19, 2026
86e68a2
Merge fix/launcher-detect-external-gateway: detect external gateway
securityguy Mar 19, 2026
1010821
docs: add PRs #1810 and #1811 to contributions table
securityguy Mar 19, 2026
82e688a
fix(claude-cli): pass system prompt via stdin instead of CLI argument
securityguy Mar 19, 2026
f067024
Merge fix/claude-cli-system-prompt-stdin: pass system prompt via stdin
securityguy Mar 19, 2026
7d5745c
docs: add PR #1812 to contributions table
securityguy Mar 19, 2026
837304c
Merge fix/cli-tool-call-extraction: robust CLI tool call extraction a…
securityguy Mar 19, 2026
cab1d19
docs: add PR #1813 to contributions table
securityguy Mar 19, 2026
eef15f9
fix(subagent): dispatch subagents through per-agent provider, enforce…
securityguy Mar 19, 2026
7f8efb9
feat(subagent): attribute subagent responses with agent name
securityguy Mar 20, 2026
fe32c4c
Merge fix/subagent-provider-dispatch: per-agent subagent dispatch, al…
securityguy Mar 20, 2026
3aafbca
docs: add PR #1814 to contributions table
securityguy Mar 20, 2026
99715e4
fix(cron): show all payload fields in cron list output
securityguy Mar 20, 2026
035affa
Merge fix/cron-list-show-all-fields: show all payload fields in cron …
securityguy Mar 20, 2026
74740f9
docs: add PR #1816 to contributions table
securityguy Mar 20, 2026
de1342f
fix(cron): set peer on inbound message so channel bindings route corr…
securityguy Mar 20, 2026
9cff725
fix(cron): publish agent response to bus after ProcessDirectWithChannel
securityguy Mar 20, 2026
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
27 changes: 0 additions & 27 deletions .github/dependabot.yml

This file was deleted.

1,624 changes: 107 additions & 1,517 deletions README.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion cmd/picoclaw/internal/agent/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
cfg.Agents.Defaults.ModelName = modelID
}

dispatcher := providers.NewProviderDispatcher(cfg)
msgBus := bus.NewMessageBus()
defer msgBus.Close()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider, dispatcher)
defer agentLoop.Close()

// Print agent startup info (only for interactive mode)
Expand Down
16 changes: 15 additions & 1 deletion cmd/picoclaw/internal/cron/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"github.com/sipeed/picoclaw/pkg/cron"
)

const maxMessageDisplay = 80

func cronListCmd(storePath string) {
cs := cron.NewCronService(storePath, nil)
jobs := cs.ListJobs(true) // Show all jobs, including disabled
Expand Down Expand Up @@ -41,8 +43,20 @@ func cronListCmd(storePath string) {

fmt.Printf(" %s (%s)\n", job.Name, job.ID)
fmt.Printf(" Schedule: %s\n", schedule)
fmt.Printf(" Status: %s\n", status)
fmt.Printf(" Status: %s\n", status)
fmt.Printf(" Next run: %s\n", nextRun)
fmt.Printf(" Channel: %s\n", job.Payload.Channel)
fmt.Printf(" To: %s\n", job.Payload.To)
fmt.Printf(" Deliver: %v\n", job.Payload.Deliver)
if job.Payload.Command != "" {
fmt.Printf(" Command: %s\n", job.Payload.Command)
} else {
msg := job.Payload.Message
if len(msg) > maxMessageDisplay {
msg = msg[:maxMessageDisplay] + "..."
}
fmt.Printf(" Message: %s\n", msg)
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion cmd/picoclaw/internal/gateway/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,9 @@ func gatewayCmd(debug bool) error {
cfg.Agents.Defaults.ModelName = modelID
}

dispatcher := providers.NewProviderDispatcher(cfg)
msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider, dispatcher)

// Print agent startup info
fmt.Println("\n📦 Agent Status:")
Expand Down
30 changes: 30 additions & 0 deletions config/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@
"summarize_token_percent": 75
}
},
"bindings": [
{
"_comment": "Route each named Telegram bot to a specific agent. Remove this section if you only use one agent.",
"agent_id": "alice",
"match": { "channel": "telegram-alice" }
},
{
"agent_id": "bob",
"match": { "channel": "telegram-bob" }
}
],
"model_list": [
{
"model_name": "gpt-5.4",
Expand Down Expand Up @@ -83,6 +94,25 @@
],
"reasoning_channel_id": ""
},
"telegram_bots": [
{
"_comment": "Multiple named Telegram bots — each creates a separate channel (e.g. telegram-alice). Use bindings to route each bot to a different agent.",
"id": "alice",
"enabled": false,
"token": "ALICE_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"typing": { "enabled": true },
"placeholder": { "enabled": true, "text": "Thinking... 💭" }
},
{
"id": "bob",
"enabled": false,
"token": "BOB_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"typing": { "enabled": true },
"placeholder": { "enabled": true, "text": "Thinking... 💭" }
}
],
"discord": {
"enabled": false,
"token": "YOUR_DISCORD_BOT_TOKEN",
Expand Down
72 changes: 64 additions & 8 deletions pkg/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type AgentLoop struct {
mu sync.RWMutex
// Track active requests for safe provider cleanup
activeRequests sync.WaitGroup
dispatcher *providers.ProviderDispatcher
}

// processOptions configures how a message is processed
Expand Down Expand Up @@ -80,16 +81,17 @@ func NewAgentLoop(
cfg *config.Config,
msgBus *bus.MessageBus,
provider providers.LLMProvider,
dispatcher *providers.ProviderDispatcher,
) *AgentLoop {
registry := NewAgentRegistry(cfg, provider)

// Register shared tools to all agents
registerSharedTools(cfg, msgBus, registry, provider)

// Set up shared fallback chain
cooldown := providers.NewCooldownTracker()
fallbackChain := providers.NewFallbackChain(cooldown)

// Register shared tools to all agents
registerSharedTools(cfg, msgBus, registry, provider, dispatcher, fallbackChain)

// Create state manager using default agent's workspace for channel recording
defaultAgent := registry.GetDefaultAgent()
var stateManager *state.Manager
Expand All @@ -105,6 +107,7 @@ func NewAgentLoop(
summarizing: sync.Map{},
fallback: fallbackChain,
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
dispatcher: dispatcher,
}

return al
Expand All @@ -116,6 +119,8 @@ func registerSharedTools(
msgBus *bus.MessageBus,
registry *AgentRegistry,
provider providers.LLMProvider,
dispatcher *providers.ProviderDispatcher,
fallbackChain *providers.FallbackChain,
) {
for _, agentID := range registry.ListAgentIDs() {
agent, ok := registry.GetAgent(agentID)
Expand Down Expand Up @@ -225,10 +230,31 @@ func registerSharedTools(
// Spawn tool with allowlist checker
if cfg.Tools.IsToolEnabled("spawn") {
if cfg.Tools.IsToolEnabled("subagent") {
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
currentAgentID := agentID
// Build a resolver so the subagent manager can look up any target
// agent's candidates without importing the agent package from tools.
candidateResolver := func(targetAgentID string) ([]providers.FallbackCandidate, bool) {
target, ok := registry.GetAgent(targetAgentID)
if !ok {
return nil, false
}
if len(target.Candidates) == 0 {
return nil, false
}
return target.Candidates, true
}
subagentManager := tools.NewSubagentManager(tools.SubagentManagerConfig{
Provider: provider,
DefaultModel: agent.Model,
Workspace: agent.Workspace,
Dispatcher: dispatcher,
Fallback: fallbackChain,
SelfCandidates: agent.Candidates,
CallerAgentID: currentAgentID,
CandidateResolver: candidateResolver,
})
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
spawnTool := tools.NewSpawnTool(subagentManager)
currentAgentID := agentID
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
})
Expand Down Expand Up @@ -404,8 +430,10 @@ func (al *AgentLoop) ReloadProviderAndConfig(
return fmt.Errorf("context canceled after registry creation: %w", err)
}

// Ensure shared tools are re-registered on the new registry
registerSharedTools(cfg, al.bus, registry, provider)
// Ensure shared tools are re-registered on the new registry.
// Build a fresh fallback chain for the new registry's subagent managers.
newFallbackChain := providers.NewFallbackChain(providers.NewCooldownTracker())
registerSharedTools(cfg, al.bus, registry, provider, al.dispatcher, newFallbackChain)

// Atomically swap the config and registry under write lock
// This ensures readers see a consistent pair
Expand All @@ -421,6 +449,11 @@ func (al *AgentLoop) ReloadProviderAndConfig(

al.mu.Unlock()

// Flush the dispatcher cache so stale providers are evicted on config reload.
if al.dispatcher != nil {
al.dispatcher.Flush(cfg)
}

// Close old provider after releasing the lock
// This prevents blocking readers while closing
if oldProvider, ok := extractProvider(oldRegistry); ok {
Expand Down Expand Up @@ -646,6 +679,12 @@ func (al *AgentLoop) ProcessDirectWithChannel(
Content: content,
SessionKey: sessionKey,
}
// Set peer so channel-based bindings (e.g. a specific Slack channel mapped
// to a named agent) are matched by the route resolver, exactly as they are
// for live inbound messages.
if chatID != "" && chatID != "direct" {
msg.Peer = bus.Peer{Kind: "channel", ID: chatID}
}

return al.processMessage(ctx, msg)
}
Expand Down Expand Up @@ -1068,7 +1107,12 @@ func (al *AgentLoop) runLLMIteration(
fbResult, fbErr := al.fallback.Execute(
ctx,
activeCandidates,
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) {
if al.dispatcher != nil {
if p, err := al.dispatcher.Get(providerName, model); err == nil {
return p.Chat(ctx, messages, providerToolDefs, model, llmOpts)
}
}
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, llmOpts)
},
)
Expand Down Expand Up @@ -1218,6 +1262,18 @@ func (al *AgentLoop) runLLMIteration(
"iteration": iteration,
})

// If the LLM returned both text content and tool calls, publish the
// text to the user immediately so it is visible before tool execution.
if response.Content != "" && opts.Channel != "" {
pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
_ = al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: response.Content,
})
pubCancel()
}

// Build assistant message with tool calls
assistantMsg := providers.Message{
Role: "assistant",
Expand Down
Loading