feat(session): add scope-aware /new and /session commands

# Conflicts:
#	pkg/agent/loop_test.go
This commit is contained in:
mingmxren 2026-03-01 12:17:52 +08:00
parent ab438d453c
commit 49b69cda81
12 changed files with 444 additions and 39 deletions

View file

@ -338,7 +338,7 @@ picoclaw gateway
**4. Telegram command menu (auto-registered at startup)**
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/new`, `/session`, `/show`, `/list`) so command menu and runtime behavior stay in sync.
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
@ -648,6 +648,21 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
└── USER.md # User preferences
```
### Session Scope and Backlog
Use `session.dm_scope` to control DM session isolation and `session.backlog_limit` to cap how many sessions are retained per scope:
```json
{
"session": {
"dm_scope": "per-channel-peer",
"backlog_limit": 20
}
}
```
`/new` (or `/reset`) starts a fresh active session for the current scope. `/session list` and `/session resume <index>` operate within that same scope.
### 🔒 Security Sandbox
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
@ -1089,6 +1104,10 @@ picoclaw agent -m "Hello"
"model": "anthropic/claude-opus-4-5"
}
},
"session": {
"dm_scope": "per-channel-peer",
"backlog_limit": 20
},
"providers": {
"openrouter": {
"api_key": "sk-or-v1-xxx"

View file

@ -307,7 +307,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
### Telegram 命令注册(启动时自动同步)
PicoClaw 现在使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start``/help``/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
PicoClaw 现在使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start``/help``/new`、`/session``/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
@ -341,6 +341,21 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
```
### 会话作用域与保留上限
使用 `session.dm_scope` 控制私聊会话隔离粒度,使用 `session.backlog_limit` 控制每个作用域保留多少历史会话:
```json
{
"session": {
"dm_scope": "per-channel-peer",
"backlog_limit": 20
}
}
```
`/new`(或 `/reset`)会在当前作用域创建新会话;`/session list``/session resume <index>` 只在当前作用域内生效。
### 心跳 / 周期性任务 (Heartbeat)
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
@ -680,6 +695,10 @@ picoclaw agent -m "你好"
"model": "anthropic/claude-opus-4-5"
}
},
"session": {
"dm_scope": "per-channel-peer",
"backlog_limit": 20
},
"providers": {
"openrouter": {
"api_key": "sk-or-v1-xxx"

View file

@ -9,6 +9,10 @@
"max_tool_iterations": 20
}
},
"session": {
"dm_scope": "per-channel-peer",
"backlog_limit": 20
},
"model_list": [
{
"model_name": "gpt4",

View file

@ -12,6 +12,7 @@ import (
"errors"
"fmt"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
@ -369,12 +370,52 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return al.processSystemMessage(ctx, msg)
}
route, agent, err := al.resolveMessageRoute(msg)
if err != nil {
return "", err
}
// Check for commands
if response, handled := al.handleCommand(ctx, msg); handled {
if response, handled := al.handleCommand(ctx, msg, route, agent); handled {
return response, nil
}
// Route to determine agent and session key
// 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 mt, ok := tool.(tools.ContextualTool); ok {
mt.SetContext(msg.Channel, msg.ChatID)
}
}
// Resolve active session from the routing scope.
scopeKey := resolveScopeKey(route, msg.SessionKey)
sessionKey, err := agent.Sessions.ResolveActive(scopeKey)
if err != nil {
return "", fmt.Errorf("resolve active session: %w", err)
}
logger.InfoCF("agent", "Routed message",
map[string]any{
"agent_id": agent.ID,
"scope_key": scopeKey,
"session_key": sessionKey,
"matched_by": route.MatchedBy,
"route_agent": route.AgentID,
"route_channel": route.Channel,
})
return al.runAgentLoop(ctx, agent, processOptions{
SessionKey: sessionKey,
Channel: msg.Channel,
ChatID: msg.ChatID,
UserMessage: msg.Content,
DefaultResponse: defaultResponse,
EnableSummary: true,
SendResponse: false,
})
}
func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) {
route := al.registry.ResolveRoute(routing.RouteInput{
Channel: msg.Channel,
AccountID: msg.Metadata["account_id"],
@ -389,38 +430,17 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
agent = al.registry.GetDefaultAgent()
}
if agent == nil {
return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
}
// 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 mt, ok := tool.(tools.ContextualTool); ok {
mt.SetContext(msg.Channel, msg.ChatID)
}
return route, agent, nil
}
func resolveScopeKey(route routing.ResolvedRoute, msgSessionKey string) string {
if msgSessionKey != "" && strings.HasPrefix(msgSessionKey, "agent:") {
return msgSessionKey
}
// Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron)
sessionKey := route.SessionKey
if msg.SessionKey != "" && strings.HasPrefix(msg.SessionKey, "agent:") {
sessionKey = msg.SessionKey
}
logger.InfoCF("agent", "Routed message",
map[string]any{
"agent_id": agent.ID,
"session_key": sessionKey,
"matched_by": route.MatchedBy,
})
return al.runAgentLoop(ctx, agent, processOptions{
SessionKey: sessionKey,
Channel: msg.Channel,
ChatID: msg.ChatID,
UserMessage: msg.Content,
DefaultResponse: defaultResponse,
EnableSummary: true,
SendResponse: false,
})
return route.SessionKey
}
func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
@ -1235,7 +1255,12 @@ func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
return totalChars * 2 / 5
}
func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) {
func (al *AgentLoop) handleCommand(
ctx context.Context,
msg bus.InboundMessage,
route routing.ResolvedRoute,
agent *AgentInstance,
) (string, bool) {
content := strings.TrimSpace(msg.Content)
if !strings.HasPrefix(content, "/") {
return "", false
@ -1247,9 +1272,94 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
}
cmd := parts[0]
if at := strings.Index(cmd, "@"); at > 0 {
cmd = cmd[:at]
}
args := parts[1:]
switch cmd {
case "/new", "/reset":
scopeKey := resolveScopeKey(route, msg.SessionKey)
newSessionKey, err := agent.Sessions.StartNew(scopeKey)
if err != nil {
return fmt.Sprintf("Failed to start new session: %v", err), true
}
backlogLimit := config.DefaultSessionBacklogLimit
if al.cfg != nil {
backlogLimit = al.cfg.Session.EffectiveBacklogLimit()
}
pruned, err := agent.Sessions.Prune(scopeKey, backlogLimit)
if err != nil {
return fmt.Sprintf(
"Started new session (%s), but pruning old sessions failed: %v",
newSessionKey,
err,
), true
}
if len(pruned) == 0 {
return fmt.Sprintf("Started new session: %s", newSessionKey), true
}
return fmt.Sprintf("Started new session: %s (pruned %d old session(s))", newSessionKey, len(pruned)), true
case "/session":
if len(args) < 1 {
return "Usage: /session [list|resume <index>]", true
}
scopeKey := resolveScopeKey(route, msg.SessionKey)
switch args[0] {
case "list":
list, err := agent.Sessions.List(scopeKey)
if err != nil {
return fmt.Sprintf("Failed to list sessions: %v", err), true
}
if len(list) == 0 {
return "No sessions found for current chat.", true
}
lines := make([]string, 0, len(list)+1)
lines = append(lines, "Sessions for current chat:")
for _, item := range list {
activeMarker := " "
if item.Active {
activeMarker = "*"
}
updated := "-"
if !item.UpdatedAt.IsZero() {
updated = item.UpdatedAt.Format("2006-01-02 15:04")
}
lines = append(lines, fmt.Sprintf(
"%d. [%s] %s (%d msgs, updated %s)",
item.Ordinal,
activeMarker,
item.SessionKey,
item.MessageCnt,
updated,
))
}
return strings.Join(lines, "\n"), true
case "resume":
if len(args) != 2 {
return "Usage: /session resume <index>", true
}
index, err := strconv.Atoi(args[1])
if err != nil || index < 1 {
return "Usage: /session resume <index>", true
}
sessionKey, err := agent.Sessions.Resume(scopeKey, index)
if err != nil {
return fmt.Sprintf("Failed to resume session %d: %v", index, err), true
}
return fmt.Sprintf("Resumed session %d: %s", index, sessionKey), true
default:
return "Usage: /session [list|resume <index>]", true
}
case "/show":
if len(args) < 1 {
return "Usage: /show [model|channel|agents]", true

View file

@ -5,7 +5,6 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"testing"
"time"
@ -13,6 +12,7 @@ import (
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/tools"
)
@ -438,6 +438,174 @@ func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, ms
const responseTimeout = 3 * time.Second
func TestProcessMessage_UsesResolvedActiveSession(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &simpleMockProvider{response: "ok"}
al := NewAgentLoop(cfg, msgBus, provider)
msg := bus.InboundMessage{
Channel: "telegram",
SenderID: "user1",
ChatID: "chat1",
Content: "hello",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
}
route := al.registry.ResolveRoute(routing.RouteInput{
Channel: msg.Channel,
Peer: extractPeer(msg),
})
scopeKey := route.SessionKey
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("No default agent found")
}
rotated, err := defaultAgent.Sessions.StartNew(scopeKey)
if err != nil {
t.Fatalf("StartNew(%q) failed: %v", scopeKey, err)
}
helper := testHelper{al: al}
_ = helper.executeAndGetResponse(t, context.Background(), msg)
if got := len(defaultAgent.Sessions.GetHistory(scopeKey)); got != 0 {
t.Fatalf("expected base scope history len=0, got %d", got)
}
rotatedHistory := defaultAgent.Sessions.GetHistory(rotated)
if len(rotatedHistory) != 2 {
t.Fatalf("expected rotated history len=2, got %d", len(rotatedHistory))
}
if rotatedHistory[0].Role != "user" || rotatedHistory[0].Content != "hello" {
t.Fatalf("unexpected first message in rotated session: %+v", rotatedHistory[0])
}
}
func TestHandleCommand_NewAndSessionCommands(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
Session: config.SessionConfig{
DMScope: "per-channel-peer",
BacklogLimit: 20,
},
}
msgBus := bus.NewMessageBus()
provider := &simpleMockProvider{response: "ok"}
al := NewAgentLoop(cfg, msgBus, provider)
helper := testHelper{al: al}
baseMsg := bus.InboundMessage{
Channel: "telegram",
SenderID: "user1",
ChatID: "chat1",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
}
route := al.registry.ResolveRoute(routing.RouteInput{
Channel: baseMsg.Channel,
Peer: extractPeer(baseMsg),
})
scopeKey := route.SessionKey
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("No default agent found")
}
respNew1 := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
Channel: baseMsg.Channel,
SenderID: baseMsg.SenderID,
ChatID: baseMsg.ChatID,
Content: "/new",
Peer: baseMsg.Peer,
})
if !strings.Contains(respNew1, scopeKey+"#2") {
t.Fatalf("/new response missing new session key, got: %q", respNew1)
}
respNew2 := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
Channel: baseMsg.Channel,
SenderID: baseMsg.SenderID,
ChatID: baseMsg.ChatID,
Content: "/new",
Peer: baseMsg.Peer,
})
if !strings.Contains(respNew2, scopeKey+"#3") {
t.Fatalf("second /new response missing session #3, got: %q", respNew2)
}
listResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
Channel: baseMsg.Channel,
SenderID: baseMsg.SenderID,
ChatID: baseMsg.ChatID,
Content: "/session list",
Peer: baseMsg.Peer,
})
if !strings.Contains(listResp, "1. [*] "+scopeKey+"#3") {
t.Fatalf("/session list response missing active session #3, got:\n%s", listResp)
}
if !strings.Contains(listResp, "3. [ ] "+scopeKey) {
t.Fatalf("/session list response missing base session ordinal, got:\n%s", listResp)
}
resumeResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
Channel: baseMsg.Channel,
SenderID: baseMsg.SenderID,
ChatID: baseMsg.ChatID,
Content: "/session resume 3",
Peer: baseMsg.Peer,
})
if !strings.Contains(resumeResp, scopeKey) {
t.Fatalf("/session resume response missing target session key, got: %q", resumeResp)
}
active, err := defaultAgent.Sessions.ResolveActive(scopeKey)
if err != nil {
t.Fatalf("ResolveActive failed: %v", err)
}
if active != scopeKey {
t.Fatalf("active session = %q, want %q after resume", active, scopeKey)
}
}
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")

View file

@ -30,6 +30,19 @@ func BuiltinDefinitions(cfg *config.Config) []Definition {
return req.Reply(FormatHelpMessage(defs))
},
},
{
Name: "new",
Aliases: []string{"reset"},
Description: "Start a new chat session",
Usage: "/new",
Channels: []string{"telegram", "whatsapp", "whatsapp_native"},
},
{
Name: "session",
Description: "Manage chat sessions",
Usage: "/session [list|resume <index>]",
Channels: []string{"telegram", "whatsapp", "whatsapp_native"},
},
{
Name: "show",
Description: "Show current configuration",

View file

@ -8,7 +8,7 @@ func TestBuiltinDefinitions_ContainsTelegramDefaults(t *testing.T) {
for _, d := range defs {
names[d.Name] = true
}
for _, want := range []string{"help", "start", "show", "list"} {
for _, want := range []string{"help", "start", "new", "session", "show", "list"} {
if !names[want] {
t.Fatalf("missing command %q", want)
}
@ -24,6 +24,9 @@ func TestBuiltinDefinitions_WhatsAppOnlyHasBasicCommands(t *testing.T) {
if !names["start"] || !names["help"] {
t.Fatalf("whatsapp should include start/help, got %+v", names)
}
if !names["new"] || !names["session"] {
t.Fatalf("whatsapp should include new/session, got %+v", names)
}
if names["show"] || names["list"] {
t.Fatalf("whatsapp should not include show/list, got %+v", names)
}

View file

@ -52,7 +52,9 @@ func (d *Dispatcher) Dispatch(ctx context.Context, req Request) Result {
continue
}
if def.Handler == nil {
return Result{Matched: true, Handled: false, Command: def.Name}
// Definition-only command (for menu registration / discovery).
// Let the inbound message continue to the agent loop.
return Result{Matched: false, Handled: false, Command: def.Name}
}
err := def.Handler(ctx, req)
return Result{Matched: true, Handled: true, Command: def.Name, Err: err}

View file

@ -59,3 +59,17 @@ func TestDispatcher_MatchTelegramMentionSyntax(t *testing.T) {
t.Fatalf("dispatch result = %+v, called=%v", res, called)
}
}
func TestDispatcher_PassThroughDefinitionWithoutHandler(t *testing.T) {
d := NewDispatcher(NewRegistry([]Definition{
{Name: "session"}, // menu-only / pass-through definition
}))
res := d.Dispatch(context.Background(), Request{
Channel: "telegram",
Text: "/session list",
})
if res.Matched {
t.Fatalf("expected pass-through unmatched result, got %+v", res)
}
}

View file

@ -78,7 +78,7 @@ func (c Config) MarshalJSON() ([]byte, error) {
}
// Only include session if not empty
if c.Session.DMScope != "" || len(c.Session.IdentityLinks) > 0 {
if c.Session.DMScope != "" || len(c.Session.IdentityLinks) > 0 || c.Session.BacklogLimit > 0 {
aux.Session = &c.Session
}
@ -165,6 +165,16 @@ type AgentBinding struct {
type SessionConfig struct {
DMScope string `json:"dm_scope,omitempty"`
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
BacklogLimit int `json:"backlog_limit,omitempty"`
}
const DefaultSessionBacklogLimit = 20
func (s SessionConfig) EffectiveBacklogLimit() int {
if s.BacklogLimit < 1 {
return DefaultSessionBacklogLimit
}
return s.BacklogLimit
}
type AgentDefaults struct {
@ -609,6 +619,10 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
if cfg.Session.BacklogLimit < 1 {
cfg.Session.BacklogLimit = DefaultSessionBacklogLimit
}
// Migrate legacy channel config fields to new unified structures
cfg.migrateChannelConfigs()

View file

@ -442,3 +442,41 @@ func TestDefaultConfig_DMScope(t *testing.T) {
t.Errorf("Session.DMScope = %q, want 'per-channel-peer'", cfg.Session.DMScope)
}
}
func TestDefaultConfig_SessionBacklogLimit(t *testing.T) {
cfg := DefaultConfig()
if cfg.Session.BacklogLimit != DefaultSessionBacklogLimit {
t.Errorf(
"Session.BacklogLimit = %d, want %d",
cfg.Session.BacklogLimit,
DefaultSessionBacklogLimit,
)
}
}
func TestLoadConfig_InvalidBacklogLimitFallsBackToDefault(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.json")
configJSON := `{
"agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}},
"session": {"backlog_limit": 0},
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}]
}`
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
t.Fatalf("os.WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if cfg.Session.BacklogLimit != DefaultSessionBacklogLimit {
t.Fatalf(
"Session.BacklogLimit = %d, want %d",
cfg.Session.BacklogLimit,
DefaultSessionBacklogLimit,
)
}
}

View file

@ -21,7 +21,8 @@ func DefaultConfig() *Config {
},
Bindings: []AgentBinding{},
Session: SessionConfig{
DMScope: "per-channel-peer",
DMScope: "per-channel-peer",
BacklogLimit: DefaultSessionBacklogLimit,
},
Channels: ChannelsConfig{
WhatsApp: WhatsAppConfig{