2026-02-25 07:47:45 +00:00
|
|
|
package agent
|
2026-02-18 17:03:34 +00:00
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bufio"
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
|
2026-03-13 09:58:34 +00:00
|
|
|
"github.com/ergochat/readline"
|
2026-02-20 18:03:11 +00:00
|
|
|
|
2026-02-25 07:47:45 +00:00
|
|
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
2026-02-18 17:03:34 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/agent"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-25 07:47:45 +00:00
|
|
|
func agentCmd(message, sessionKey, model string, debug bool) error {
|
|
|
|
|
if sessionKey == "" {
|
|
|
|
|
sessionKey = "cli:default"
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-25 07:47:45 +00:00
|
|
|
cfg, err := internal.LoadConfig()
|
2026-02-18 17:03:34 +00:00
|
|
|
if err != nil {
|
2026-02-25 07:47:45 +00:00
|
|
|
return fmt.Errorf("error loading config: %w", err)
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-25 13:31:07 +00:00
|
|
|
logger.ConfigureFromEnv()
|
|
|
|
|
|
2026-03-21 05:18:25 +00:00
|
|
|
if debug {
|
|
|
|
|
logger.SetLevel(logger.DEBUG)
|
|
|
|
|
fmt.Println("🔍 Debug mode enabled")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 07:47:45 +00:00
|
|
|
if model != "" {
|
|
|
|
|
cfg.Agents.Defaults.ModelName = model
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
provider, modelID, err := providers.CreateProvider(cfg)
|
|
|
|
|
if err != nil {
|
2026-02-25 07:47:45 +00:00
|
|
|
return fmt.Errorf("error creating provider: %w", err)
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
2026-02-25 07:47:45 +00:00
|
|
|
|
2026-02-18 17:03:34 +00:00
|
|
|
// Use the resolved model ID from provider creation
|
|
|
|
|
if modelID != "" {
|
2026-02-23 08:55:06 +00:00
|
|
|
cfg.Agents.Defaults.ModelName = modelID
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msgBus := bus.NewMessageBus()
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
defer msgBus.Close()
|
2026-02-18 17:03:34 +00:00
|
|
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
feat(session): integrate JSONL persistence into agent loop (#1170)
* feat(session): add SessionStore interface and JSONL backend adapter
Extract a SessionStore interface from the methods the agent loop uses
(AddMessage, GetHistory, SetSummary, TruncateHistory, Save, etc.).
Both SessionManager and the new JSONLBackend satisfy this interface,
allowing the persistence layer to be swapped transparently.
JSONLBackend wraps memory.Store and maps its error-returning API to
the fire-and-forget contract that the agent loop expects — write
errors are logged, reads return empty defaults on failure. Save()
triggers compaction to reclaim space after logical truncation.
Part of #1169
* test(session): add JSONLBackend integration tests
8 tests covering the full SessionStore contract through the JSONL
backend: message roundtrip, tool calls, summary, truncation with
compaction, history replacement, empty sessions, session isolation,
and the complete summarization flow (SetSummary → TruncateHistory →
Save).
Includes compile-time interface satisfaction checks for both
SessionManager and JSONLBackend.
Part of #1169
* feat(agent): wire JSONL session store into agent loop
Replace the concrete *SessionManager field with the SessionStore
interface and initialize the JSONL backend by default. Legacy .json
session files are auto-migrated on first startup. Falls back to
SessionManager if the JSONL store cannot be initialized.
The agent loop code (loop.go) requires zero changes — all method
calls work identically through the interface.
Closes #1169
* fix(session): propagate compact error from Save
Save() was swallowing the error returned by Compact and always
returning nil. Callers checking Save's return value would never
see a compaction failure. Return the error directly so the agent
loop can log or handle it as needed.
* feat(session): add Close to SessionStore interface
Add Close() error to SessionStore so callers can release resources
through the interface. JSONLBackend already had Close; this adds
a no-op implementation to SessionManager for compatibility.
* fix(session): close session stores on shutdown and harden migration
- Add Close() to AgentInstance, AgentRegistry, and AgentLoop so JSONL
file handles are released during gateway shutdown and CLI exit.
- Fall back to SessionManager when migration fails, preventing a split
state where some sessions live in JSONL and others remain in JSON.
- Add defer agentLoop.Close() in the CLI agent command path.
- Document SessionStore interface methods (fire-and-forget contract).
2026-03-10 07:14:09 +00:00
|
|
|
defer agentLoop.Close()
|
2026-02-18 17:03:34 +00:00
|
|
|
|
|
|
|
|
// Print agent startup info (only for interactive mode)
|
|
|
|
|
startupInfo := agentLoop.GetStartupInfo()
|
|
|
|
|
logger.InfoCF("agent", "Agent initialized",
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
|
|
|
|
"tools_count": startupInfo["tools"].(map[string]any)["count"],
|
|
|
|
|
"skills_total": startupInfo["skills"].(map[string]any)["total"],
|
|
|
|
|
"skills_available": startupInfo["skills"].(map[string]any)["available"],
|
2026-02-18 17:03:34 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if message != "" {
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
|
|
|
|
|
if err != nil {
|
2026-02-25 07:47:45 +00:00
|
|
|
return fmt.Errorf("error processing message: %w", err)
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
2026-02-25 07:47:45 +00:00
|
|
|
fmt.Printf("\n%s %s\n", internal.Logo, response)
|
|
|
|
|
return nil
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
2026-02-25 07:47:45 +00:00
|
|
|
|
|
|
|
|
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", internal.Logo)
|
|
|
|
|
interactiveMode(agentLoop, sessionKey)
|
|
|
|
|
|
|
|
|
|
return nil
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
2026-02-25 07:47:45 +00:00
|
|
|
prompt := fmt.Sprintf("%s You: ", internal.Logo)
|
2026-02-18 17:03:34 +00:00
|
|
|
|
|
|
|
|
rl, err := readline.NewEx(&readline.Config{
|
|
|
|
|
Prompt: prompt,
|
|
|
|
|
HistoryFile: filepath.Join(os.TempDir(), ".picoclaw_history"),
|
|
|
|
|
HistoryLimit: 100,
|
|
|
|
|
InterruptPrompt: "^C",
|
|
|
|
|
EOFPrompt: "exit",
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
fmt.Printf("Error initializing readline: %v\n", err)
|
|
|
|
|
fmt.Println("Falling back to simple input mode...")
|
|
|
|
|
simpleInteractiveMode(agentLoop, sessionKey)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer rl.Close()
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
line, err := rl.Readline()
|
|
|
|
|
if err != nil {
|
|
|
|
|
if err == readline.ErrInterrupt || err == io.EOF {
|
|
|
|
|
fmt.Println("\nGoodbye!")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
fmt.Printf("Error reading input: %v\n", err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
input := strings.TrimSpace(line)
|
|
|
|
|
if input == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if input == "exit" || input == "quit" {
|
|
|
|
|
fmt.Println("Goodbye!")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
|
|
|
|
|
if err != nil {
|
|
|
|
|
fmt.Printf("Error: %v\n", err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 07:47:45 +00:00
|
|
|
fmt.Printf("\n%s %s\n\n", internal.Logo, response)
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
|
|
|
|
reader := bufio.NewReader(os.Stdin)
|
|
|
|
|
for {
|
2026-02-25 07:47:45 +00:00
|
|
|
fmt.Print(fmt.Sprintf("%s You: ", internal.Logo))
|
2026-02-18 17:03:34 +00:00
|
|
|
line, err := reader.ReadString('\n')
|
|
|
|
|
if err != nil {
|
|
|
|
|
if err == io.EOF {
|
|
|
|
|
fmt.Println("\nGoodbye!")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
fmt.Printf("Error reading input: %v\n", err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
input := strings.TrimSpace(line)
|
|
|
|
|
if input == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if input == "exit" || input == "quit" {
|
|
|
|
|
fmt.Println("Goodbye!")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
|
|
|
|
|
if err != nil {
|
|
|
|
|
fmt.Printf("Error: %v\n", err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 07:47:45 +00:00
|
|
|
fmt.Printf("\n%s %s\n\n", internal.Logo, response)
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
}
|