Skip to content

Commit bc37aff

Browse files
author
linyuan.yang
committed
整理 agenda
1 parent 4c70804 commit bc37aff

13 files changed

Lines changed: 556 additions & 215 deletions

File tree

packages/docs-site/guide/agenda.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ An **Agenda Profile** is the store + optional auto-sync. Sidebar → **Agenda Pr
3131

3232
Then, in an agent → **Agenda** section, toggle Agenda on and pick the profile. Enabling it registers the agenda tools; with a sync model, items are reconciled from the conversation automatically each turn.
3333

34-
AgendaSync normally uses one model call with the completed conversation and the full structure of every pending item. Only when that input exceeds the Sync Model budget does it reuse the same model to compress the conversation into explicit agenda-change intents, match compact cards in token-bounded batches (including item/trigger IDs, schedules, actions, and message previews), and give only the selected full records to the final sync pass. There is no separate Selector Model setting, and pending items are no longer cut off at a fixed count.
34+
AgendaSync normally uses one model call with the completed turn and the full structure of every pending item. When that input exceeds the Sync Model budget, it reuses the same model to extract up to 12 agenda-change intents, locally ranks the complete pending catalog, and screens compact cards in at most 6 token-bounded batches. Batch results carry cross-batch relevance scores; the final Writer receives at most 20 selected full records. If those records still exceed the budget, trigger messages become 60-character previews. An oversized turn is middle-truncated while preserving its beginning and end. The Selector's `shouldSync` result is advisory—the final Writer still verifies every overflow turn—and one failed card batch falls back to local ranking instead of discarding the entire sync. This bounds an overflow run to at most 8 model calls (analysis + 6 batches + final Writer). There is no separate Selector Model setting.
3535

3636
From the Agenda Profiles page → **View** you can browse stored items, filter by pending/done/cancelled/expired, manually **Complete** / **Cancel**, add/edit/disable/reopen/delete triggers, fire a trigger manually for testing, and inspect each trigger's fire history. Reopening an expired item restores only the item to Pending; retime or re-enable an appropriate trigger separately.
3737

