mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-07-28 01:27:58 +00:00
docs: add session and routing documentation (#2571)
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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 key,agent 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`
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 只能访问配置的工作区内的文件和执行命令。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
|
||||
> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!**
|
||||
|
||||
如果你想看 agent 分发和轻量模型路由的完整示例,请看 [路由使用指南](routing-guide.zh.md)。
|
||||
|
||||
该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择:
|
||||
|
||||
- **不同 Agent 使用不同 Provider**:每个 Agent 可以使用自己的 LLM provider
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user