docs: add session and routing documentation (#2571)

This commit is contained in:
美電球
2026-04-17 21:25:18 +08:00
committed by GitHub
parent 15a3560533
commit 9fe678247f
14 changed files with 2379 additions and 0 deletions
+2
View File
@@ -4,6 +4,8 @@ Internal architecture notes for major runtime mechanisms and subsystem design.
- [Steering](steering.md): injecting messages into a running agent loop between tool calls.
- [SubTurn Mechanism](subturn.md): sub-agent coordination, concurrency control, and lifecycle handling.
- [Session System](session-system.md): session scope allocation, JSONL persistence, alias compatibility, and migration. ([ZH](session-system.zh.md))
- [Routing System](routing-system.md): agent dispatch, session policy selection, and light/heavy model routing. ([ZH](routing-system.zh.md))
- [Hook System Guide](hooks/README.md): current hook architecture and protocol details.
- [Agent Refactor](agent-refactor/README.md): notes and checkpoints for the agent refactor work.
+282
View File
@@ -0,0 +1,282 @@
# Routing System
> Back to [README](../README.md)
In PicoClaw, the runtime "routing system" is not just one decision.
It is the combined pipeline that decides:
1. which agent handles an inbound message
2. which session dimensions should isolate that conversation
3. whether the turn should use the agent's primary model or a configured light model
This document covers the runtime path in `pkg/routing` and its integration in `pkg/agent`.
It does not describe the launcher's HTTP `ServeMux` routes or the frontend's TanStack Router files under `web/`.
## Routing Layers
| Layer | Files | Responsibility |
| --- | --- | --- |
| Agent dispatch | `pkg/routing/route.go`, `pkg/routing/agent_id.go` | Choose the target agent for the inbound message. |
| Session policy selection | `pkg/routing/route.go` | Decide which dimensions should define session isolation for that routed turn. |
| Model routing | `pkg/routing/router.go`, `pkg/routing/features.go`, `pkg/routing/classifier.go` | Choose between the primary model and a configured light model based on message complexity. |
| Runtime integration | `pkg/agent/registry.go`, `pkg/agent/loop_message.go`, `pkg/agent/loop_turn.go` | Apply the route result, allocate session scope, and select model candidates before provider execution. |
## End-To-End Flow
The normal path for a user message is:
```text
InboundMessage
-> NormalizeInboundContext
-> RouteResolver.ResolveRoute(...)
-> session.AllocateRouteSession(...)
-> ensureSessionMetadata(...)
-> Router.SelectModel(...)
-> provider execution
```
The first half answers "who should handle this message and what session does it belong to".
The second half answers "which model tier should that agent use for this turn".
## Agent Dispatch
`routing.RouteResolver` turns a normalized `bus.InboundContext` into a `ResolvedRoute`:
```go
type ResolvedRoute struct {
AgentID string
Channel string
AccountID string
SessionPolicy SessionPolicy
MatchedBy string
}
```
`MatchedBy` is a debugging aid.
Typical values are:
- `default`
- `dispatch.rule`
- `dispatch.rule:<rule-name>`
## Dispatch Input View
Before matching rules, the resolver builds a normalized `dispatchView`.
Each field is normalized to the exact shape expected by rule matching.
| Selector field | Runtime shape |
| --- | --- |
| `channel` | lowercased channel name |
| `account` | normalized account ID |
| `space` | `<space_type>:<space_id>` |
| `chat` | `<chat_type>:<chat_id>` |
| `topic` | `topic:<topic_id>` |
| `sender` | lowercased canonical sender ID |
| `mentioned` | boolean copied from inbound context |
This means dispatch rules must match the normalized shape, for example:
```json
{
"agents": {
"dispatch": {
"rules": [
{
"name": "support-group",
"agent": "support",
"when": {
"channel": "telegram",
"chat": "group:-100123"
}
},
{
"name": "slack-mentions",
"agent": "support",
"when": {
"channel": "slack",
"space": "workspace:t001",
"mentioned": true
}
}
]
}
}
}
```
## Dispatch Algorithm
`ResolveRoute(...)` follows this sequence:
1. Normalize `channel` and `account`.
2. Clone `session.identity_links` from config.
3. Build the normalized dispatch view.
4. Scan `agents.dispatch.rules` in order.
5. Skip rules with no constraints at all.
6. Return the first rule whose selector fields all match exactly.
7. If no rule matches, fall back to the default agent.
Important consequences:
- first match wins
- there is no score or priority field beyond list order
- invalid target agent IDs fall back to the default agent
- sender matching can see canonical identities produced by `identity_links`
## Default Agent Resolution
If no dispatch rule wins, or if a rule points at an unknown agent, the resolver picks a default agent using this order:
1. the agent marked `default: true`
2. otherwise the first entry in `agents.list`
3. otherwise implicit `main`
Both agent IDs and account IDs are normalized through the helpers in `pkg/routing/agent_id.go`.
## Session Policy Handoff
Agent dispatch does not directly build a session key.
Instead it emits a `SessionPolicy`:
```go
type SessionPolicy struct {
Dimensions []string
IdentityLinks map[string][]string
}
```
The dimensions come from:
- global `session.dimensions`
- or `dispatch_rule.session_dimensions` when the matching rule overrides them
Only these dimension names survive normalization:
- `space`
- `chat`
- `topic`
- `sender`
Invalid or duplicated entries are silently dropped.
`pkg/session/AllocateRouteSession(...)` then turns that policy into:
- a structured `SessionScope`
- a canonical routed session key
- legacy compatibility aliases
So the routing package owns "what should isolate this conversation", while the session package owns "how that isolation becomes keys and durable storage".
## Identity Links
`session.identity_links` is shared between dispatch and session allocation.
That is intentional: a sender canonicalized for routing should also map to the same session identity.
Without that symmetry, the system could route two messages to the same agent but still fragment their history into different sessions.
## Model Routing
The second routing stage decides whether a turn can use a cheaper or faster light model.
Config shape:
```json
{
"routing": {
"enabled": true,
"light_model": "gemini-2.0-flash",
"threshold": 0.35
}
}
```
`pkg/routing.Router` compares the current turn against structural features and returns:
- chosen model name
- whether the light model was used
- computed complexity score
If the score is below the threshold, the light model wins.
Otherwise the agent's primary model is used.
At runtime this only matters when the agent actually has light-model candidates configured; otherwise execution stays on the primary candidate set.
## Complexity Features
`ExtractFeatures(...)` computes a language-agnostic feature vector:
| Feature | Meaning |
| --- | --- |
| `TokenEstimate` | Approximate token count; CJK runes count more accurately than a flat rune split. |
| `CodeBlockCount` | Number of fenced code blocks in the current message. |
| `RecentToolCalls` | Tool-call count across the last six history entries. |
| `ConversationDepth` | Total history length. |
| `HasAttachments` | Detects embedded media or common media URL/file extensions. |
This is intentionally structural rather than keyword-based, so the router behaves the same across languages.
## RuleClassifier Scoring
The current classifier is `RuleClassifier`.
It uses a weighted sum capped to `[0, 1]`.
| Signal | Score |
| --- | --- |
| attachments present | `1.00` |
| token estimate `> 200` | `0.35` |
| token estimate `> 50` | `0.15` |
| code block present | `0.40` |
| recent tool calls `> 3` | `0.25` |
| recent tool calls `1..3` | `0.10` |
| conversation depth `> 10` | `0.10` |
The default threshold is `0.35`.
That makes the following behavior intentional:
- trivial chat stays on the light model
- code tasks usually jump to the heavy model immediately
- attachments always force the heavy model
- long, plain-text prompts cross the heavy-model boundary at the default threshold
## Runtime Integration
Agent dispatch and model routing happen in different places:
- `pkg/agent/registry.go` owns `RouteResolver`
- `pkg/agent/loop_message.go` resolves the route and allocates session scope
- `pkg/agent/loop_turn.go:selectCandidates` calls `agent.Router.SelectModel(...)`
When the light model is selected, the agent loop swaps to `agent.LightCandidates`.
When it is not selected, execution stays on the agent's primary provider candidate set.
## Explicit Session Keys
One nuance sits just outside `pkg/routing` but matters for the full routing story.
After a route is allocated, `pkg/agent/loop_utils.go:resolveScopeKey` preserves an explicit incoming session key when the caller already supplied:
- an opaque canonical key
- a legacy `agent:...` key
That makes manual system flows, tests, and compatibility paths deterministic even when the normal routed scope would have produced a different key.
## What This Document Does Not Cover
The repository also contains two unrelated route systems:
- backend HTTP routes registered in `web/backend/api/router.go`
- frontend file routes under `web/frontend/src/routes/`
Those are launcher implementation details.
They are separate from the runtime routing system described here.
## Related Files
- `pkg/routing/route.go`
- `pkg/routing/router.go`
- `pkg/routing/classifier.go`
- `pkg/routing/features.go`
- `pkg/routing/agent_id.go`
- `pkg/session/allocator.go`
- `pkg/agent/registry.go`
- `pkg/agent/loop_message.go`
- `pkg/agent/loop_turn.go`
+281
View File
@@ -0,0 +1,281 @@
# 路由系统
> 返回 [README](../README.md)
在 PicoClaw 里,“路由系统”不是单一判断。
它实际上是组合起来的一条运行时决策链,负责决定:
1. 哪个 agent 来处理一条入站消息
2. 这条消息应该落在哪种 session 隔离维度下
3. 这一轮该使用 agent 的主模型,还是配置中的轻量模型
本文覆盖 `pkg/routing` 及其在 `pkg/agent` 中的集成方式。
它不讨论 `web/` 目录下 launcher 的 HTTP `ServeMux` 路由,也不讨论前端 TanStack Router 文件路由。
## 路由分层
| 层次 | 文件 | 作用 |
| --- | --- | --- |
| Agent 分发 | `pkg/routing/route.go``pkg/routing/agent_id.go` | 为入站消息选择目标 agent。 |
| Session 策略选择 | `pkg/routing/route.go` | 决定该 turn 的会话隔离维度。 |
| 模型路由 | `pkg/routing/router.go``pkg/routing/features.go``pkg/routing/classifier.go` | 根据消息复杂度在主模型和轻量模型之间做选择。 |
| 运行时集成 | `pkg/agent/registry.go``pkg/agent/loop_message.go``pkg/agent/loop_turn.go` | 应用 route 结果、分配 session scope,并在真正调用 provider 前选出模型候选集。 |
## 端到端流程
普通用户消息的路径如下:
```text
InboundMessage
-> NormalizeInboundContext
-> RouteResolver.ResolveRoute(...)
-> session.AllocateRouteSession(...)
-> ensureSessionMetadata(...)
-> Router.SelectModel(...)
-> provider execution
```
前半段回答的是“谁来处理,以及属于哪段会话”。
后半段回答的是“这个 agent 这一轮该走哪一档模型”。
## Agent 分发
`routing.RouteResolver` 会把归一化后的 `bus.InboundContext` 转成 `ResolvedRoute`
```go
type ResolvedRoute struct {
AgentID string
Channel string
AccountID string
SessionPolicy SessionPolicy
MatchedBy string
}
```
`MatchedBy` 主要用于日志和调试,常见值包括:
- `default`
- `dispatch.rule`
- `dispatch.rule:<rule-name>`
## Dispatch 输入视图
真正做规则匹配前,resolver 会先构造一个归一化后的 `dispatchView`
每个字段都会变成规则匹配所期待的固定形状。
| Selector 字段 | 运行时形状 |
| --- | --- |
| `channel` | 小写 channel 名称 |
| `account` | 归一化后的 account ID |
| `space` | `<space_type>:<space_id>` |
| `chat` | `<chat_type>:<chat_id>` |
| `topic` | `topic:<topic_id>` |
| `sender` | 小写 canonical sender ID |
| `mentioned` | 直接来自 inbound context 的布尔值 |
这意味着 dispatch rule 必须写成归一化后的形状,例如:
```json
{
"agents": {
"dispatch": {
"rules": [
{
"name": "support-group",
"agent": "support",
"when": {
"channel": "telegram",
"chat": "group:-100123"
}
},
{
"name": "slack-mentions",
"agent": "support",
"when": {
"channel": "slack",
"space": "workspace:t001",
"mentioned": true
}
}
]
}
}
}
```
## Dispatch 算法
`ResolveRoute(...)` 的流程是:
1. 归一化 `channel``account`
2. 从配置复制 `session.identity_links`
3. 构建归一化后的 dispatch view。
4. 按顺序扫描 `agents.dispatch.rules`
5. 没有任何约束条件的 rule 会被跳过。
6. 第一个所有 selector 字段都精确匹配的 rule 胜出。
7. 如果没有 rule 匹配,则回退到默认 agent。
这带来几个重要结论:
- 第一条命中的规则优先,没有额外 priority 字段
- rule 顺序本身就是优先级
- 指向无效 agent 的 rule 最终会回退到默认 agent
- sender 匹配看到的是经过 `identity_links` 归一化后的身份
## 默认 Agent 解析
如果没有 dispatch rule 命中,或者 rule 指向了不存在的 agent,resolver 会按以下顺序选择默认 agent:
1. `default: true` 的 agent
2. 否则取 `agents.list` 的第一项
3. 如果配置里没有 agent,则使用隐式 `main`
Agent ID 和 Account ID 都会经过 `pkg/routing/agent_id.go` 中的归一化逻辑。
## Session 策略交接
Agent 分发本身不会直接生成 session key。
它只会产出一个 `SessionPolicy`
```go
type SessionPolicy struct {
Dimensions []string
IdentityLinks map[string][]string
}
```
维度来源有两种:
- 全局 `session.dimensions`
- 如果命中的 dispatch rule 指定了 `session_dimensions`,则用 rule 覆盖
最终只有这些维度名会被保留下来:
- `space`
- `chat`
- `topic`
- `sender`
非法项或重复项会被静默丢弃。
随后 `pkg/session/AllocateRouteSession(...)` 再把这份策略转成:
- 结构化 `SessionScope`
- canonical routed session key
- legacy 兼容 alias
所以可以把职责边界理解为:
- `pkg/routing` 决定“这段对话应该按什么维度隔离”
- `pkg/session` 决定“这些维度如何变成 key 和持久化状态”
## Identity Links
`session.identity_links` 会同时被 dispatch 和 session allocation 使用。
这是刻意保持一致的设计:如果某个 sender 在路由阶段已经被规范化,那么 session 阶段也应该落到同一个身份上。
否则就会出现“消息路由到了同一个 agent,但上下文仍被拆成多个 session”的问题。
## 模型路由
第二阶段路由决定这一轮能否使用更便宜或更快的轻量模型。
配置形状如下:
```json
{
"routing": {
"enabled": true,
"light_model": "gemini-2.0-flash",
"threshold": 0.35
}
}
```
`pkg/routing.Router` 会根据当前 turn 的结构特征,返回:
- 选中的模型名
- 是否使用了 light model
- 复杂度分数
当分数低于阈值时,走轻量模型;否则仍使用 agent 的主模型。
但在运行时,只有当 agent 实际配置了 light-model candidates 时,这个判断才会产生效果;否则仍会停留在主模型候选集上。
## 复杂度特征
`ExtractFeatures(...)` 会计算一个与自然语言内容无关、偏结构化的特征向量:
| 特征 | 含义 |
| --- | --- |
| `TokenEstimate` | 估算 token 数;对 CJK 文本比简单 rune 平分更准确。 |
| `CodeBlockCount` | 当前消息中 fenced code block 的数量。 |
| `RecentToolCalls` | 最近 6 条历史消息中的 tool call 总数。 |
| `ConversationDepth` | 整体历史长度。 |
| `HasAttachments` | 是否检测到嵌入媒体或常见媒体 URL / 文件扩展名。 |
这样做的目的,是让模型路由不依赖关键词,从而在不同语言下都保持一致行为。
## RuleClassifier 评分
当前分类器是 `RuleClassifier`,使用加权求和并把结果截断到 `[0, 1]`
| 信号 | 分值 |
| --- | --- |
| 存在附件 | `1.00` |
| token 估计 `> 200` | `0.35` |
| token 估计 `> 50` | `0.15` |
| 存在代码块 | `0.40` |
| 最近 tool calls `> 3` | `0.25` |
| 最近 tool calls `1..3` | `0.10` |
| 会话深度 `> 10` | `0.10` |
默认阈值是 `0.35`
这意味着以下行为是刻意设计出来的:
- 很轻的闲聊仍走轻量模型
- 编码类请求通常会立刻切到重模型
- 带附件的请求一定走重模型
- 很长的纯文本请求在默认阈值下也会跨过重模型边界
## 运行时集成
Agent 分发和模型路由发生在不同位置:
- `pkg/agent/registry.go` 持有 `RouteResolver`
- `pkg/agent/loop_message.go` 负责 resolve route 并分配 session scope
- `pkg/agent/loop_turn.go:selectCandidates` 调用 `agent.Router.SelectModel(...)`
当 light model 被选中时,agent loop 会切换到 `agent.LightCandidates`
如果没有被选中,则继续使用 agent 的主 provider 候选集。
## 显式 Session Key
还有一个不在 `pkg/routing` 内部、但对整体“路由语义”很重要的细节。
在 route 分配完成后,`pkg/agent/loop_utils.go:resolveScopeKey` 会优先保留调用方显式传入的 session key,只要它属于以下格式之一:
- 不透明 canonical key
- legacy `agent:...` key
这样一来,手工系统流、测试和兼容路径即使在正常路由 scope 会生成不同 key 的情况下,仍然能保持确定性。
## 本文不覆盖的内容
仓库里还存在两套和这里无关的“route”系统:
- `web/backend/api/router.go` 注册的后端 HTTP 路由
- `web/frontend/src/routes/` 下的前端文件路由
它们属于 launcher 的实现细节,和本文描述的运行时路由系统是两回事。
## 相关文件
- `pkg/routing/route.go`
- `pkg/routing/router.go`
- `pkg/routing/classifier.go`
- `pkg/routing/features.go`
- `pkg/routing/agent_id.go`
- `pkg/session/allocator.go`
- `pkg/agent/registry.go`
- `pkg/agent/loop_message.go`
- `pkg/agent/loop_turn.go`
+255
View File
@@ -0,0 +1,255 @@
# Session System
> Back to [README](../README.md)
This document describes the runtime session system used by PicoClaw to:
- map inbound messages onto stable conversation scopes
- persist message history and summaries
- preserve compatibility with legacy `agent:...` session keys while the runtime uses opaque canonical keys
This document covers the core runtime path in `pkg/session`, `pkg/memory`, and `pkg/agent`.
It does not describe launcher login cookies or dashboard authentication sessions in `web/backend/middleware`.
## Responsibilities
The session system has four jobs:
1. Decide which messages should share the same conversation context.
2. Persist that context durably across turns and restarts.
3. Expose a small `SessionStore` interface to the agent loop.
4. Keep older session-key formats working during storage and routing migrations.
## Main Components
| Layer | Files | Responsibility |
| --- | --- | --- |
| Session contract | `pkg/session/session_store.go` | Defines the `SessionStore` interface used by the agent loop. |
| Legacy backend | `pkg/session/manager.go` | Stores one JSON file per session. Still used as a fallback. |
| Session adapter | `pkg/session/jsonl_backend.go` | Adapts `pkg/memory.Store` to `SessionStore`, including alias and scope metadata support. |
| Durable storage | `pkg/memory/jsonl.go` | Append-only JSONL storage plus `.meta.json` sidecar metadata. |
| Scope and key building | `pkg/session/scope.go`, `pkg/session/key.go`, `pkg/session/allocator.go` | Builds structured scopes, opaque canonical keys, and legacy aliases from routing results. |
| Runtime integration | `pkg/agent/instance.go`, `pkg/agent/loop.go`, `pkg/agent/loop_message.go` | Initializes the store, allocates session scope, and persists metadata before turns run. |
## Session Data Model
The structured session identity is represented by `session.SessionScope`:
| Field | Meaning |
| --- | --- |
| `Version` | Schema version. Current value is `ScopeVersionV1`. |
| `AgentID` | Routed agent handling the turn. |
| `Channel` | Normalized inbound channel name. |
| `Account` | Normalized account or bot identifier. |
| `Dimensions` | Ordered list of active partition dimensions such as `chat` or `sender`. |
| `Values` | Concrete normalized values for each selected dimension. |
Only four dimensions are currently recognized by the allocator:
- `space`
- `chat`
- `topic`
- `sender`
The default config uses:
```json
{
"session": {
"dimensions": ["chat"]
}
}
```
That means one shared conversation per chat unless a dispatch rule overrides it.
## Canonical Keys And Legacy Aliases
The runtime now prefers opaque canonical keys:
```text
sk_v1_<sha256>
```
These keys are built from a canonical scope signature in `pkg/session/key.go`.
The goal is to make storage keys stable while decoupling them from any specific legacy text format.
For compatibility, the allocator also emits legacy aliases such as:
```text
agent:main:direct:user123
agent:main:slack:channel:c001
agent:main:pico:direct:pico:session-123
```
These aliases matter because older sessions, tests, and some tools still refer to the legacy shape.
The JSONL backend resolves aliases back to the canonical key before reads and writes.
The agent loop also preserves explicit incoming session keys when the caller already supplied one of the recognized explicit formats:
- opaque canonical key
- legacy `agent:...` key
That behavior lives in `pkg/agent/loop_utils.go:resolveScopeKey`.
## Allocation Flow
The end-to-end flow for a normal inbound message is:
```text
InboundMessage
-> RouteResolver.ResolveRoute(...)
-> session.AllocateRouteSession(...)
-> resolveScopeKey(...)
-> ensureSessionMetadata(...)
-> AgentLoop turn execution
-> SessionStore read/write operations
```
More concretely:
1. `pkg/agent/loop_message.go` resolves the agent route from normalized inbound context.
2. `session.AllocateRouteSession` converts the route's `SessionPolicy` plus inbound context into a structured `SessionScope`.
3. The allocator builds:
- `SessionKey`: canonical routed session key
- `SessionAliases`: compatibility aliases for that routed scope
- `MainSessionKey`: agent-level main session key
- `MainAliases`: legacy alias for the main session
4. `runAgentLoop` persists scope metadata and aliases through `ensureSessionMetadata`.
5. During later reads or writes, `JSONLBackend.ResolveSessionKey` maps aliases back onto the canonical key.
The main session key is separate from routed chat sessions.
It is mainly used for agent-level or system-style flows that need one stable per-agent conversation, for example `processSystemMessage`.
## Scope Construction Rules
`pkg/session/allocator.go` builds scope values from normalized inbound context.
Important rules:
- `space` becomes `<space_type>:<space_id>`
- `chat` becomes `<chat_type>:<chat_id>`
- `topic` becomes `topic:<topic_id>`
- `sender` is canonicalized through `session.identity_links` before being stored
There are two special cases worth calling out.
### Telegram forum isolation
Telegram forum topics must stay isolated even when the configured dimensions only mention `chat`.
To preserve that behavior, the allocator appends `/<topic_id>` to the `chat` value for Telegram forum messages unless `topic` is already an explicit dimension.
Example:
```text
group:-1001234567890/42
group:-1001234567890/99
```
Those produce different session keys.
### Identity links
`session.identity_links` lets multiple sender identifiers collapse into one canonical identity.
Both dispatch matching and session allocation use that mapping so that the same person can keep one conversation even if their raw sender IDs differ across channels or accounts.
## Storage Format
The default runtime backend is `pkg/memory.JSONLStore`, wrapped by `session.JSONLBackend`.
Each session uses two files:
```text
{sanitized_key}.jsonl
{sanitized_key}.meta.json
```
The files store:
- `.jsonl`: one `providers.Message` per line, append-only
- `.meta.json`: summary, timestamps, line counts, logical truncation offset, scope, aliases
`SessionMeta` currently includes:
- `Key`
- `Summary`
- `Skip`
- `Count`
- `CreatedAt`
- `UpdatedAt`
- `Scope`
- `Aliases`
## Write And Crash Semantics
The JSONL store is designed around append-first durability and stale-over-loss recovery:
- `AddMessage` and `AddFullMessage` append one JSON line, `fsync`, then update metadata.
- `TruncateHistory` is logical first: it only advances `meta.Skip`.
- `Compact` physically rewrites the JSONL file to remove skipped lines.
- `SetHistory` and `Compact` write metadata before rewriting JSONL so a crash may temporarily expose old data, but should not lose data.
- Corrupt JSONL lines are skipped during reads instead of failing the entire session.
`JSONLBackend.Save` maps onto `store.Compact(...)`.
In other words, `Save` is no longer "flush dirty memory to disk"; it is now "reclaim dead lines after logical truncation".
## Concurrency Model
`pkg/memory.JSONLStore` uses a fixed 64-shard mutex array keyed by session hash.
That gives per-session serialization without keeping an unbounded mutex map in memory.
The legacy `SessionManager` uses a single in-memory map guarded by an RW mutex.
Both backends satisfy the same `SessionStore` interface, which is why the agent loop does not need storage-specific code.
## Compatibility And Migration
`pkg/agent/instance.go:initSessionStore` prefers the JSONL backend.
Startup sequence:
1. Create `memory.NewJSONLStore(dir)`.
2. Run `memory.MigrateFromJSON(...)` to import legacy `.json` sessions.
3. Wrap the store with `session.NewJSONLBackend(store)`.
4. If JSONL initialization or migration fails, fall back to `session.NewSessionManager(dir)`.
This fallback is intentional: a partial migration would be worse than staying on the legacy store for one run.
### Alias promotion
When canonical metadata is first created, `EnsureSessionMetadata` may promote history from a non-empty legacy alias into the canonical session.
That promotion only happens when the canonical session is still empty, so active canonical history is not overwritten.
This is how the system preserves old histories such as:
- legacy direct-message keys
- older Pico direct-session keys
while moving the runtime onto opaque canonical keys.
## Other SessionStore Implementations
`pkg/agent/subturn.go` defines an `ephemeralSessionStore`.
It satisfies the same `SessionStore` interface, but keeps data in memory only and is destroyed when the sub-turn ends.
That lets SubTurn reuse the same session-facing APIs without writing child-session history into the parent's durable storage.
## Operational Consumers
The session system is consumed by more than the agent loop:
- `web/backend/api/session.go` reads JSONL metadata and legacy JSON sessions to expose session history in the launcher UI.
- `pkg/agent/steering.go` can recover scope metadata for active steering flows.
- tooling and tests can still refer to legacy aliases because alias resolution is handled below the agent loop.
## Related Files
- `pkg/session/session_store.go`
- `pkg/session/manager.go`
- `pkg/session/jsonl_backend.go`
- `pkg/session/scope.go`
- `pkg/session/key.go`
- `pkg/session/allocator.go`
- `pkg/memory/jsonl.go`
- `pkg/agent/instance.go`
- `pkg/agent/loop.go`
- `pkg/agent/loop_message.go`
+254
View File
@@ -0,0 +1,254 @@
# Session 系统
> 返回 [README](../README.md)
本文说明 PicoClaw 运行时的 Session 系统如何完成以下事情:
- 把入站消息映射到稳定的会话作用域
- 持久化消息历史与摘要
- 在运行时使用不透明 canonical key 的同时,继续兼容旧的 `agent:...` session key
本文覆盖 `pkg/session``pkg/memory``pkg/agent` 中的核心运行时链路。
它不讨论 `web/backend/middleware` 中 launcher 登录 Cookie 或 dashboard 鉴权 session。
## 职责
Session 系统承担四件事:
1. 决定哪些消息应该共享同一段上下文。
2. 让这段上下文能跨 turn、跨进程重启持久存在。
3. 向 agent loop 暴露一个足够小的 `SessionStore` 抽象。
4. 在存储层和路由层迁移期间继续兼容旧 session key。
## 主要组件
| 层次 | 文件 | 作用 |
| --- | --- | --- |
| Session 抽象 | `pkg/session/session_store.go` | 定义 agent loop 依赖的 `SessionStore` 接口。 |
| 旧后端 | `pkg/session/manager.go` | 每个 session 一个 JSON 文件的旧实现,仍作为回退方案保留。 |
| Session 适配层 | `pkg/session/jsonl_backend.go` | 把 `pkg/memory.Store` 适配成 `SessionStore`,并支持 alias 与 scope metadata。 |
| 持久化存储 | `pkg/memory/jsonl.go` | Append-only JSONL 存储与 `.meta.json` 元数据侧文件。 |
| Scope / Key 构建 | `pkg/session/scope.go``pkg/session/key.go``pkg/session/allocator.go` | 从路由结果生成结构化 scope、不透明 canonical key 和 legacy alias。 |
| 运行时集成 | `pkg/agent/instance.go``pkg/agent/loop.go``pkg/agent/loop_message.go` | 初始化存储、分配 session scope,并在 turn 执行前落 metadata。 |
## Session 数据模型
结构化的会话身份由 `session.SessionScope` 表示:
| 字段 | 含义 |
| --- | --- |
| `Version` | Scope 模式版本,当前为 `ScopeVersionV1`。 |
| `AgentID` | 处理该 turn 的路由 agent。 |
| `Channel` | 归一化后的入站 channel 名称。 |
| `Account` | 归一化后的 bot / account 标识。 |
| `Dimensions` | 当前启用的隔离维度顺序,例如 `chat``sender`。 |
| `Values` | 每个维度对应的具体归一化值。 |
Allocator 当前只识别四个维度:
- `space`
- `chat`
- `topic`
- `sender`
默认配置是:
```json
{
"session": {
"dimensions": ["chat"]
}
}
```
也就是默认按 chat 共享上下文;如果 dispatch rule 覆盖了维度,则以 rule 为准。
## Canonical Key 与 Legacy Alias
运行时现在优先使用不透明 canonical key
```text
sk_v1_<sha256>
```
它由 `pkg/session/key.go` 中的 scope signature 计算得到。
这样可以让存储 key 稳定,同时不再把持久化格式和某一种旧文本 key 绑定死。
为了兼容旧数据,allocator 还会生成 legacy alias,例如:
```text
agent:main:direct:user123
agent:main:slack:channel:c001
agent:main:pico:direct:pico:session-123
```
这些 alias 很重要,因为旧 session、部分测试以及某些工具仍然会引用这种格式。
JSONL backend 会在读写前先把 alias 解析回 canonical key。
此外,如果调用方已经显式传入了受支持的 session keyagent loop 会保留它,不强行改成新分配的 routed key。
这条逻辑在 `pkg/agent/loop_utils.go:resolveScopeKey` 中:
- 不透明 canonical key
- legacy `agent:...` key
都属于“显式 key”。
## 分配流程
普通入站消息的完整链路如下:
```text
InboundMessage
-> RouteResolver.ResolveRoute(...)
-> session.AllocateRouteSession(...)
-> resolveScopeKey(...)
-> ensureSessionMetadata(...)
-> AgentLoop turn 执行
-> SessionStore 读写
```
具体来说:
1. `pkg/agent/loop_message.go` 先用归一化后的 inbound context 解析 agent route。
2. `session.AllocateRouteSession` 把 route 的 `SessionPolicy` 和 inbound context 组合成结构化 `SessionScope`
3. Allocator 会生成:
- `SessionKey`:当前路由会话的 canonical key
- `SessionAliases`:该路由会话的兼容 alias
- `MainSessionKey`agent 级主会话 key
- `MainAliases`:主会话对应的 legacy alias
4. `runAgentLoop` 通过 `ensureSessionMetadata` 持久化 scope metadata 和 alias。
5. 后续读写时,`JSONLBackend.ResolveSessionKey` 会先把 alias 映射回 canonical key。
`MainSessionKey` 和普通聊天会话是分开的。
它主要服务于 agent 级、系统级的上下文场景,比如 `processSystemMessage`
## Scope 构建规则
`pkg/session/allocator.go` 会从归一化后的 inbound context 生成 scope 值。
关键规则如下:
- `space` 变成 `<space_type>:<space_id>`
- `chat` 变成 `<chat_type>:<chat_id>`
- `topic` 变成 `topic:<topic_id>`
- `sender` 会先经过 `session.identity_links` 归一化再写入
其中有两个需要单独记住的特殊规则。
### Telegram forum 隔离
Telegram forum topic 必须默认保持隔离,即使配置只写了 `chat` 维度。
为此,如果消息来自 Telegram forum 且策略里没有显式包含 `topic`allocator 会把 `/<topic_id>` 拼到 `chat` 值后面。
例如:
```text
group:-1001234567890/42
group:-1001234567890/99
```
这两者会得到不同的 session key。
### Identity links
`session.identity_links` 可以把多个 sender 标识折叠为一个 canonical identity。
dispatch 匹配和 session 分配都会使用这套映射,因此同一个人即使跨 channel 或 account 使用不同原始 sender ID,也可以继续落到同一段上下文里。
## 存储格式
默认运行时后端是 `pkg/memory.JSONLStore`,外面包了一层 `session.JSONLBackend`
每个 session 使用两类文件:
```text
{sanitized_key}.jsonl
{sanitized_key}.meta.json
```
各自保存:
- `.jsonl`:一行一个 `providers.Message`append-only
- `.meta.json`:摘要、时间戳、行数、逻辑截断偏移、scope、aliases
`SessionMeta` 当前包含:
- `Key`
- `Summary`
- `Skip`
- `Count`
- `CreatedAt`
- `UpdatedAt`
- `Scope`
- `Aliases`
## 写入与崩溃语义
JSONL store 的设计核心是“追加优先、宁可暂时读到旧数据也不要丢数据”:
- `AddMessage` / `AddFullMessage` 先追加一行 JSON,再 `fsync`,最后更新 metadata。
- `TruncateHistory` 先做逻辑截断,本质上只是推进 `meta.Skip`
- `Compact` 才会真正重写 JSONL 文件,把被跳过的旧行物理移除。
- `SetHistory``Compact` 都会先写 metadata 再改写 JSONL;如果中途崩溃,最多短时间暴露旧数据,不应丢数据。
- 读取 JSONL 时如果碰到损坏行,会跳过该行,而不是让整个 session 读取失败。
`JSONLBackend.Save` 对应到底层的 `store.Compact(...)`
也就是说,`Save` 在新实现里不再是“把内存脏数据刷盘”,而是“在逻辑截断后回收无效行占用的磁盘空间”。
## 并发模型
`pkg/memory.JSONLStore` 使用固定 64 分片 mutex,按 session key 的 hash 做串行化。
这样既能做到“按 session 串行”,又不会因为 session 数量增长而把 mutex map 做成无界结构。
旧的 `SessionManager` 则是一个内存 map 加 RW mutex。
这两个实现都满足同一个 `SessionStore` 接口,所以 agent loop 不需要写任何存储后端特化逻辑。
## 兼容与迁移
`pkg/agent/instance.go:initSessionStore` 会优先初始化 JSONL 后端。
启动过程如下:
1. 创建 `memory.NewJSONLStore(dir)`
2. 执行 `memory.MigrateFromJSON(...)`,把旧 `.json` session 迁入新格式。
3.`session.NewJSONLBackend(store)` 包装。
4. 如果 JSONL 初始化或迁移失败,则回退到 `session.NewSessionManager(dir)`
这个回退是刻意设计的:做一半的迁移,比整轮继续使用旧后端更危险。
### Alias 提升
第一次为 canonical key 建 metadata 时,`EnsureSessionMetadata` 会尝试把某个非空 legacy alias 的历史提升到 canonical session。
但这件事只会在 canonical session 仍然为空时发生,因此不会覆盖已经存在的 canonical 历史。
这保证了系统在迁移到 opaque key 的同时,仍能保留旧历史,例如:
- 旧的 direct-message key
- 旧的 Pico direct-session key
## 其他 SessionStore 实现
`pkg/agent/subturn.go` 里定义了 `ephemeralSessionStore`
它同样实现 `SessionStore`,但只存在于内存里,在 sub-turn 结束时销毁。
这样 SubTurn 就能复用相同的 session 接口,而不会把子任务历史写进父会话的持久存储。
## 运行时消费者
Session 系统不只被 agent loop 使用:
- `web/backend/api/session.go` 会读取 JSONL metadata 和旧 JSON session,并把历史暴露给 launcher UI。
- `pkg/agent/steering.go` 可以在 steering 场景下恢复 scope metadata。
- 因为 alias 解析发生在 agent loop 之下,测试和工具仍然可以继续使用 legacy alias。
## 相关文件
- `pkg/session/session_store.go`
- `pkg/session/manager.go`
- `pkg/session/jsonl_backend.go`
- `pkg/session/scope.go`
- `pkg/session/key.go`
- `pkg/session/allocator.go`
- `pkg/memory/jsonl.go`
- `pkg/agent/instance.go`
- `pkg/agent/loop.go`
- `pkg/agent/loop_message.go`
+2
View File
@@ -4,6 +4,8 @@ Task-oriented guides for setup, configuration, and common PicoClaw workflows.
- [Docker & Quick Start Guide](docker.md): install and run PicoClaw with Docker or the launcher.
- [Configuration Guide](configuration.md): environment variables, workspace layout, routing, and sandbox settings.
- [Session Guide](session-guide.md): how session scope affects memory sharing, summaries, and isolation.
- [Routing Guide](routing-guide.md): agent dispatch, session overrides, and light-model routing.
- [Chat Apps Configuration](chat-apps.md): supported chat platforms and channel-specific setup paths.
- [Providers & Model Configuration](providers.md): `model_list`, providers, and model routing.
- [Spawn & Async Tasks](spawn-tasks.md): background work, long-running tasks, and sub-agent orchestration.
+11
View File
@@ -122,6 +122,15 @@ dammi le ultime news
- Unknown slash command (for example `/foo`) passes through to normal LLM processing.
- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing.
### Session Isolation
Session scope controls how much memory is shared between chats, users, threads, and spaces.
- Use `session.dimensions` for the global default.
- Use `session_dimensions` on a dispatch rule for one routed exception.
For step-by-step recipes and isolation patterns, see the [Session Guide](session-guide.md).
### Routing
Routing is configured through `agents.dispatch.rules`.
@@ -195,6 +204,8 @@ In the example above, the VIP rule must appear before the broader group rule.
Because routing is strictly ordered, more specific rules should be placed
earlier and broader fallback rules later.
For more complete routing and model-tier examples, see the [Routing Guide](routing-guide.md).
### 🔒 Security Sandbox
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
+80
View File
@@ -120,6 +120,86 @@ dammi le ultime news
- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。
- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。
### Session 隔离
Session scope 决定了聊天、用户、线程和 space 之间共享多少上下文。
- 全局默认值使用 `session.dimensions`
- 如果只想让某条路由例外,使用 dispatch rule 上的 `session_dimensions`
如果你想看完整的隔离方案和配置配方,请看 [Session 使用指南](session-guide.zh.md)。
### Routing
Routing 通过 `agents.dispatch.rules` 配置。
每条规则都针对 channel 归一化后的 inbound context 做匹配。
规则按从上到下顺序检查,第一条命中的规则立即生效。若没有规则命中,PicoClaw 会回退到默认 agent。
支持的匹配字段:
* `channel`
* `account`
* `space`
* `chat`
* `topic`
* `sender`
* `mentioned`
这些值使用和 session system 一致的归一化词汇:
* `space`: `workspace:t001``guild:123456`
* `chat`: `direct:user123``group:-100123``channel:c123`
* `topic`: `topic:42`
* `sender`: 平台归一化后的 sender 标识
规则也可以通过 `session_dimensions` 覆盖全局 `session.dimensions`,这样路由和会话隔离就能保持一致,而不必回到旧的 `bindings``dm_scope` 配置。
示例:
```json
{
"agents": {
"list": [
{ "id": "main", "default": true },
{ "id": "support" },
{ "id": "sales" }
],
"dispatch": {
"rules": [
{
"name": "vip in support group",
"agent": "sales",
"when": {
"channel": "telegram",
"chat": "group:-1001234567890",
"sender": "12345"
},
"session_dimensions": ["chat", "sender"]
},
{
"name": "telegram support group",
"agent": "support",
"when": {
"channel": "telegram",
"chat": "group:-1001234567890"
},
"session_dimensions": ["chat"]
}
]
}
},
"session": {
"dimensions": ["chat"]
}
}
```
在这个例子里,VIP 规则必须放在更宽泛的群规则前面。
因为 routing 是严格按顺序执行的,所以更具体的规则要放前面,兜底规则放后面。
如果你想看更完整的 agent 路由和模型分层示例,请看 [路由使用指南](routing-guide.zh.md)。
### 🔒 安全沙箱 (Security Sandbox)
PicoClaw 默认在沙箱环境中运行。Agent 只能访问配置的工作区内的文件和执行命令。
+2
View File
@@ -35,6 +35,8 @@
> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!**
For agent dispatch and light-model routing examples, see the [Routing Guide](routing-guide.md).
This design also enables **multi-agent support** with flexible provider selection:
- **Different agents, different providers**: Each agent can use its own LLM provider
+2
View File
@@ -34,6 +34,8 @@
> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!**
如果你想看 agent 分发和轻量模型路由的完整示例,请看 [路由使用指南](routing-guide.zh.md)。
该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择:
- **不同 Agent 使用不同 Provider**:每个 Agent 可以使用自己的 LLM provider
+331
View File
@@ -0,0 +1,331 @@
# Routing Guide
> Back to [README](../README.md)
In PicoClaw, routing has two user-facing parts:
- **agent routing**: choose which agent should handle a message
- **model routing**: choose whether a turn should use the primary model or the configured light model
This guide explains how to configure both for real deployments.
## Quick Start
### Route one Telegram group to a support agent
```json
{
"agents": {
"list": [
{ "id": "main", "default": true },
{ "id": "support" }
],
"dispatch": {
"rules": [
{
"name": "telegram support group",
"agent": "support",
"when": {
"channel": "telegram",
"chat": "group:-1001234567890"
}
}
]
}
}
}
```
### Route only Slack mentions in one workspace
```json
{
"agents": {
"list": [
{ "id": "main", "default": true },
{ "id": "support" }
],
"dispatch": {
"rules": [
{
"name": "slack mentions",
"agent": "support",
"when": {
"channel": "slack",
"space": "workspace:t001",
"mentioned": true
}
}
]
}
}
}
```
### Use a light model for simple turns
```json
{
"model_list": [
{
"model_name": "gpt-main",
"model": "openai/gpt-5.4",
"api_keys": ["sk-main"]
},
{
"model_name": "flash-light",
"model": "gemini/gemini-2.0-flash-exp",
"api_keys": ["sk-light"]
}
],
"agents": {
"defaults": {
"model_name": "gpt-main",
"routing": {
"enabled": true,
"light_model": "flash-light",
"threshold": 0.35
}
}
}
}
```
## Agent Routing
Agent routing is configured with:
```text
agents.dispatch.rules
```
Rules are evaluated from top to bottom.
The **first matching rule wins**.
If no rule matches, PicoClaw falls back to the default agent.
## Supported Match Fields
| Field | Meaning | Example |
| --- | --- | --- |
| `channel` | Channel name | `telegram`, `slack`, `discord` |
| `account` | Normalized account ID | `default`, `bot2` |
| `space` | Workspace, guild, or similar container | `workspace:t001`, `guild:123456` |
| `chat` | Direct chat, group, or channel | `direct:user123`, `group:-100123`, `channel:c123` |
| `topic` | Thread or topic | `topic:42` |
| `sender` | Normalized sender identity | `12345`, `john` |
| `mentioned` | Whether the bot was explicitly mentioned | `true` |
Values must match the normalized runtime shape, not the raw incoming payload.
## Rule Ordering
Put more specific rules before broader rules.
Good:
1. VIP sender inside one group
2. all traffic for that group
3. channel-wide fallback
Bad:
1. all traffic for that group
2. VIP sender inside the same group
In the bad ordering, the broad rule wins first and the VIP rule never runs.
## Session Interaction
Routing and sessions are related but different.
- routing decides which agent handles the message
- session settings decide which messages share memory
You can override the global `session.dimensions` value for one matched rule with `session_dimensions`.
Example:
```json
{
"agents": {
"list": [
{ "id": "main", "default": true },
{ "id": "support" },
{ "id": "sales" }
],
"dispatch": {
"rules": [
{
"name": "vip in support group",
"agent": "sales",
"when": {
"channel": "telegram",
"chat": "group:-1001234567890",
"sender": "12345"
},
"session_dimensions": ["chat", "sender"]
},
{
"name": "support group",
"agent": "support",
"when": {
"channel": "telegram",
"chat": "group:-1001234567890"
},
"session_dimensions": ["chat"]
}
]
}
},
"session": {
"dimensions": ["chat"]
}
}
```
In this configuration:
- the VIP gets routed to `sales`
- everyone else in the group goes to `support`
- the VIP route also gets per-user session isolation
## Identity Links
`session.identity_links` also affects routing when you match on `sender`.
Use it when the same real user may appear under multiple raw sender IDs.
Example:
```json
{
"session": {
"identity_links": {
"john": ["slack:u123", "legacy-user-42"]
}
},
"agents": {
"dispatch": {
"rules": [
{
"name": "john goes to sales",
"agent": "sales",
"when": {
"sender": "john"
}
}
]
}
}
}
```
## Model Routing
Model routing is configured under:
```text
agents.defaults.routing
```
Current fields:
| Field | Meaning |
| --- | --- |
| `enabled` | Turn model routing on or off |
| `light_model` | `model_name` from `model_list` used for simple turns |
| `threshold` | Complexity cutoff in `[0, 1]` |
Important behavior:
- the light model must exist in `model_list`
- PicoClaw resolves the light model at startup; if it is invalid, routing is disabled
- one turn stays on one model tier, even if it later calls tools
## What Affects The Complexity Score
The current model router looks at structural signals such as:
- message length
- fenced code blocks
- recent tool calls in the same session
- conversation depth
- media or attachments
This means a "simple" turn may still go to the primary model if it includes:
- code
- images or audio
- a very long prompt
- a tool-heavy ongoing workflow
## Choosing A Threshold
Recommended starting point:
```json
{
"agents": {
"defaults": {
"routing": {
"enabled": true,
"light_model": "flash-light",
"threshold": 0.35
}
}
}
}
```
General rule:
- lower threshold: use the primary model more often
- higher threshold: use the light model more aggressively
Practical suggestions:
- `0.25` if you want safer routing with fewer light-model turns
- `0.35` as the default starting point
- `0.50+` only if your light model is already strong enough for most chat traffic
## Troubleshooting
### A rule is not matching
Check:
- rule order
- normalized value shape such as `group:-100123` instead of just `-100123`
- whether the channel actually provides `space`, `topic`, or `mentioned`
### The wrong agent handles a message
The most common cause is ordering.
Remember: first match wins.
### The light model is never used
Check:
- `agents.defaults.routing.enabled` is `true`
- `light_model` exists in `model_list`
- the light model can actually initialize
- your threshold is not too low
### The primary model is still chosen for short messages
That can still happen when the turn includes:
- a code block
- media or attachments
- recent tool-heavy history
### Routing works, but the conversation memory is still too shared
Adjust `session.dimensions` globally or `session_dimensions` on the specific route.
Routing chooses the agent, but sessions decide context sharing.
## Related Guides
- [Session Guide](session-guide.md)
- [Configuration Guide](configuration.md)
- [Providers & Model Configuration](providers.md)
+331
View File
@@ -0,0 +1,331 @@
# 路由使用指南
> 返回 [README](../project/README.zh.md)
PicoClaw 里用户能直接感知到的“路由”主要有两部分:
- **agent 路由**:决定哪一个 agent 处理一条消息
- **模型路由**:决定这一轮是走主模型,还是走轻量模型
这份文档面向真实部署中的配置使用场景。
## 快速开始
### 把一个 Telegram 群路由给 support agent
```json
{
"agents": {
"list": [
{ "id": "main", "default": true },
{ "id": "support" }
],
"dispatch": {
"rules": [
{
"name": "telegram support group",
"agent": "support",
"when": {
"channel": "telegram",
"chat": "group:-1001234567890"
}
}
]
}
}
}
```
### 只处理某个 Slack workspace 里的 @提及
```json
{
"agents": {
"list": [
{ "id": "main", "default": true },
{ "id": "support" }
],
"dispatch": {
"rules": [
{
"name": "slack mentions",
"agent": "support",
"when": {
"channel": "slack",
"space": "workspace:t001",
"mentioned": true
}
}
]
}
}
}
```
### 给简单请求启用轻量模型
```json
{
"model_list": [
{
"model_name": "gpt-main",
"model": "openai/gpt-5.4",
"api_keys": ["sk-main"]
},
{
"model_name": "flash-light",
"model": "gemini/gemini-2.0-flash-exp",
"api_keys": ["sk-light"]
}
],
"agents": {
"defaults": {
"model_name": "gpt-main",
"routing": {
"enabled": true,
"light_model": "flash-light",
"threshold": 0.35
}
}
}
}
```
## Agent 路由
Agent 路由通过下面这个配置项定义:
```text
agents.dispatch.rules
```
规则从上到下依次检查。
**第一条匹配的规则直接生效**。
如果没有规则命中,PicoClaw 会回退到默认 agent。
## 支持的匹配字段
| 字段 | 含义 | 示例 |
| --- | --- | --- |
| `channel` | Channel 名称 | `telegram``slack``discord` |
| `account` | 归一化后的 account ID | `default``bot2` |
| `space` | workspace、guild 等上层容器 | `workspace:t001``guild:123456` |
| `chat` | 私聊、群或频道 | `direct:user123``group:-100123``channel:c123` |
| `topic` | 线程或话题 | `topic:42` |
| `sender` | 归一化后的发送者身份 | `12345``john` |
| `mentioned` | 是否显式 @ 了 bot | `true` |
注意,配置里要写的是运行时归一化后的值,不是原始 webhook / SDK payload。
## 规则顺序
把更具体的规则放前面,把更宽泛的规则放后面。
正确顺序:
1. 某个群里的 VIP 用户
2. 这个群的全部消息
3. 某个 channel 的更宽泛兜底
错误顺序:
1. 这个群的全部消息
2. 同一个群里的 VIP 用户
在错误顺序下,宽泛规则会先命中,VIP 规则永远不会生效。
## 和 Session 的关系
路由和 Session 是相关但不同的两件事:
- 路由决定由哪个 agent 处理
- Session 决定这些消息是否共享同一段记忆
如果你想让某条命中的路由使用不同的会话策略,可以用 `session_dimensions` 覆盖全局 `session.dimensions`
示例:
```json
{
"agents": {
"list": [
{ "id": "main", "default": true },
{ "id": "support" },
{ "id": "sales" }
],
"dispatch": {
"rules": [
{
"name": "vip in support group",
"agent": "sales",
"when": {
"channel": "telegram",
"chat": "group:-1001234567890",
"sender": "12345"
},
"session_dimensions": ["chat", "sender"]
},
{
"name": "support group",
"agent": "support",
"when": {
"channel": "telegram",
"chat": "group:-1001234567890"
},
"session_dimensions": ["chat"]
}
]
}
},
"session": {
"dimensions": ["chat"]
}
}
```
在这个配置里:
- VIP 用户会被路由到 `sales`
- 其他群成员会进入 `support`
- VIP 路由还会额外按 `chat + sender` 做每用户隔离
## Identity Links
当你用 `sender` 做匹配时,`session.identity_links` 也会影响路由结果。
适合这种场景:同一个真实用户可能出现为多个原始 sender ID。
示例:
```json
{
"session": {
"identity_links": {
"john": ["slack:u123", "legacy-user-42"]
}
},
"agents": {
"dispatch": {
"rules": [
{
"name": "john goes to sales",
"agent": "sales",
"when": {
"sender": "john"
}
}
]
}
}
}
```
## 模型路由
模型路由配置在:
```text
agents.defaults.routing
```
当前支持字段:
| 字段 | 含义 |
| --- | --- |
| `enabled` | 开启或关闭模型路由 |
| `light_model` | `model_list` 中用于简单请求的 `model_name` |
| `threshold` | `[0, 1]` 范围内的复杂度阈值 |
关键行为:
- `light_model` 必须存在于 `model_list`
- PicoClaw 会在启动时解析轻量模型;如果模型无效,路由会被禁用
- 同一轮 turn 只会使用同一档模型,不会中途切档
## 什么会影响复杂度分数
当前模型路由会看一些结构化信号,例如:
- 消息长度
- fenced code block
- 同一 session 最近是否频繁调用工具
- 会话深度
- 是否带有媒体或附件
因此,看起来“很简单”的消息,在以下情况下仍可能走主模型:
- 带代码
- 带图片或音频
- prompt 很长
- 当前是一个工具调用很多的工作流
## 阈值怎么选
推荐起点:
```json
{
"agents": {
"defaults": {
"routing": {
"enabled": true,
"light_model": "flash-light",
"threshold": 0.35
}
}
}
}
```
通用规律:
- 阈值越低,越容易回到主模型
- 阈值越高,越积极地使用轻量模型
实用建议:
- `0.25`:更保守,更少轻量模型 turn
- `0.35`:默认推荐起点
- `0.50+`:只有当你的轻量模型已经能覆盖大多数聊天任务时再考虑
## 常见问题
### 某条规则没有命中
优先检查:
- 规则顺序
- 值的形状是否写成了归一化格式,例如 `group:-100123` 而不是裸 `-100123`
- 当前 channel 是否真的提供了 `space``topic``mentioned`
### 消息被错误的 agent 处理了
最常见原因还是顺序。
记住:第一条匹配的规则直接生效。
### 轻量模型从来没有被用到
检查:
- `agents.defaults.routing.enabled` 是否为 `true`
- `light_model` 是否存在于 `model_list`
- 轻量模型能否成功初始化
- 阈值是不是设得太低
### 明明是短消息,还是走了主模型
这通常是因为当前 turn 同时满足了其他“复杂”信号,例如:
- 带代码块
- 带媒体或附件
- 最近的 session 历史里工具调用很多
### 路由没问题,但上下文还是共享得太多
去调整 `session.dimensions` 或某条 route 上的 `session_dimensions`
路由只决定“谁来处理”,session 才决定“记忆怎么共享”。
## 相关文档
- [Session 使用指南](session-guide.zh.md)
- [配置指南](configuration.zh.md)
- [Provider 与模型配置](providers.zh.md)
+273
View File
@@ -0,0 +1,273 @@
# Session Guide
> Back to [README](../README.md)
PicoClaw sessions decide which messages share the same conversation history.
If your bot "remembers too much" or "forgets too much", the first thing to check is the session configuration.
This guide is for users configuring session behavior in `config.json`.
For implementation details, see the architecture docs instead.
## What Sessions Control
A session controls:
- which previous messages are visible to the agent
- when summarization starts for that conversation
- whether two users in the same group share context
- whether different chats, threads, or spaces stay isolated
Session data is stored under your workspace, typically:
```text
~/.picoclaw/workspace/sessions/
```
## Quick Start
### Default: one context per chat
This is the default and is the right choice for most bots.
```json
{
"session": {
"dimensions": ["chat"]
}
}
```
Use this when:
- each group/channel should have its own shared memory
- each direct message should have its own separate memory
### Separate each user inside a group
If users in the same group should not share memory, add `sender`:
```json
{
"session": {
"dimensions": ["chat", "sender"]
}
}
```
Use this when:
- one shared assistant sits in a busy group
- each user should keep a private thread of context even inside the same room
### Share one context across multiple rooms in the same workspace or guild
If your channel exposes a `space` value, you can route by workspace or guild instead of by room:
```json
{
"session": {
"dimensions": ["space"]
}
}
```
Use this when:
- a Slack workspace assistant should share context across channels
- a Discord guild assistant should share context across channels
### Split by thread or forum topic
If your channel exposes `topic`, you can isolate per thread:
```json
{
"session": {
"dimensions": ["chat", "topic"]
}
}
```
Use this when:
- each forum topic should keep its own history
- each threaded discussion should stay separate
## Available Dimensions
| Dimension | What it means | Good for |
| --- | --- | --- |
| `space` | Workspace, guild, or similar top-level container | One shared assistant across many rooms |
| `chat` | Direct chat, group, or channel | Default per-room isolation |
| `topic` | Thread, topic, or forum sub-channel | Keep threaded discussions separate |
| `sender` | The message sender after normalization | Per-user context inside shared rooms |
Not every channel provides every field.
If a channel does not supply `space` or `topic`, those dimensions simply have no effect for that message.
## Important Behavior
### Sessions are always separated by agent
Even if two agents receive messages from the same chat, they do not share one session.
### Sessions are still separated by channel and account
`session.dimensions` adds finer-grained isolation, but PicoClaw still keeps a baseline separation by:
- agent
- channel
- account
That means an empty or very small `dimensions` list does **not** create one global memory across every platform.
### Telegram forum topics already stay isolated in the default `chat` mode
Telegram forum messages keep topic isolation by default even when `dimensions` only contains `chat`.
You usually do not need a special workaround for Telegram forums.
### Summaries happen per session
`summarize_message_threshold` and `summarize_token_percent` apply inside each session independently.
If you create smaller sessions, summarization also happens on smaller per-session histories.
## Common Recipes
### One shared assistant per group or direct chat
```json
{
"session": {
"dimensions": ["chat"]
}
}
```
### One context per user inside each chat
```json
{
"session": {
"dimensions": ["chat", "sender"]
}
}
```
### One context per sender across one workspace or guild
```json
{
"session": {
"dimensions": ["space", "sender"]
}
}
```
This is useful for workspace-wide assistants where each user should keep their own memory while moving across rooms in the same workspace.
### Use a different session policy for one routed agent only
You can keep the global default and override it for one dispatch rule:
```json
{
"agents": {
"list": [
{ "id": "main", "default": true },
{ "id": "support" }
],
"dispatch": {
"rules": [
{
"name": "support group",
"agent": "support",
"when": {
"channel": "telegram",
"chat": "group:-1001234567890"
},
"session_dimensions": ["chat", "sender"]
}
]
}
},
"session": {
"dimensions": ["chat"]
}
}
```
In this example:
- most traffic uses one shared context per chat
- the support group uses one context per user inside that chat
## Identity Links
`session.identity_links` helps when the same user may appear under multiple raw sender IDs and you want PicoClaw to treat them as one sender identity.
Example:
```json
{
"session": {
"dimensions": ["chat", "sender"],
"identity_links": {
"john": ["slack:u123", "u123", "legacy-user-42"]
}
}
}
```
This is mainly useful for:
- migrated sender IDs
- platform-specific ID aliases
- cleanup after changing channel adapters or account naming
Current limitation:
- `identity_links` does not make one user share memory across different channels automatically
- channel and account remain part of the baseline session scope
## Troubleshooting
### Users in one group are sharing memory
Your current session is probably keyed only by `chat`.
Switch to:
```json
{
"session": {
"dimensions": ["chat", "sender"]
}
}
```
### The same user does not share memory across Slack and Telegram
That is expected.
PicoClaw still separates sessions by channel even if you use `sender`.
### Threads are mixing together
Add `topic` when the channel provides one:
```json
{
"session": {
"dimensions": ["chat", "topic"]
}
}
```
### Old sessions seem to use legacy keys
That is normal during migration.
PicoClaw keeps compatibility with older `agent:...` session keys while moving runtime storage to opaque canonical keys.
## Related Guides
- [Configuration Guide](configuration.md)
- [Routing Guide](routing-guide.md)
- [Providers & Model Configuration](providers.md)
+273
View File
@@ -0,0 +1,273 @@
# Session 使用指南
> 返回 [README](../project/README.zh.md)
PicoClaw 的 Session 决定了哪些消息会共享同一段对话历史。
如果你的 bot 表现为“记得太多”或“忘得太快”,首先就该检查 session 配置。
这份文档面向编辑 `config.json` 的普通用户。
如果你想看内部实现细节,请看 architecture 文档,而不是这里。
## Session 控制什么
一个 session 会影响:
- Agent 能看到哪些历史消息
- 这段对话何时开始触发摘要
- 同一个群里的不同用户是否共享上下文
- 不同聊天、不同线程、不同空间是否保持隔离
Session 数据保存在工作区目录下,通常是:
```text
~/.picoclaw/workspace/sessions/
```
## 快速开始
### 默认:每个 chat 一段上下文
这是默认值,也是大多数 bot 的正确起点。
```json
{
"session": {
"dimensions": ["chat"]
}
}
```
适用场景:
- 每个群 / 频道都有自己的共享记忆
- 每个私聊都有各自独立的记忆
### 在同一个群里按用户分开
如果同一个群里的不同用户不应该共享上下文,增加 `sender`
```json
{
"session": {
"dimensions": ["chat", "sender"]
}
}
```
适用场景:
- 一个群里挂着一个共享 assistant,但不希望用户之间串上下文
- 希望每个用户在同一个房间里保留自己的独立记忆
### 在同一个 workspace / guild 下跨多个房间共享上下文
如果你的 channel 会提供 `space`,可以按 workspace 或 guild 共享,而不是按单个房间共享:
```json
{
"session": {
"dimensions": ["space"]
}
}
```
适用场景:
- Slack workspace 里的 assistant 想跨多个 channel 共享上下文
- Discord guild 里的 assistant 想跨多个 channel 共享上下文
### 按线程或论坛 topic 隔离
如果 channel 会提供 `topic`,可以显式按线程隔离:
```json
{
"session": {
"dimensions": ["chat", "topic"]
}
}
```
适用场景:
- 每个论坛 topic 都要保留独立历史
- 每个 threaded discussion 都不能串上下文
## 可用维度
| 维度 | 含义 | 适合什么场景 |
| --- | --- | --- |
| `space` | workspace、guild 或类似的上层容器 | 一个 assistant 跨多个房间共享上下文 |
| `chat` | 私聊、群聊或频道 | 默认按房间隔离 |
| `topic` | 线程、topic 或 forum 子通道 | 让 threaded discussion 保持隔离 |
| `sender` | 归一化后的消息发送者 | 在共享房间内按用户隔离 |
并不是每个 channel 都会提供全部字段。
如果某个 channel 没有 `space``topic`,对应维度对那条消息就不会生效。
## 关键行为
### Session 总是按 agent 分开
即使两个 agent 处理同一个 chat,它们也不会共享同一段 session。
### Session 仍然会按 channel 和 account 分开
`session.dimensions` 只是添加更细的隔离维度,PicoClaw 仍然保留一层基础隔离:
- agent
- channel
- account
这意味着即使 `dimensions` 为空,系统也**不会**把所有平台的消息都混成一个全局记忆。
### Telegram forum topic 在默认 `chat` 模式下也会保持隔离
Telegram forum 消息在默认 `chat` 模式下就会保留 topic 隔离。
通常不需要额外为 Telegram forum 单独写 workaround。
### 摘要是按 session 触发的
`summarize_message_threshold``summarize_token_percent` 都是针对单个 session 生效。
如果你把 session 切得更小,摘要也会按更小的历史范围触发。
## 常见配置方案
### 每个群 / 私聊共享一段上下文
```json
{
"session": {
"dimensions": ["chat"]
}
}
```
### 每个 chat 内再按用户拆分
```json
{
"session": {
"dimensions": ["chat", "sender"]
}
}
```
### 在同一个 workspace / guild 内按用户保留上下文
```json
{
"session": {
"dimensions": ["space", "sender"]
}
}
```
这适合做 workspace 级 assistant:用户在同一个 workspace 里跨多个房间移动,但仍保留自己的上下文。
### 只给某个路由出来的 agent 覆盖 session 策略
你可以保留全局默认值,再在某条 dispatch rule 上单独覆盖:
```json
{
"agents": {
"list": [
{ "id": "main", "default": true },
{ "id": "support" }
],
"dispatch": {
"rules": [
{
"name": "support group",
"agent": "support",
"when": {
"channel": "telegram",
"chat": "group:-1001234567890"
},
"session_dimensions": ["chat", "sender"]
}
]
}
},
"session": {
"dimensions": ["chat"]
}
}
```
在这个例子里:
- 大部分流量仍然按 `chat` 共享上下文
- 只有 support 群按 `chat + sender` 拆成每人一段上下文
## Identity Links
`session.identity_links` 适合处理这种场景:同一个人可能会以多个原始 sender ID 出现,但你希望 PicoClaw 把它们视为同一个发送者身份。
示例:
```json
{
"session": {
"dimensions": ["chat", "sender"],
"identity_links": {
"john": ["slack:u123", "u123", "legacy-user-42"]
}
}
}
```
这主要适用于:
- sender ID 迁移
- 同一平台下的多个 ID 别名
- 调整 channel adapter 或 account 命名后的兼容清理
当前限制:
- `identity_links` 不会自动让同一个用户跨不同 channel 共享记忆
- channel 和 account 仍然属于基础 session scope 的一部分
## 常见问题
### 同一个群里的用户在共享记忆
大概率是当前 session 只按 `chat` 建。
改成:
```json
{
"session": {
"dimensions": ["chat", "sender"]
}
}
```
### 同一个用户在 Slack 和 Telegram 之间没有共享记忆
这是当前实现下的预期行为。
即使使用了 `sender`PicoClaw 仍然会按 channel 做基础隔离。
### 不同线程混在一起了
如果这个 channel 提供 `topic`,加上它:
```json
{
"session": {
"dimensions": ["chat", "topic"]
}
}
```
### 升级后看到旧的 session key
这属于正常兼容行为。
PicoClaw 在迁移到新的 opaque canonical key 时,仍会兼容旧的 `agent:...` session key。
## 相关文档
- [配置指南](configuration.zh.md)
- [路由指南](routing-guide.zh.md)
- [Provider 与模型配置](providers.zh.md)