packages/docs-site/zh/guide/agenda.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Agenda 是 sbot 的有状态提醒 / 日程系统。每个日程**条目**(待
3131

3232
随后在 Agent → **Agenda** 区块开启 Agenda 并选择 Profile。开启后会注册日程工具;配置 sync 模型后,每轮对话都会从对话内容自动校正日程条目。
3333

34-
AgendaSync 通常用一次模型调用读取本轮对话和全部待处理条目的完整结构。输入超过 Sync 模型预算时,系统才会复用同一个模型:先将对话压缩成明确的日程变更意图,再按 Token 预算分批匹配包含 item / trigger id、时间、action 和消息预览的紧凑卡片,最后只把候选完整结构交给同步抽取器。这里没有独立的 Selector 模型配置,也不再按固定条数截断待处理条目
34+
AgendaSync 通常用一次模型调用读取本轮对话和全部待处理条目的完整结构。输入超过 Sync 模型预算时,系统复用同一个模型抽取最多 12 条日程变更意图,先对完整 Pending 目录做本地相关性排序,再最多用 6 个 Token 预算批次筛选紧凑卡片。各批候选携带可跨批比较的相关性分数,最终 Writer 最多读取 20 条候选的完整结构;仍超预算时,触发消息改为 60 字符预览。若本轮对话本身过长,会保留开头与结尾并截去中段。Selector 的 `shouldSync` 只是建议,超预算路径仍会让最终 Writer 复核;单批失败也会回退到本地候选,不会丢掉整轮。因此一次降级同步最多调用模型 8 次(分析 1 次 + 卡片 6 批 + Writer 1 次)。这里没有独立的 Selector 模型配置。
3535

3636
在 Agenda Profiles 页面 → **查看** 可浏览已存条目、按待处理 / 已完成 / 已取消 / 已过期筛选、手动 **完成** / **取消**,也可以新增 / 编辑 / 停用 / 重新启用 / 删除触发器、手动触发测试,并查看每个触发器的触发历史。恢复已过期条目只会把主体改回待处理;仍需另外调整时间或启用合适的触发器。
3737

packages/sbot/prompts/agenda/sync/selector.txt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ You are the low-cost overflow stage for AgendaSync.
22

33
You never create, edit, close, or delete agenda items. Your only jobs are:
44

5-
1. Decide whether the completed conversation contains an explicit agenda change that the
5+
1. Advise whether the completed conversation contains an explicit agenda change that the
66
assistant may have failed to apply: a new todo/reminder/schedule/routine/automation, or
7-
an explicit edit to an existing one.
7+
an explicit edit to an existing one. The final writer still verifies the transcript, so
8+
this decision is not permission to mutate and is not a hard veto.
89
2. When asked to match a catalog batch, select the exact agenda item IDs that may be the
910
target or a semantic duplicate of those explicit changes.
1011

@@ -16,4 +17,6 @@ intent as agenda changes.
1617

1718
Agenda cards contain item content plus trigger IDs, schedules, actions, and a short message
1819
preview. Match references such as "the 9am one" using trigger data, not content alone.
19-
Candidate IDs must be copied exactly from the supplied batch. Return structured data only.
20+
Candidate IDs must be copied exactly from the supplied batch. For each candidate, include a
21+
0-100 relevance score calibrated so scores can be compared across batches. Return structured
22+
data only.

packages/sbot/skills/config-guide/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ DELETE /api/settings/agendaProfiles/:id
261261
- memory:`~/.sbot/memories/<memoryId>/`(全局)+ `workspaces/<pathHash>/`(当前 workPath 独有)
262262
- agenda:`~/.sbot/agendas/<agendaId>/agenda.db`
263263

264-
`enabled: false` 表示引用可以存在,但运行时不启用。`memoryProfiles.writerModel` 必须指向 `models` 中的 UUID;可选的 `selectorModel` 用于低成本目录筛选,不设置时复用 `writerModel`。频道仍只引用一个 memoryProfile;同一个 MemoryService 会同时读取全局记忆和当前 workPath 记忆,项目内容不会进入其他 workPath。未配置 workPath 的会话(包括普通 Web 对话)统一使用 `~/.sbot/workspace` 作为默认记忆工作区。`agendaProfiles.syncModel` 可选;为空时同步抽取不启用。AgendaSync 正常单次处理全部 Pending 条目超出模型输入预算时复用同一个 `syncModel` 做意图压缩和紧凑卡片筛选,不需要额外的 Selector 配置。
264+
`enabled: false` 表示引用可以存在,但运行时不启用。`memoryProfiles.writerModel` 必须指向 `models` 中的 UUID;可选的 `selectorModel` 用于低成本目录筛选,不设置时复用 `writerModel`。频道仍只引用一个 memoryProfile;同一个 MemoryService 会同时读取全局记忆和当前 workPath 记忆,项目内容不会进入其他 workPath。未配置 workPath 的会话(包括普通 Web 对话)统一使用 `~/.sbot/workspace` 作为默认记忆工作区。`agendaProfiles.syncModel` 可选;为空时同步抽取不启用。AgendaSync 正常单次处理全部 Pending 条目超出模型输入预算时复用同一个 `syncModel` 做意图分析,完整目录先本地相关性排序,再最多筛选 6 个紧凑卡片批次,最终 Writer 最多读取 20 条候选,不需要额外的 Selector 配置。
265265

266266
Agenda item 管理走:
267267

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import {
4+
AgendaExtractor,
5+
AgendaPriority,
6+
AgendaSource,
7+
AgendaStatus,
8+
AgendaTriggerKind,
9+
MessageRole,
10+
SessionDeliveryMode,
11+
estimateMessagesTokens,
12+
type AgendaRecord,
13+
type ChatMessage,
14+
type IModelService,
15+
} from "scorpio.ai";
16+
17+
class FakeModelService {
18+
readonly config = { contextWindow: 1_024 } as any;
19+
readonly calls: ChatMessage[][] = [];
20+
failCandidateCalls = false;
21+
analysis: any = { shouldSync: false, intents: [] };
22+
candidate: any = { candidates: [] };
23+
candidateFactory?: (messages: ChatMessage[]) => any;
24+
final: any = { actions: [] };
25+
failFinal = false;
26+
27+
async invokeStructured<T>(_schema: unknown, prompt: string | ChatMessage[]): Promise<T> {
28+
const messages = typeof prompt === 'string'
29+
? [{ role: MessageRole.Human, content: prompt }]
30+
: prompt;
31+
this.calls.push(messages);
32+
const system = String(messages[0]?.content ?? '');
33+
if (system.includes('# Conversation analysis mode')) return this.analysis as T;
34+
if (system.includes('# Agenda-card matching mode')) {
35+
if (this.failCandidateCalls) throw new Error('candidate failed');
36+
return (this.candidateFactory?.(messages) ?? this.candidate) as T;
37+
}
38+
if (this.failFinal) throw new Error('final failed');
39+
return this.final as T;
40+
}
41+
}
42+
43+
function record(id: number, messageSize = 240): AgendaRecord {
44+
const now = Date.now();
45+
return {
46+
item: {
47+
id,
48+
content: `事项 ${id}`,
49+
status: AgendaStatus.Pending,
50+
priority: AgendaPriority.Normal,
51+
assignee: 'user' as AgendaRecord['item']['assignee'],
52+
assigneeName: null,
53+
dueAt: now + id * 60_000,
54+
source: AgendaSource.User,
55+
createdAt: now,
56+
updatedAt: now,
57+
doneAt: null,
58+
},
59+
triggers: [{
60+
id,
61+
itemId: id,
62+
kind: AgendaTriggerKind.Absolute,
63+
expr: new Date(now + id * 60_000).toISOString(),
64+
action: SessionDeliveryMode.Notify,
65+
message: `提醒 ${id} ${'长'.repeat(messageSize)}`,
66+
channelSessionId: 0,
67+
enabled: true,
68+
fireCount: 0,
69+
maxFires: 1,
70+
lastFiredAt: null,
71+
nextFireAt: now + id * 60_000,
72+
createdAt: now,
73+
}],
74+
};
75+
}
76+
77+
function conversation(size = 1_200): ChatMessage[] {
78+
return [{ role: MessageRole.Human, content: `请更新事项 ${'内容'.repeat(size)}` }];
79+
}
80+
81+
test("overflow selector no-sync advice does not veto the final writer", async () => {
82+
const model = new FakeModelService();
83+
const extractor = new AgendaExtractor(model as unknown as IModelService, 'writer', 'selector');
84+
85+
const actions = await extractor.extract(conversation(), [record(1)]);
86+
87+
assert.deepEqual(actions, []);
88+
assert.equal(model.calls.length, 2);
89+
assert.match(String(model.calls[0][0].content), /Conversation analysis mode/);
90+
assert.match(String(model.calls[1][0].content), /Oversized-catalog candidate contract/);
91+
assert.ok(model.calls.every(call => estimateMessagesTokens(call) <= 512));
92+
});
93+
94+
test("overflow candidate scan has a hard six-batch model-call cap", async () => {
95+
const model = new FakeModelService();
96+
model.analysis = { shouldSync: true, intents: ['修改事项 40 的提醒时间'] };
97+
const extractor = new AgendaExtractor(model as unknown as IModelService, 'writer', 'selector');
98+
99+
await extractor.extract(conversation(), Array.from({ length: 40 }, (_, index) => record(index + 1)));
100+
101+
const candidateCalls = model.calls.filter(call => String(call[0]?.content ?? '').includes('# Agenda-card matching mode'));
102+
assert.equal(candidateCalls.length, 6);
103+
assert.equal(model.calls.length, 8); // analysis + six batches + final writer
104+
});
105+
106+
test("one failed candidate batch falls back locally and still reaches the writer", async () => {
107+
const model = new FakeModelService();
108+
model.analysis = { shouldSync: true, intents: ['修改事项 1'] };
109+
model.failCandidateCalls = true;
110+
const extractor = new AgendaExtractor(model as unknown as IModelService, 'writer', 'selector');
111+
112+
const actions = await extractor.extract(conversation(), [record(1)]);
113+
114+
assert.deepEqual(actions, []);
115+
assert.equal(model.calls.length, 3);
116+
});
117+
118+
test("candidate relevance scores are merged globally instead of keeping batch order", async () => {
119+
const model = new FakeModelService();
120+
model.config.contextWindow = 4_096;
121+
model.analysis = { shouldSync: true, intents: ['修改事项'] };
122+
model.candidateFactory = messages => {
123+
const human = String(messages[1]?.content ?? '');
124+
const ids = [...human.matchAll(/<agenda id="(\d+)"/g)].map(match => Number(match[1]));
125+
return { candidates: ids.map(id => ({ id, relevance: id })) };
126+
};
127+
const extractor = new AgendaExtractor(model as unknown as IModelService, 'writer', 'selector');
128+
129+
await extractor.extract(conversation(), Array.from({ length: 8 }, (_, index) => record(index + 1)));
130+
131+
const finalHuman = String(model.calls.at(-1)?.[1]?.content ?? '');
132+
const finalIds = [...finalHuman.matchAll(/<agenda id="(\d+)"/g)].map(match => Number(match[1]));
133+
assert.ok(finalIds.length > 1);
134+
assert.deepEqual(finalIds, [...finalIds].sort((a, b) => b - a));
135+
});
136+
137+
test("a final writer failure propagates so the pending job can be marked failed", async () => {
138+
const model = new FakeModelService();
139+
model.failFinal = true;
140+
const extractor = new AgendaExtractor(model as unknown as IModelService, 'writer', 'selector');
141+
142+
await assert.rejects(() => extractor.extract(conversation(), [record(1)]), /final failed/);
143+
});

0 commit comments

Comments
 (0)