From 221622951f52da01ca9dafd8bc7159bc06e26bbc Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 17:23:12 +0800 Subject: [PATCH 01/40] docs: add main-session decouple spec and plan --- .../2026-05-17-decouple-main-session-plan.md | 1983 +++++++++++++++++ ...2026-05-16-decouple-main-session-design.md | 345 +++ 2 files changed, 2328 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-17-decouple-main-session-plan.md create mode 100644 docs/superpowers/specs/2026-05-16-decouple-main-session-design.md diff --git a/docs/superpowers/plans/2026-05-17-decouple-main-session-plan.md b/docs/superpowers/plans/2026-05-17-decouple-main-session-plan.md new file mode 100644 index 0000000..ebea1f5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-decouple-main-session-plan.md @@ -0,0 +1,1983 @@ +# Main Session 解耦实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 从 Stello 中彻底删除 Main Session 概念(类型、工厂、配置、存储)。统一为单一 Session;对话起点是 root session(普通 session,`parentId === null`)。原 Main 承担的跨 Session 综合 / insights 推送外包给外部 orchestrator,框架仅暴露纯数据 SDK API。 + +**Architecture:** 改动横跨 `@stello-ai/session` 与 `@stello-ai/core` 两个 package。Session 层只剩一种 Session 接口与一个 `SessionStorage` 接口;Core 层把 `SessionTree` 三个工厂收敛为 `createSession`,删 `MAIN_SESSION_ID` 常量与所有 `MainSessionConfig` / `mainSessionLoader` / `integrate()` 配套。新增的 SDK 方法挂在 `StelloAgent`,依赖一个新的顶层 `storage: SessionStorage` 注入点。 + +**Tech Stack:** TypeScript 严格模式 / pnpm monorepo / Vitest / tsup。Spec 在 `docs/superpowers/specs/2026-05-16-decouple-main-session-design.md`。 + +**设计选择(已与用户对齐):** +- Storage 注入点:顶层 `StelloAgentConfig.storage` +- `getTopology()` 形态:`SessionTreeNode[]` 森林数组 +- 实现与测试改动放在同一个任务内(同一 commit) +- 不留 deprecated alias / 不写迁移工具(spec §7.3) +- demo / devtools / visualizer 不在本计划范围(spec §7.2) + +**总体顺序:** + +``` +A. Session 包 bottom-up B. Core 包 types 层 + 1 SessionMeta 瘦身 5 MAIN_SESSION_ID 删除 + 2 SessionStorage 收敛 6 MainSessionConfig 类型清理 + 3 MainSession 工厂删除 7 Adapter MainSession 类型清理 + 4 Session index 导出收敛 8 llm/defaults integrate 删除 + +C. Core 包 SessionTree + Engine + Agent D. SDK API + 9 SessionTree 重写多 root 12 topology / list SDK + 10 Engine fork main 分支删除 13 data-IO SDK + storage 注入 + 11 StelloAgent main 配套删除 + +E. 收尾 + 14 core/types.ts 与 core/index.ts 导出收敛 + 15 全量 typecheck + test + CHANGELOG +``` + +每个任务结束都跑一次 `pnpm typecheck` 和包内 `pnpm test`,保证下一任务起点是绿的。**所有任务全部完成前不要 release / 不要打 tag**。 + +--- + +## Task 1: 删除 SessionMeta 上的 role / tags / metadata 字段 + +**目标:** Session 元数据只保留 `id / label / status / createdAt / updatedAt`,对外语义统一。 + +**Files:** +- Modify: `packages/session/src/types/session.ts` +- Modify: `packages/session/src/types/storage.ts:1` (drop unused `SessionFilter` import path) +- Modify: `packages/session/src/create-session.ts:387-396, 442-451, 482-493` +- Modify: `packages/session/src/create-main-session.ts:353-365, 437-448`(仅过渡兼容;Task 3 整体删除该文件) +- Modify: `packages/session/src/mocks/in-memory-storage.ts:27-40` +- Modify: `packages/session/src/__tests__/meta.test.ts` +- Modify: `packages/session/src/__tests__/lifecycle.test.ts:15-16, 26-28, 64-90` +- Modify: `packages/session/src/__tests__/abort.test.ts:175-185` + +- [ ] **Step 1: 改 `types/session.ts` 内 SessionMeta / SessionMetaUpdate / SessionFilter** + +将 `packages/session/src/types/session.ts` 内的相关类型替换为: + +```ts +/** Session 元数据,描述一个独立对话单元 */ +export interface SessionMeta { + readonly id: string + label: string + status: 'active' | 'archived' + createdAt: string + updatedAt: string +} + +/** 可更新的 SessionMeta 字段子集 */ +export interface SessionMetaUpdate { + label?: string +} + +/** 列举 Session 时的过滤条件 */ +export interface SessionFilter { + status?: 'active' | 'archived' +} +``` + +`ForkOptions` 内不要删 `tags` / `metadata`——因为本任务暂时还允许 fork API 传它们以保持向下兼容;等 Task 3 把 createMainSession 一起删时再清。**但** `ForkOptions.tags` 和 `ForkOptions.metadata` 不再写入 SessionMeta;create-session.ts 里要忽略它们(见下一步)。 + +实际上,本任务直接把 `ForkOptions.tags` / `ForkOptions.metadata` 一起删干净更省事。改 `ForkOptions` 同步删除这两个字段。 + +- [ ] **Step 2: 改 `create-session.ts` 三处使用点** + +定位 `packages/session/src/create-session.ts:387-396`(`fork` 内的 childMeta 构造): + +```ts +const childMeta: SessionMeta = { + id: childId, + label: forkOptions.label, + status: 'active', + createdAt: now, + updatedAt: now, +} +``` + +`packages/session/src/create-session.ts:438-451`(`updateMeta` 实现)只剩 label 分支: + +```ts +async updateMeta(updates: SessionMetaUpdate): Promise { + if (currentMeta.status === 'archived') { + throw new SessionArchivedError(currentMeta.id) + } + const updatedMeta: SessionMeta = { + ...currentMeta, + ...(updates.label !== undefined && { label: updates.label }), + updatedAt: new Date().toISOString(), + } + await storage.putSession(updatedMeta) + currentMeta = updatedMeta +}, +``` + +`packages/session/src/create-session.ts:482-493`(`createSession` 工厂初始化): + +```ts +const meta: SessionMeta = { + id, + label: options.label ?? 'New Session', + status: 'active', + createdAt: now, + updatedAt: now, +} +``` + +同步删除 `CreateSessionOptions.tags` / `CreateSessionOptions.metadata` 字段(`packages/session/src/types/functions.ts:42-46`)。 + +- [ ] **Step 3: 改 `create-main-session.ts` 等价位置** + +`createMainSession` 整个文件会在 Task 3 删掉,但为了保证本任务 typecheck 通过:把 `packages/session/src/create-main-session.ts:353-365` 的 `updateMeta` 实现里 `tags` / `metadata` 分支去掉;把 `packages/session/src/create-main-session.ts:368-406` 的 `fork` 里传给 `createSession` 的 `tags` / `metadata` 去掉;把 `packages/session/src/create-main-session.ts:437-448` 的 meta 构造内的 `role: 'main'`、`tags`、`metadata` 字段去掉。 + +注意:先保留 `role: 'main'` 字段实际上没法保留——SessionMeta 接口已经没有这个字段了。**必须删掉**。同步把 `loadMainSession` 的 `if (!meta || meta.role !== 'main') return null` 改为 `if (!meta) return null`。 + +- [ ] **Step 4: 改 `mocks/in-memory-storage.ts:27-40` 的 listSessions filter** + +```ts +async listSessions(filter?: SessionFilter): Promise { + const all = Array.from(this.sessions.values()) + if (!filter) return all + return all.filter((s) => { + if (filter.status !== undefined && s.status !== filter.status) return false + return true + }) +} +``` + +`getAllSessionL2s` 中 `session.role !== 'standard'` 这一过滤改为只剩 `session.status !== 'active'`(暂时如此;Task 3 会把整个 `getAllSessionL2s` 删掉)。 + +- [ ] **Step 5: 改测试 — `meta.test.ts`** + +去掉 `tags: ['a', 'b'], metadata: { foo: 'bar' }`、`expect(m.role).toBe('standard')`、`expect(m.tags).toEqual(...)`、`expect(m.metadata).toEqual(...)` 等断言。`updateMeta` 测试只测 label 字段。`createSession 默认 role 为 standard` 这一条用例整条删除。 + +- [ ] **Step 6: 改测试 — `lifecycle.test.ts`** + +定位 `await session.updateMeta({ tags: ['tag1', 'tag2'] })` 行(line ~15):用 `await session.updateMeta({ label: 'Renamed' })` 替代;断言改为 `expect(session.meta.label).toBe('Renamed')`。 + +`await makeSession({ label: 'Keep', tags: ['keep'] })` 行(line ~26):去掉 `tags: ['keep']` 参数;断言改为只校验 label。 + +`fork` 用例(line ~75-90)中 `tags: ['forked']` 与 `expect(child.meta.tags).toEqual(['forked'])` 一起删;保留对 `child.meta.label / status` 的断言;删除 `expect(child.meta.role).toBe('standard')` 这一行。 + +- [ ] **Step 7: 改测试 — `abort.test.ts:175-185`** + +abort 测试里手工构造 `SessionMeta` 对象的地方(`role: 'standard'`、`tags: []` 字段),改为新形状: + +```ts +{ + id: 'abort-session', + label: 'Abort Session', + status: 'active', + createdAt: now, + updatedAt: now, +} +``` + +- [ ] **Step 8: typecheck + 包内测试** + +```bash +pnpm --filter @stello-ai/session typecheck && pnpm --filter @stello-ai/session test +``` + +Expected: 全绿。如果 main-session.test.ts 失败(因为它读 meta.role),**允许暂时失败**:本任务的下一个任务(Task 3)就会删整个 main-session 测试文件。可以临时给该文件加 `it.skip` 或 vitest 的 `describe.skip` 包住整个文件,commit message 注明"will be removed in Task 3"。 + +- [ ] **Step 9: Commit** + +```bash +git add packages/session/src/types/session.ts \ + packages/session/src/types/functions.ts \ + packages/session/src/create-session.ts \ + packages/session/src/create-main-session.ts \ + packages/session/src/mocks/in-memory-storage.ts \ + packages/session/src/__tests__/meta.test.ts \ + packages/session/src/__tests__/lifecycle.test.ts \ + packages/session/src/__tests__/abort.test.ts +git commit -m "refactor(session): drop role/tags/metadata from SessionMeta" +``` + +--- + +## Task 2: 合并 MainStorage 到 SessionStorage,删除拓扑/全局键值/批量 L2 等冗余方法 + +**目标:** SessionStorage 单一接口,只剩 SessionMeta CRUD + L3 + system prompt + insight + memory + transaction;删除 `MainStorage` 接口、`TopologyNode`(迁到 core 的责任)、`getAllSessionL2s`、`listSessions`、`putNode / getChildren / removeNode`、`getGlobal / putGlobal`。 + +**Files:** +- Modify: `packages/session/src/types/storage.ts` +- Modify: `packages/session/src/mocks/in-memory-storage.ts` +- Modify: `packages/session/src/__tests__/main-session.test.ts`(仅去掉用到 storage 已删方法的部分;整个文件下一任务再删) + +- [ ] **Step 1: 重写 `types/storage.ts`** + +```ts +import type { SessionMeta, SessionFilter } from './session.js' +import type { Message } from './llm.js' + +/** 列举消息记录时的选项 */ +export interface ListRecordsOptions { + limit?: number + offset?: number + /** 只返回指定 role 的消息 */ + role?: Message['role'] +} + +/** + * SessionStorage — Session 数据操作接口 + * + * 所有 Session(含 root)共用同一个接口。 + * 拓扑节点 CRUD 由 core SessionTree 持有,不在此接口职责内。 + */ +export interface SessionStorage { + /** 读取 Session 元数据,不存在返回 null */ + getSession(id: string): Promise + /** 写入或更新 Session 元数据 */ + putSession(session: SessionMeta): Promise + /** 列举 Session(按状态过滤) */ + listSessions(filter?: SessionFilter): Promise + + /** 追加一条对话记录(L3) */ + appendRecord(sessionId: string, record: Message): Promise + /** 读取对话记录列表(L3) */ + listRecords(sessionId: string, options?: ListRecordsOptions): Promise + /** 裁剪旧 L3 记录,仅保留最近 keepRecent 条 */ + trimRecords(sessionId: string, keepRecent: number): Promise + + /** 读取 Session 的 system prompt */ + getSystemPrompt(sessionId: string): Promise + /** 写入 Session 的 system prompt */ + putSystemPrompt(sessionId: string, content: string): Promise + + /** 读取 Session 的 insight,一次性,send 消费后调用 clearInsight */ + getInsight(sessionId: string): Promise + /** 写入 Session 的 insight */ + putInsight(sessionId: string, content: string): Promise + /** 清除 Session 的 insight */ + clearInsight(sessionId: string): Promise + + /** 读取 Session 的持久 memory(原 L2 / 原 synthesis 统一槽位) */ + getMemory(sessionId: string): Promise + /** 写入 Session 的 memory */ + putMemory(sessionId: string, content: string): Promise + + /** 事务(内存实现可直接执行 fn) */ + transaction(fn: (tx: SessionStorage) => Promise): Promise +} +``` + +> 注:`MainStorage` 接口完全删除;`TopologyNode` 也从本文件搬走(拓扑节点是 core 的责任)。 +> 保留 `listSessions` 在 `SessionStorage` 上是为了让 SDK 层 `agent.listSessions` 可以直接代理过去(spec §6.1 标记 listSessions 为类别确定)。 + +- [ ] **Step 2: 改 `mocks/in-memory-storage.ts`** + +```ts +import type { SessionStorage, ListRecordsOptions } from '../types/storage.js' +import type { SessionMeta, SessionFilter } from '../types/session.js' +import type { Message } from '../types/llm.js' + +export class InMemoryStorageAdapter implements SessionStorage { + private sessions = new Map() + private records = new Map() + private memories = new Map() + private systemPrompts = new Map() + private insights = new Map() + + async getSession(id: string): Promise { + return this.sessions.get(id) ?? null + } + + async putSession(session: SessionMeta): Promise { + this.sessions.set(session.id, { ...session }) + } + + async listSessions(filter?: SessionFilter): Promise { + const all = Array.from(this.sessions.values()) + if (!filter) return all + return all.filter((s) => { + if (filter.status !== undefined && s.status !== filter.status) return false + return true + }) + } + + async appendRecord(sessionId: string, record: Message): Promise { + const list = this.records.get(sessionId) ?? [] + list.push({ ...record }) + this.records.set(sessionId, list) + } + + async listRecords(sessionId: string, options?: ListRecordsOptions): Promise { + let list = this.records.get(sessionId) ?? [] + if (options?.role) list = list.filter((m) => m.role === options.role) + const offset = options?.offset ?? 0 + list = list.slice(offset) + if (options?.limit !== undefined) list = list.slice(0, options.limit) + return list.map((m) => ({ ...m })) + } + + async trimRecords(sessionId: string, keepRecent: number): Promise { + if (keepRecent <= 0) { + this.records.set(sessionId, []) + return + } + const list = this.records.get(sessionId) ?? [] + if (list.length > keepRecent) { + this.records.set(sessionId, list.slice(-keepRecent)) + } + } + + async getSystemPrompt(sessionId: string): Promise { + return this.systemPrompts.get(sessionId) ?? null + } + async putSystemPrompt(sessionId: string, content: string): Promise { + this.systemPrompts.set(sessionId, content) + } + + async getInsight(sessionId: string): Promise { + return this.insights.get(sessionId) ?? null + } + async putInsight(sessionId: string, content: string): Promise { + this.insights.set(sessionId, content) + } + async clearInsight(sessionId: string): Promise { + this.insights.delete(sessionId) + } + + async getMemory(sessionId: string): Promise { + return this.memories.get(sessionId) ?? null + } + async putMemory(sessionId: string, content: string): Promise { + this.memories.set(sessionId, content) + } + + async transaction(fn: (tx: SessionStorage) => Promise): Promise { + return fn(this) + } +} +``` + +- [ ] **Step 3: 暂时把 `create-main-session.ts` 内 `storage.getAllSessionL2s()` 调用注释或抛错** + +`createMainSession` 在下个任务整体删除。本任务里为了 typecheck 通过,把 `packages/session/src/create-main-session.ts:322` 那行 +```ts +const childSummaries = await storage.getAllSessionL2s() +``` +临时改为 `const childSummaries: never[] = []` 并加一行注释 `// Task 3 will delete this file`。同时把 `storage` 的类型从 `MainStorage` 改为 `SessionStorage`(顶部 import 同步)。 + +- [ ] **Step 4: typecheck + 包内测试** + +```bash +pnpm --filter @stello-ai/session typecheck && pnpm --filter @stello-ai/session test +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/session/src/types/storage.ts \ + packages/session/src/mocks/in-memory-storage.ts \ + packages/session/src/create-main-session.ts +git commit -m "refactor(session): merge MainStorage into SessionStorage" +``` + +--- + +## Task 3: 删除 MainSession 接口、工厂、上下文组装、专属测试 + +**目标:** 彻底移除 `MainSession` 类型 / `createMainSession` / `loadMainSession` / `assembleMainSessionContext` / `IntegrateFn` / `IntegrateResult` / `ChildL2Summary` / `CreateMainSessionOptions` / `LoadMainSessionOptions`,以及对应的测试。 + +**Files:** +- Delete: `packages/session/src/types/main-session-api.ts` +- Delete: `packages/session/src/create-main-session.ts` +- Delete: `packages/session/src/__tests__/main-session.test.ts` +- Modify: `packages/session/src/types/functions.ts`(删 Integrate / MainSession 相关类型) +- Modify: `packages/session/src/context-utils.ts`(删 `assembleMainSessionContext`) +- Modify: `packages/session/src/__tests__/integration-llm.test.ts`(替换为单 session 上下文测试或删除 Main 相关用例) +- Modify: `packages/session/src/__tests__/context-compress.test.ts`(删 Main 相关用例) + +- [ ] **Step 1: 删除 `main-session-api.ts` 与 `create-main-session.ts`** + +```bash +rm packages/session/src/types/main-session-api.ts \ + packages/session/src/create-main-session.ts +``` + +- [ ] **Step 2: 删除 `main-session.test.ts`** + +```bash +rm packages/session/src/__tests__/main-session.test.ts +``` + +- [ ] **Step 3: 改 `types/functions.ts`** + +删除以下类型导出与定义: +- `ChildL2Summary` interface +- `IntegrateResult` interface +- `IntegrateFn` type +- `CreateMainSessionOptions` interface +- `LoadMainSessionOptions` interface + +保留:`ConsolidateFn` / `CompressFn` / `CreateSessionOptions` / `LoadSessionOptions` / `SendResult` / `StreamResult`。 + +文件末尾不留任何 Main 相关 import 路径。 + +- [ ] **Step 4: 改 `context-utils.ts`** + +删除 `assembleMainSessionContext` 函数(line 290-369)。整个文件 OK。`assembleSessionContext` 是唯一的上下文组装函数,对 root / 子 Session 同构。 + +- [ ] **Step 5: 改 `integration-llm.test.ts`** + +打开 `packages/session/src/__tests__/integration-llm.test.ts`。逐 it 块判断: + +- 顶部 `import { createMainSession } from '../create-main-session.js'` 这行删掉。 +- 凡是 `await createMainSession(...)` 的调用,改为 `await createSession({ storage, llm, label: 'Test Root', ... })`,并把后续断言 `main.synthesis()` 改为 `root.memory()`。 +- 凡是测 `main.integrate()` 的 it 块——integrate 已不存在——整体 `it.skip`,并加注释 `// removed in main-session decouple; integration is orchestrator-side now`。本任务之后这些用例可在后续 cleanup task 中删除;但为减少范围,先 skip 即可。 + +执行检查:再次跑 `pnpm --filter @stello-ai/session test` 时这些 `it.skip` 显示为 skipped,**不**显示为 failed。 + +- [ ] **Step 6: 改 `context-compress.test.ts`** + +打开 `packages/session/src/__tests__/context-compress.test.ts`:搜 `assembleMainSessionContext` / `createMainSession`;用 `assembleSessionContext` / `createSession` 替换;root 在替换后等于普通 session,行为应相同。 + +- [ ] **Step 7: typecheck + 包内测试** + +```bash +pnpm --filter @stello-ai/session typecheck && pnpm --filter @stello-ai/session test +``` + +Expected: 全绿。 + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "refactor(session): remove MainSession interface, factory, and tests" +``` + +--- + +## Task 4: 收敛 `@stello-ai/session` 的 index.ts 导出 + +**目标:** 删掉所有 Main 相关 export,保留单一 Session 类型与函数。 + +**Files:** +- Modify: `packages/session/src/index.ts` + +- [ ] **Step 1: 改 `index.ts`** + +```ts +// 类型导出 — Session +export type { SessionMeta, SessionMetaUpdate, SessionFilter, ForkOptions, ForkContextFn } from './types/session.js' +export type { SessionStorage, ListRecordsOptions } from './types/storage.js' +export type { + Message, ToolCall, LLMCompleteOptions, LLMResult, LLMChunk, LLMAdapter, +} from './types/llm.js' +export type { + Session, + MessageQueryOptions, + SessionSendOptions, +} from './types/session-api.js' +export { + SessionArchivedError, + NotImplementedError, +} from './types/session-api.js' + +// 类型导出 — 函数签名与选项 +export type { + CompressFn, + ConsolidateFn, + CreateSessionOptions, + LoadSessionOptions, + SendResult, + StreamResult, +} from './types/functions.js' + +// 工具工厂 +export type { Tool, CallToolResult, ToolAnnotations } from './tool.js' +export { tool } from './tool.js' + +// Session 工厂函数 +export { createSession, loadSession } from './create-session.js' + +// LLM Adapter — 高层工厂 +export type { ClaudeModel, ClaudeOptions } from './adapters/claude.js' +export { createClaude } from './adapters/claude.js' +export type { GPTModel, GPTOptions } from './adapters/gpt.js' +export { createGPT } from './adapters/gpt.js' + +// LLM Adapter — 底层工厂 +export type { OpenAICompatibleOptions } from './adapters/openai-compatible.js' +export { createOpenAICompatibleAdapter } from './adapters/openai-compatible.js' +export type { AnthropicAdapterOptions } from './adapters/anthropic.js' +export { createAnthropicAdapter } from './adapters/anthropic.js' + +// Mock 实现(用于测试) +export { InMemoryStorageAdapter } from './mocks/in-memory-storage.js' +``` + +注意:`TopologyNode` 不再从 session 包导出(它属于 core 的拓扑层)。 + +- [ ] **Step 2: typecheck + 包内测试 + 构建产物** + +```bash +pnpm --filter @stello-ai/session typecheck && \ +pnpm --filter @stello-ai/session test && \ +pnpm --filter @stello-ai/session build +``` + +Expected: 全绿,dist 文件输出正常。 + +- [ ] **Step 3: Commit** + +```bash +git add packages/session/src/index.ts +git commit -m "refactor(session): consolidate index.ts exports after Main removal" +``` + +> 至此 `@stello-ai/session` 全包改完。下面进入 `@stello-ai/core`。 + +--- + +## Task 5: 删除 `MAIN_SESSION_ID` 并简化核心 SessionMeta / CreateSessionOptions + +**目标:** 在 core 包内删 `MAIN_SESSION_ID` 常量与所有引用;把 `CreateSessionOptions` 改为 `{ parentId?, label?, sourceSessionId? }`,多 root 合法。 + +**Files:** +- Modify: `packages/core/src/types/session.ts` +- Modify: `packages/core/src/session/session-tree.ts`(仅删去 import `MAIN_SESSION_ID`,工厂方法改造在 Task 9) +- Modify: `packages/core/src/engine/stello-engine.ts`(仅去 import;Task 10 删 branch) +- Modify: `packages/core/src/engine/__tests__/stello-engine.test.ts`(删 `MAIN_SESSION_ID` import 与用例) +- Modify: `packages/core/src/session/__tests__/session-tree.test.ts`(删 `MAIN_SESSION_ID` import) +- Modify: `packages/core/src/index.ts`(删 `MAIN_SESSION_ID` 重导出) + +- [ ] **Step 1: 改 `packages/core/src/types/session.ts`** + +整体替换为: + +```ts +// ─── Session 系统类型定义 ─── + +import type { SerializableSessionConfig } from './session-config'; + +/** Session 状态 */ +export type SessionStatus = 'active' | 'archived'; + +/** + * Session 元数据 + * + * Session 是 Stello 的原子单元——一个独立对话空间。 + * 不包含树结构信息,Session 不感知自己在拓扑中的位置。 + */ +export interface SessionMeta { + readonly id: string; + label: string; + status: SessionStatus; + turnCount: number; + createdAt: string; + updatedAt: string; + lastActiveAt: string; +} + +/** + * 拓扑节点 + * + * 树结构信息,独立于 Session 维护。id 与 SessionMeta.id 对应。 + * `parentId === null` 即为 root。多 root 合法。 + */ +export interface TopologyNode { + readonly id: string; + parentId: string | null; + children: string[]; + refs: string[]; + depth: number; + index: number; + label: string; + sourceSessionId?: string; +} + +/** 递归树节点(API 返回用) */ +export interface SessionTreeNode { + id: string; + label: string; + sourceSessionId?: string; + status: SessionStatus; + turnCount: number; + children: SessionTreeNode[]; +} + +/** + * 创建 Session 的参数(纯拓扑信息) + * + * `parentId` 为空则为新 root;非空挂在该节点下。 + */ +export interface CreateSessionOptions { + /** 父节点 ID;为空建 root */ + parentId?: string; + /** 显示名称 */ + label?: string; + /** fork 时的上下文来源 session(不传默认语义 = parentId 或 undefined) */ + sourceSessionId?: string; +} + +/** + * Session 树操作接口 + * + * 管理对话的空间结构。支持多 root(森林)。 + */ +export interface SessionTree { + /** + * 创建 Session 拓扑节点。 + * - `options.parentId` 为空:创建新 root(`parentId === null`) + * - 非空:挂在该节点下作为子节点 + * - **不**继承父 Session 上下文 / 配置(需要继承走 forkSession) + */ + createSession(options?: CreateSessionOptions): Promise; + /** 获取单个 Session 元数据 */ + get(id: string): Promise; + /** 列出所有 Session */ + listAll(): Promise; + /** 列出所有 root(parentId === null) */ + listRoots(): Promise; + /** 归档 Session(不连带子节点) */ + archive(id: string): Promise; + /** 创建跨分支引用 */ + addRef(fromId: string, toId: string): Promise; + /** 更新 Session 元数据 */ + updateMeta( + id: string, + updates: Partial>, + ): Promise; + /** 获取单个拓扑节点 */ + getNode(id: string): Promise; + /** 获取完整拓扑(森林) */ + getTree(): Promise; + /** 获取所有祖先节点 */ + getAncestors(id: string): Promise; + /** 获取同级兄弟节点 */ + getSiblings(id: string): Promise; + /** 读取 Session 固化配置 */ + getConfig(id: string): Promise; + /** 写入 Session 固化配置 */ + putConfig(id: string, config: SerializableSessionConfig): Promise; +} +``` + +`MAIN_SESSION_ID` 常量、`createRoot`、`createChild`、`getRoot` 三个方法全部从接口移除。 + +- [ ] **Step 2: 去 `session-tree.ts` 顶部 import** + +把 `packages/core/src/session/session-tree.ts:10` +```ts +import { MAIN_SESSION_ID } from '../types/session'; +``` +直接删掉。然后把 `createRoot` 等方法的 body 中所有 `MAIN_SESSION_ID` 替换为常量字符串 `'root'`(仅为 Task 9 重构前过渡用)。本 task 不重写 SessionTreeImpl 实质行为;Task 9 才正式重写。 + +实操:把 `session-tree.ts:117-148` 内 `createRoot` 方法里的 `MAIN_SESSION_ID` 替换为局部常量 `const ROOT_ID = 'root'`,整体逻辑保留。仅追求本 task 内 typecheck 通过。 + +- [ ] **Step 3: 去 `stello-engine.ts:2` import** + +```ts +import type { SessionTree } from '../types/session'; +// 删除:import { MAIN_SESSION_ID } from '../types/session'; +``` + +Engine `forkSession` 内 `sourceSessionId === MAIN_SESSION_ID` 判断改为: + +```ts +const parentFrozen = await this.sessions.getConfig(sourceSessionId); +const parent: SessionConfig = parentFrozen ?? {}; +``` + +整段 `if (sourceSessionId === MAIN_SESSION_ID) { ... } else { ... }` 逻辑塌缩为直接 `await this.sessions.getConfig(sourceSessionId)`。Task 10 的"删 fork-from-main 分支" 这里就完成了;后续 Task 10 只剩测试调整。 + +- [ ] **Step 4: 删 `core/index.ts` 内 `MAIN_SESSION_ID` 导出** + +```ts +// 删除: +// export { MAIN_SESSION_ID } from './types/session'; +``` + +- [ ] **Step 5: 删测试内 `MAIN_SESSION_ID` import** + +- `packages/core/src/session/__tests__/session-tree.test.ts:7` — 删 import。`createRoot 返回固定的 MAIN_SESSION_ID 作为 id` 这条用例(line 47-50)整体删除;其它 `createRoot` 调用本 task 保留(Task 9 才整体重写测试)。 +- `packages/core/src/engine/__tests__/stello-engine.test.ts:7` — 删 import;`describe('forkSession from main session (issue #55)')` 整个 describe 块(Task 12 会再清理)暂时改为用 `'root'` 字面量替代 `MAIN_SESSION_ID`,测试逻辑保留以便能跑通。 + +- [ ] **Step 6: typecheck + 包内测试** + +```bash +pnpm --filter @stello-ai/core typecheck && pnpm --filter @stello-ai/core test +``` + +Expected: 全绿。 + +- [ ] **Step 7: Commit** + +```bash +git add packages/core/src/types/session.ts \ + packages/core/src/session/session-tree.ts \ + packages/core/src/engine/stello-engine.ts \ + packages/core/src/index.ts \ + packages/core/src/session/__tests__/session-tree.test.ts \ + packages/core/src/engine/__tests__/stello-engine.test.ts +git commit -m "refactor(core): remove MAIN_SESSION_ID constant and inline fork-from-main branch" +``` + +--- + +## Task 6: 删除 `MainSessionConfig` / `SerializableMainSessionConfig` 类型 + +**目标:** core 内 session-config.ts 只剩 `SessionConfig` / `SerializableSessionConfig`。 + +**Files:** +- Modify: `packages/core/src/types/session-config.ts` +- Modify: `packages/core/src/types.ts`(删导出) + +- [ ] **Step 1: 改 `session-config.ts`** + +把 `packages/core/src/types/session-config.ts` 整体替换为: + +```ts +// ─── Session 统一配置类型定义 ─── + +import type { LLMAdapter, LLMCompleteOptions } from '@stello-ai/session'; +import type { + SessionCompatibleConsolidateFn, + SessionCompatibleCompressFn, +} from '../adapters/session-runtime'; + +/** + * Session 配置字段集 + * + * 固化后写入存储。覆盖单个 Session 在上下文组装、LLM 调用、 + * tool 调度、L3→L2 提炼、上下文压缩等环节所需的可配置项。 + */ +export interface SessionConfig { + systemPrompt?: string; + llm?: LLMAdapter; + tools?: LLMCompleteOptions['tools']; + skills?: string[]; + consolidateFn?: SessionCompatibleConsolidateFn; + compressFn?: SessionCompatibleCompressFn; +} + +/** + * SessionConfig 的可序列化子集 + */ +export interface SerializableSessionConfig { + systemPrompt?: string; + skills?: string[]; +} +``` + +`MainSessionConfig` / `SerializableMainSessionConfig` 类型整体删除。`SessionCompatibleIntegrateFn` 也不再被 import。 + +- [ ] **Step 2: 改 `core/types.ts`** + +把 `packages/core/src/types.ts:43-49` 改为: + +```ts +// Session 统一配置 +export type { + SessionConfig, + SerializableSessionConfig, +} from './types/session-config'; +``` + +去掉 `MainSessionConfig` / `SerializableMainSessionConfig` 两个 export。 + +- [ ] **Step 3: typecheck** + +```bash +pnpm --filter @stello-ai/core typecheck +``` + +> 此时 `stello-agent.ts` 等仍 import `MainSessionConfig`,会报错。下一个 task 修复。先继续做本 task 的 commit 准备:把当前未编译通过的状态保留为 WIP,Task 11 一起验证。 + +实际操作:本 task 先把 `stello-agent.ts` 顶部 import 里 `MainSessionConfig` 和 `SerializableMainSessionConfig` 也删掉,并把 body 内引用同步处理(用 `// removed in main-session decouple` 临时占位删除相关方法体里的引用——Task 11 时整体重写)。具体: + - `stello-agent.ts:32-36` import 去掉 `MainSessionConfig` / `SerializableMainSessionConfig` / `SerializableMainSessionConfig`。 + - `stello-agent.ts:102-103`(`mainSessionConfig?: MainSessionConfig`)字段先注释 `// removed in Task 11`。 + - `stello-agent.ts:136-143`(`serializeMainSessionConfig` 函数)整体保留不动,Task 11 删。 + +> 这一 task 收尾时**允许 typecheck 红**——下游 Task 7/8/11 会陆续把 stello-agent.ts 改干净。如果 executor 偏好每个 task 都绿,可以在本 task 把 `stello-agent.ts` 内 `mainSessionConfig` / `serializeMainSessionConfig` 等用法直接删除(合并 Task 11 的内容)。 + +- [ ] **Step 4: Commit** + +```bash +git add packages/core/src/types/session-config.ts \ + packages/core/src/types.ts \ + packages/core/src/agent/stello-agent.ts +git commit -m "refactor(core): drop MainSessionConfig types" +``` + +--- + +## Task 7: 删除 `MainSessionCompatible` 与 `SessionCompatibleIntegrateFn` adapter 类型 + +**目标:** `adapters/session-runtime.ts` 内不再有 MainSession / integrate 相关类型。 + +**Files:** +- Modify: `packages/core/src/adapters/session-runtime.ts` +- Modify: `packages/core/src/agent/stello-agent.ts`(删 import) +- Modify: `packages/core/src/index.ts`(删 export) + +- [ ] **Step 1: 改 `adapters/session-runtime.ts`** + +定位: +- `SessionCompatibleIntegrateFn` 类型(line 38-45)整体删除 +- `MainSessionCompatible` 接口(line 92-95)整体删除 + +`SessionCompatible` 内已经没有 integrate 方法,保留不变。 + +- [ ] **Step 2: 改 `core/index.ts`** + +`packages/core/src/index.ts:65-80` 内: +- 删除 `MainSessionCompatible` export +- 删除 `SessionCompatibleIntegrateFn` export + +```ts +export type { + SessionRuntimeAdapterOptions, + SessionCompatible, + SessionCompatibleToolCall, + SessionCompatibleSendResult, + SessionCompatibleConsolidateFn, + SessionCompatibleCompressFn, + SessionCompatibleForkOptions, +} from './adapters/session-runtime'; +``` + +同步删除 `core/index.ts:166`(`Session, MainSession, SendResult, StreamResult` re-export)中的 `MainSession` 名字。 + +同步删除 `core/index.ts:177-178` 内 `CreateMainSessionOptions, LoadMainSessionOptions` re-export。 + +同步删除 `core/index.ts:176`(`IntegrateFn, IntegrateResult, ChildL2Summary`)。 + +最终该段 export 应为: +```ts +// 函数签名 +CompressFn, ConsolidateFn, +CreateSessionOptions as SessionCreateOptions, +LoadSessionOptions, +``` + +- [ ] **Step 3: 改 `stello-agent.ts` import** + +`packages/core/src/agent/stello-agent.ts:20-24`:把 `MainSessionCompatible` 从 import 中去掉。 + +```ts +import { + adaptSessionToEngineRuntime, + serializeSessionSendResult, + sessionSendResultParser, + type SessionCompatible, + type SessionCompatibleSendResult, +} from '../adapters/session-runtime'; +``` + +- [ ] **Step 4: typecheck + 测试** + +```bash +pnpm --filter @stello-ai/core typecheck && pnpm --filter @stello-ai/core test +``` + +> Task 11 之前 stello-agent 还有 `mainSessionLoader` 引用 `MainSessionCompatible`——如果 Task 6 没顺便清,这里需要把 `mainSessionLoader` 字段的返回类型先用 `unknown` 兜住,下个任务再清。 + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/adapters/session-runtime.ts \ + packages/core/src/agent/stello-agent.ts \ + packages/core/src/index.ts +git commit -m "refactor(core): drop MainSessionCompatible/SessionCompatibleIntegrateFn" +``` + +--- + +## Task 8: 删除 `createDefaultIntegrateFn` 与 `DEFAULT_INTEGRATE_PROMPT` + +**目标:** core 不再提供 integrate 默认实现(外包给 orchestrator client)。 + +**Files:** +- Modify: `packages/core/src/llm/defaults.ts` +- Modify: `packages/core/src/llm/__tests__/defaults.test.ts` +- Modify: `packages/core/src/index.ts` + +- [ ] **Step 1: 改 `defaults.ts`** + +删除 `DEFAULT_INTEGRATE_PROMPT` 常量(line 37-51)与 `createDefaultIntegrateFn` 函数(line 98-132)。 + +`import type { SessionCompatibleIntegrateFn ... }` 去掉。 + +- [ ] **Step 2: 改测试** + +打开 `packages/core/src/llm/__tests__/defaults.test.ts`,凡是测 `createDefaultIntegrateFn` / `DEFAULT_INTEGRATE_PROMPT` 的用例整体删除。 + +- [ ] **Step 3: 改 `core/index.ts`** + +`packages/core/src/index.ts:137-145`: + +```ts +export { + createDefaultConsolidateFn, + createDefaultCompressFn, + DEFAULT_CONSOLIDATE_PROMPT, + DEFAULT_COMPRESS_PROMPT, +} from './llm/defaults'; +export type { LLMCallFn, DefaultFnOptions } from './llm/defaults'; +``` + +- [ ] **Step 4: typecheck + 测试** + +```bash +pnpm --filter @stello-ai/core typecheck && pnpm --filter @stello-ai/core test +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/llm/defaults.ts \ + packages/core/src/llm/__tests__/defaults.test.ts \ + packages/core/src/index.ts +git commit -m "refactor(core): drop createDefaultIntegrateFn and DEFAULT_INTEGRATE_PROMPT" +``` + +--- + +## Task 9: 重写 SessionTreeImpl 支持多 root,统一 createSession 入口 + +**目标:** `SessionTreeImpl` 实现新接口:`createSession({ parentId?, label?, sourceSessionId? })` 同时支持建 root(parentId 缺省)和挂子节点。新增 `listRoots()`。`getTree()` 返回 `SessionTreeNode[]` 森林。删除 `createRoot` / `createChild` / `getRoot`。 + +**Files:** +- Modify: `packages/core/src/session/session-tree.ts` +- Modify: `packages/core/src/session/__tests__/session-tree.test.ts` + +- [ ] **Step 1: 重写 `session-tree.ts` 关键方法** + +整体保留文件骨架(StoredMeta / metaPath / configPath / now / resolveSourceSessionId / toSessionMeta / toTopologyNode),重写以下方法: + +```ts +/** + * 创建 Session 拓扑节点:parentId 缺省即建 root,非空挂在该父节点下。 + * + * - root:parentId 为 null,depth = 0 + * - 子:从父读取并 push 进父的 children 列表,串行化在 writeLock 内 + * + * 多 root 合法:不再要求第一个 root 持有固定 ID;每次都生成 randomUUID。 + */ +async createSession(options: CreateSessionOptions = {}): Promise { + return this.withWriteLock(async () => { + const ts = now(); + const id = randomUUID(); + + if (!options.parentId) { + const stored: StoredMeta = { + id, + parentId: null, + children: [], + refs: [], + label: options.label ?? 'Root', + index: 0, + status: 'active', + depth: 0, + turnCount: 0, + createdAt: ts, + updatedAt: ts, + lastActiveAt: ts, + }; + if (options.sourceSessionId !== undefined) { + stored.sourceSessionId = options.sourceSessionId; + } + await this.fs.writeJSON(metaPath(id), stored); + await this.initSessionFiles(id); + // 初始化 core.json(首次任何 root 触发) + const coreExisting = await this.fs.readJSON('core.json'); + if (coreExisting === null) { + await this.fs.writeJSON('core.json', {}); + } + return toTopologyNode(stored); + } + + const parent = await this.requireStored(options.parentId); + const stored: StoredMeta = { + id, + parentId: parent.id, + children: [], + refs: [], + label: options.label ?? 'Session', + index: parent.children.length, + status: 'active', + depth: parent.depth + 1, + turnCount: 0, + createdAt: ts, + updatedAt: ts, + lastActiveAt: ts, + }; + if (options.sourceSessionId !== undefined) { + stored.sourceSessionId = options.sourceSessionId; + } + await this.fs.writeJSON(metaPath(id), stored); + await this.initSessionFiles(id); + parent.children.push(id); + parent.updatedAt = now(); + await this.fs.writeJSON(metaPath(parent.id), parent); + return toTopologyNode(stored); + }); +} + +/** 列出所有 root(parentId === null) */ +async listRoots(): Promise { + const all = await this.listAllStored(); + return all.filter((s) => s.parentId === null).map(toTopologyNode); +} + +/** 获取完整拓扑(森林) */ +async getTree(): Promise { + const all = await this.listAllStored(); + const map = new Map(all.map((s) => [s.id, s])); + const roots = all.filter((s) => s.parentId === null); + + const buildNode = (stored: StoredMeta): SessionTreeNode => { + const source = resolveSourceSessionId(stored); + const node: SessionTreeNode = { + id: stored.id, + label: stored.label, + status: stored.status, + turnCount: stored.turnCount, + children: stored.children + .map((childId) => map.get(childId)) + .filter((c): c is StoredMeta => c !== undefined) + .map(buildNode), + }; + if (source !== undefined) node.sourceSessionId = source; + return node; + }; + + return roots.map(buildNode); +} +``` + +**删除**:`createRoot()` / `createChild()` / `getRoot()` 三个方法。 +**新增**:`private async initSessionFiles(id)`:把原 `createRoot` / `createChild` 里写 memory.md / scope.md / index.md 的 3 行 `writeFile` 抽到此处。 + +- [ ] **Step 2: 重写 `session-tree.test.ts`** + +按下列要点重新组织测试(保留同类覆盖,但用新 API): + +```ts +describe('SessionTreeImpl', () => { + // ─── createSession(无 parentId = root) ─── + it('createSession 无 parentId 时建 root,parentId 为 null,depth=0', async () => { + const root = await tree.createSession({ label: '根' }); + expect(root.parentId).toBeNull(); + expect(root.depth).toBe(0); + expect(root.label).toBe('根'); + }); + + it('createSession 无 label 时默认 "Root"(root 路径)', async () => { + const root = await tree.createSession(); + expect(root.label).toBe('Root'); + }); + + it('多 root 合法:listRoots 返回所有 root', async () => { + const r1 = await tree.createSession({ label: 'R1' }); + const r2 = await tree.createSession({ label: 'R2' }); + const roots = await tree.listRoots(); + expect(roots.map((r) => r.id).sort()).toEqual([r1.id, r2.id].sort()); + }); + + // ─── createSession(带 parentId = child) ─── + it('createSession 带 parentId 时挂在父下,depth = parent.depth + 1', async () => { + const root = await tree.createSession({ label: '根' }); + const child = await tree.createSession({ parentId: root.id, label: '子' }); + expect(child.parentId).toBe(root.id); + expect(child.depth).toBe(1); + }); + + it('createSession 持久化 sourceSessionId 字段', async () => { + const root = await tree.createSession({ label: '根' }); + const child = await tree.createSession({ + parentId: root.id, label: '子', sourceSessionId: 'src-x', + }); + const node = await tree.getNode(child.id); + expect(node?.sourceSessionId).toBe('src-x'); + }); + + // ─── getTree 森林 ─── + it('getTree 返回多 root 的森林', async () => { + const r1 = await tree.createSession({ label: 'R1' }); + const r2 = await tree.createSession({ label: 'R2' }); + await tree.createSession({ parentId: r1.id, label: 'C1' }); + const forest = await tree.getTree(); + expect(forest).toHaveLength(2); + expect(forest.find((n) => n.id === r1.id)?.children).toHaveLength(1); + expect(forest.find((n) => n.id === r2.id)?.children).toHaveLength(0); + }); + + it('getTree 空森林返回空数组', async () => { + const forest = await tree.getTree(); + expect(forest).toEqual([]); + }); + + // ─── 旧 API 用例搬迁 ─── + // 把所有原本调 createRoot() 的用例替换为 await tree.createSession({ label: 'X' }) + // 把原本调 createChild({ parentId, label }) 的用例替换为 await tree.createSession({ parentId, label }) + // 删除:`createRoot 返回固定的 MAIN_SESSION_ID 作为 id`、`getRoot 返回根节点的 SessionMeta`、 + // `createRoot 幂等:第二次调用返回现有节点` 三条用例。 +}); +``` + +执行:把原 487 行测试里 `await tree.createRoot(label)` 全文替换为 `await tree.createSession({ label })`;`await tree.createChild({ parentId, label })` 替换为 `await tree.createSession({ parentId, label })`;`await tree.getRoot()` 用例删除(root 现在不唯一,没有 getRoot 概念)。 + +- [ ] **Step 3: typecheck + 测试** + +```bash +pnpm --filter @stello-ai/core typecheck && pnpm --filter @stello-ai/core test +``` + +Expected: 全绿。 + +- [ ] **Step 4: Commit** + +```bash +git add packages/core/src/session/session-tree.ts \ + packages/core/src/session/__tests__/session-tree.test.ts +git commit -m "refactor(core): unify SessionTree under createSession with multi-root support" +``` + +--- + +## Task 10: Engine 测试清理 fork-from-main 用例 + +**目标:** Task 5 已经在 `stello-engine.ts` 内删了 `MAIN_SESSION_ID` 判断。本任务把测试里的 `describe('forkSession from main session (issue #55)')` 整段移除或调整为通用 fork 测试。 + +**Files:** +- Modify: `packages/core/src/engine/__tests__/stello-engine.test.ts:480-553` + +- [ ] **Step 1: 删除 describe('forkSession from main session (issue #55)') 整段** + +`packages/core/src/engine/__tests__/stello-engine.test.ts:480-553` 整个 describe 块删除。原本测的"main 跳过父配置"语义在新模型下不存在——root 是普通 session,其配置应被子 fork 继承。 + +如果想保留 root 的 fork 行为覆盖,可在原位置加一条用例: + +```ts +it('从 root session fork 时正常读取 root 的 getConfig 并继承', async () => { + const getConfig = vi.fn().mockResolvedValue({ systemPrompt: 'root sys' }); + const createSession = vi.fn().mockResolvedValue({ + id: 'child-1', parentId: 'root-id', children: [], refs: [], + depth: 1, index: 0, label: 'UI', + }); + const sessionFork = vi.fn().mockResolvedValue({ + id: 'child-1', meta: { id: 'child-1', turnCount: 0, status: 'active' }, + turnCount: 0, send: vi.fn(), consolidate: vi.fn(), setTools: vi.fn(), + }); + + const engine = new StelloEngineImpl({ + session: { + id: 'root-id', + meta: { id: 'root-id', turnCount: 0, status: 'active' as const }, + turnCount: 0, send: vi.fn(), consolidate: vi.fn(), + messages: vi.fn().mockResolvedValue([]), + setTools: vi.fn(), + fork: sessionFork, + }, + sessions: { ...sessions, createSession, getConfig, putConfig: vi.fn() } as unknown as SessionTree, + memory, skills, confirm, agent: {} as never, + lifecycle: { bootstrap: vi.fn(), afterTurn: vi.fn() }, + tools: { getToolDefinitions: vi.fn().mockReturnValue([]), executeTool: vi.fn() }, + }); + + await engine.forkSession({ label: 'UI' }); + + expect(getConfig).toHaveBeenCalledWith('root-id'); + expect(sessionFork).toHaveBeenCalledWith(expect.objectContaining({ + systemPrompt: 'root sys', + })); +}); +``` + +注意:本测试用到的 `sessions.createChild` 现在叫 `sessions.createSession`。Engine 内部调用也要从 `createChild` 改为 `createSession`——Task 9 后 SessionTree 接口上没有 `createChild` 方法。 + +- [ ] **Step 2: 改 Engine `forkSession` 内部调用方法名** + +`packages/core/src/engine/stello-engine.ts:393-398`: + +```ts +const child = await this.sessions.createSession({ + parentId: topologyParentId, + label: options.label, + sourceSessionId, +}); +``` + +把 `createChild` → `createSession`。 + +- [ ] **Step 3: typecheck + 测试** + +```bash +pnpm --filter @stello-ai/core typecheck && pnpm --filter @stello-ai/core test +``` + +Expected: 全绿。 + +- [ ] **Step 4: Commit** + +```bash +git add packages/core/src/engine/stello-engine.ts \ + packages/core/src/engine/__tests__/stello-engine.test.ts +git commit -m "refactor(core): replace SessionTree.createChild with createSession in engine" +``` + +--- + +## Task 11: 删除 StelloAgent 上的 createMainSession / integrate / mainSessionConfig / mainSessionLoader;新增 createSession + +**目标:** StelloAgent 顶层 API 收敛。新增统一入口 `createSession({ parentId?, label? })`。删除所有 Main 相关方法 / 配置 / loader。 + +**Files:** +- Modify: `packages/core/src/agent/stello-agent.ts` +- Modify: `packages/core/src/agent/__tests__/stello-agent.test.ts` + +- [ ] **Step 1: 改 `stello-agent.ts` — 删除 Main 相关 import / 类型字段** + +去掉 Main 相关 import:`MainSessionConfig` / `SerializableMainSessionConfig` / `MainSessionCompatible`。 + +`StelloAgentSessionConfig` 内删除 `mainSessionLoader` 字段: + +```ts +export interface StelloAgentSessionConfig { + sessionLoader?: (sessionId: string) => Promise<{ + session: SessionCompatible; + config: SerializableSessionConfig | null; + }>; + serializeSendResult?: (result: SessionCompatibleSendResult) => string; + toolCallParser?: ToolCallParser; + options?: Record; +} +``` + +`StelloAgentConfig` 内删除 `mainSessionConfig` 字段: + +```ts +export interface StelloAgentConfig { + sessions: SessionTree; + memory: MemoryEngine; + sessionDefaults?: SessionConfig; + session?: StelloAgentSessionConfig; + capabilities: StelloAgentCapabilitiesConfig; + runtime?: StelloAgentRuntimeConfig; + orchestration?: StelloAgentOrchestrationConfig; +} +``` + +删除 `serializeMainSessionConfig` 函数(line 136-143)。 + +- [ ] **Step 2: 改 `stello-agent.ts` — 替换 createMainSession 为 createSession** + +定位 `StelloAgent.createMainSession`(line 217-222),整体替换为: + +```ts +/** + * 创建一个新的 Session 拓扑节点。 + * + * - `parentId` 为空:建 root(parentId === null) + * - 非空:挂在该节点下作为子节点(**不**继承父 Session 上下文 / 配置) + * + * 需要继承上下文(systemPrompt / L3 / 合成配置)应走 `forkSession`。 + */ +async createSession(options?: { + parentId?: string; + label?: string; +}): Promise { + return this.sessions.createSession({ + parentId: options?.parentId, + label: options?.label, + }); +} +``` + +- [ ] **Step 3: 改 `stello-agent.ts` — 删除 integrate 方法** + +定位 `StelloAgent.integrate`(line 290-301),整段删除。 + +- [ ] **Step 4: 改 `stello-agent.ts` — 调整 DefaultEngineFactory 构造参数** + +`packages/core/src/agent/stello-agent.ts:190-205` 构造 DefaultEngineFactory 时不再需要传 `mainSessionConfig`(本来也没传),保持原样即可。 + +- [ ] **Step 5: 改测试 — `stello-agent.test.ts`** + +删除整段 `describe('createMainSession')`(line 464-594)。 +删除 `it('会保留 mainSessionConfig 独立配置(不参与 fork 合成链)')` 用例(line 330-342)。 +删除 `it('integrate 调用 mainSession.integrate')`(line 385-396)与 `it('integrate 未配置 mainSessionLoader 时抛错')`(line 398-401)。 + +新增 `describe('createSession')` 块覆盖: + +```ts +describe('createSession', () => { + it('createSession 无 parentId 时建 root', async () => { + const createSession = vi.fn().mockResolvedValue({ + id: 'root-id', parentId: null, children: [], refs: [], + depth: 0, index: 0, label: 'My Root', + }); + const agent = createStelloAgent( + baseConfig({ sessions: { createSession } as unknown as SessionTree }), + ); + const node = await agent.createSession({ label: 'My Root' }); + expect(createSession).toHaveBeenCalledWith({ parentId: undefined, label: 'My Root' }); + expect(node.parentId).toBeNull(); + }); + + it('createSession 带 parentId 时挂在父下', async () => { + const createSession = vi.fn().mockResolvedValue({ + id: 'child-id', parentId: 'root-id', children: [], refs: [], + depth: 1, index: 0, label: 'Child', + }); + const agent = createStelloAgent( + baseConfig({ sessions: { createSession } as unknown as SessionTree }), + ); + const node = await agent.createSession({ parentId: 'root-id', label: 'Child' }); + expect(createSession).toHaveBeenCalledWith({ parentId: 'root-id', label: 'Child' }); + expect(node.parentId).toBe('root-id'); + }); + + it('createSession 不接受 mainSessionConfig 这类配置(接口收敛)', () => { + // 类型层断言:StelloAgentConfig 已无 mainSessionConfig 字段 + const cfg = baseConfig(); + // @ts-expect-error - mainSessionConfig 已删除 + cfg.mainSessionConfig = { systemPrompt: 'X' }; + }); +}); +``` + +检查 `baseConfig` helper 是否有 `mainSessionConfig` 字段(位于测试文件顶部);有则删该字段。同样 `mainSessionLoader: vi.fn(...)` 出现的地方一律删。 + +- [ ] **Step 6: typecheck + 测试** + +```bash +pnpm --filter @stello-ai/core typecheck && pnpm --filter @stello-ai/core test +``` + +Expected: 全绿。 + +- [ ] **Step 7: Commit** + +```bash +git add packages/core/src/agent/stello-agent.ts \ + packages/core/src/agent/__tests__/stello-agent.test.ts +git commit -m "refactor(core): remove createMainSession/integrate/mainSessionConfig from StelloAgent" +``` + +--- + +## Task 12: 新增 orchestrator-facing 拓扑 / 列举 SDK 方法 + +**目标:** 在 `StelloAgent` 上挂 4 个不依赖 storage 的纯拓扑/元数据方法:`listSessions(filter?)` / `getTopology()` / `listRoots()` / `getTopologyNode(id)`。这些方法全部代理给已注入的 `sessions: SessionTree`。 + +**Files:** +- Modify: `packages/core/src/agent/stello-agent.ts` +- Modify: `packages/core/src/agent/__tests__/stello-agent.test.ts` + +- [ ] **Step 1: 改 `stello-agent.ts` 新增方法** + +在 `StelloAgent` 类内、`createSession` 方法之后插入: + +```ts +/** 列出所有 Session(按状态过滤) */ +listSessions(filter?: { status?: 'active' | 'archived' }): Promise { + // 走 SessionTree.listAll;过滤在内存做(拓扑量级小,不下沉到 storage) + if (!filter) return this.sessions.listAll(); + return this.sessions.listAll().then((all) => + all.filter((s) => (filter.status === undefined ? true : s.status === filter.status)), + ); +} + +/** 列出所有 root(parentId === null) */ +listRoots(): Promise { + return this.sessions.listRoots(); +} + +/** 获取完整拓扑(森林) */ +getTopology(): Promise { + return this.sessions.getTree(); +} + +/** 获取单个拓扑节点 */ +getTopologyNode(id: string): Promise { + return this.sessions.getNode(id); +} +``` + +顶部 import 同步添加: + +```ts +import type { + SessionTree, TopologyNode, SessionTreeNode, SessionMeta, +} from '../types/session'; +``` + +- [ ] **Step 2: 改测试 — `stello-agent.test.ts`** + +```ts +describe('orchestrator-facing topology SDK', () => { + it('listSessions 代理 sessions.listAll', async () => { + const listAll = vi.fn().mockResolvedValue([ + { id: 'a', label: 'A', status: 'active', turnCount: 0, createdAt: '', updatedAt: '', lastActiveAt: '' }, + { id: 'b', label: 'B', status: 'archived', turnCount: 0, createdAt: '', updatedAt: '', lastActiveAt: '' }, + ]); + const agent = createStelloAgent( + baseConfig({ sessions: { listAll } as unknown as SessionTree }), + ); + expect(await agent.listSessions()).toHaveLength(2); + expect(await agent.listSessions({ status: 'active' })).toEqual([ + expect.objectContaining({ id: 'a' }), + ]); + }); + + it('listRoots 代理 sessions.listRoots', async () => { + const listRoots = vi.fn().mockResolvedValue([ + { id: 'r1', parentId: null, children: [], refs: [], depth: 0, index: 0, label: 'R1' }, + ]); + const agent = createStelloAgent( + baseConfig({ sessions: { listRoots } as unknown as SessionTree }), + ); + const roots = await agent.listRoots(); + expect(roots).toHaveLength(1); + expect(roots[0]!.parentId).toBeNull(); + }); + + it('getTopology 代理 sessions.getTree', async () => { + const getTree = vi.fn().mockResolvedValue([ + { id: 'r1', label: 'R1', status: 'active', turnCount: 0, children: [] }, + ]); + const agent = createStelloAgent( + baseConfig({ sessions: { getTree } as unknown as SessionTree }), + ); + const forest = await agent.getTopology(); + expect(forest).toHaveLength(1); + expect(forest[0]!.id).toBe('r1'); + }); + + it('getTopologyNode 代理 sessions.getNode', async () => { + const getNode = vi.fn().mockResolvedValue({ + id: 'x', parentId: null, children: [], refs: [], depth: 0, index: 0, label: 'X', + }); + const agent = createStelloAgent( + baseConfig({ sessions: { getNode } as unknown as SessionTree }), + ); + const node = await agent.getTopologyNode('x'); + expect(node?.id).toBe('x'); + }); +}); +``` + +- [ ] **Step 3: typecheck + 测试** + +```bash +pnpm --filter @stello-ai/core typecheck && pnpm --filter @stello-ai/core test +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/core/src/agent/stello-agent.ts \ + packages/core/src/agent/__tests__/stello-agent.test.ts +git commit -m "feat(core): add orchestrator-facing topology SDK on StelloAgent" +``` + +--- + +## Task 13: 新增 data-IO SDK 方法 + 顶层 storage 注入 + +**目标:** 在 `StelloAgentConfig` 上新增 `storage: SessionStorage` 字段。在 `StelloAgent` 上挂数据 IO SDK:`getSessionMetadata(id)` / `listSessionDigests(filter?)` / `listMessages(id, opts?)` / `putMemory(id, content)` / `putInsight(id, content)` / `clearInsight(id)`。所有调用直接走注入的 storage,框架不感知 memory / insight 语义。 + +**Files:** +- Modify: `packages/core/src/agent/stello-agent.ts` +- Modify: `packages/core/src/agent/__tests__/stello-agent.test.ts` +- Modify: `packages/core/src/index.ts`(重新导出 `SessionMetadataView` / `SessionDigest`) + +- [ ] **Step 1: 在 `stello-agent.ts` 顶部加 storage 类型 import 与新类型** + +```ts +import type { + SessionStorage, ListRecordsOptions, Message, +} from '@stello-ai/session'; + +/** 单 Session 的外部数据视图(memory + insight 聚合) */ +export interface SessionMetadataView { + memory: string | null; + insight: string | null; +} + +/** Session digest:批量视图条目(取代旧 getAllSessionL2s) */ +export interface SessionDigest { + id: string; + label: string; + status: 'active' | 'archived'; + memory: string | null; + insight: string | null; +} +``` + +- [ ] **Step 2: 改 `StelloAgentConfig` 添加 storage 字段** + +```ts +export interface StelloAgentConfig { + sessions: SessionTree; + memory: MemoryEngine; + /** + * Session 数据存储(L3 / system prompt / insight / memory)。 + * + * 用于 orchestrator-facing SDK(getSessionMetadata / listMessages / putMemory / ...)。 + * 应用层应保证 sessions(拓扑) 与 storage(内容) 指向同一份持久化后端。 + */ + storage?: SessionStorage; + sessionDefaults?: SessionConfig; + session?: StelloAgentSessionConfig; + capabilities: StelloAgentCapabilitiesConfig; + runtime?: StelloAgentRuntimeConfig; + orchestration?: StelloAgentOrchestrationConfig; +} +``` + +- [ ] **Step 3: 改 `StelloAgent` 类,加 storage 字段与方法** + +```ts +export class StelloAgent { + readonly config: StelloAgentConfig; + readonly sessions: StelloAgentConfig['sessions']; + readonly memory: StelloAgentConfig['memory']; + /** 注入的数据存储;data-IO SDK 方法依赖该字段 */ + readonly storage?: SessionStorage; + + // ... 现有构造逻辑 + constructor(config: StelloAgentConfig) { + // ... 原有代码 + this.storage = config.storage; + } + + // ─── data-IO SDK ─── + + /** 读取单个 Session 的 memory / insight 视图 */ + async getSessionMetadata(id: string): Promise { + const storage = this.requireStorage('getSessionMetadata'); + const [memory, insight] = await Promise.all([ + storage.getMemory(id), + storage.getInsight(id), + ]); + return { memory, insight }; + } + + /** + * 列出所有 Session 的 digest(id / label / status / memory / insight)。 + * + * 取代旧 `MainStorage.getAllSessionL2s()`:调用方自行根据 memory 字段做 reflection。 + */ + async listSessionDigests(filter?: { status?: 'active' | 'archived' }): Promise { + const storage = this.requireStorage('listSessionDigests'); + const metas = await this.sessions.listAll(); + const filtered = filter?.status + ? metas.filter((m) => m.status === filter.status) + : metas; + return Promise.all( + filtered.map(async (m) => { + const [memory, insight] = await Promise.all([ + storage.getMemory(m.id), + storage.getInsight(m.id), + ]); + return { id: m.id, label: m.label, status: m.status, memory, insight }; + }), + ); + } + + /** 读取指定 Session 的 L3 消息 */ + listMessages(id: string, options?: ListRecordsOptions): Promise { + const storage = this.requireStorage('listMessages'); + return storage.listRecords(id, options); + } + + /** 写入指定 Session 的 memory(持久;每次 send 注入) */ + putMemory(id: string, content: string): Promise { + const storage = this.requireStorage('putMemory'); + return storage.putMemory(id, content); + } + + /** 写入指定 Session 的 insight(一次性;被 send 消费后清除) */ + putInsight(id: string, content: string): Promise { + const storage = this.requireStorage('putInsight'); + return storage.putInsight(id, content); + } + + /** 清除指定 Session 的 insight */ + clearInsight(id: string): Promise { + const storage = this.requireStorage('clearInsight'); + return storage.clearInsight(id); + } + + private requireStorage(method: string): SessionStorage { + if (!this.storage) { + throw new Error( + `StelloAgent.${method} 需要 StelloAgentConfig.storage;请在创建 agent 时注入 SessionStorage`, + ); + } + return this.storage; + } +} +``` + +- [ ] **Step 4: 改 `core/index.ts` 重新导出新类型** + +```ts +export type { + StelloAgentConfig, + StelloAgentHotConfig, + StelloAgentSessionConfig, + StelloAgentCapabilitiesConfig, + StelloAgentRuntimeConfig, + StelloAgentOrchestrationConfig, + SessionMetadataView, + SessionDigest, +} from './agent/stello-agent'; +``` + +- [ ] **Step 5: 改测试 — `stello-agent.test.ts`** + +```ts +describe('orchestrator-facing data-IO SDK', () => { + function storageMock() { + return { + getMemory: vi.fn().mockResolvedValue('mem-x'), + putMemory: vi.fn().mockResolvedValue(undefined), + getInsight: vi.fn().mockResolvedValue('ins-x'), + putInsight: vi.fn().mockResolvedValue(undefined), + clearInsight: vi.fn().mockResolvedValue(undefined), + listRecords: vi.fn().mockResolvedValue([{ role: 'user', content: 'hi' }]), + } as unknown as SessionStorage; + } + + it('未注入 storage 时数据 IO 抛错', async () => { + const agent = createStelloAgent(baseConfig()); + await expect(agent.getSessionMetadata('x')).rejects.toThrow( + 'StelloAgent.getSessionMetadata 需要 StelloAgentConfig.storage', + ); + }); + + it('getSessionMetadata 聚合 memory + insight', async () => { + const storage = storageMock(); + const agent = createStelloAgent({ ...baseConfig(), storage }); + expect(await agent.getSessionMetadata('s1')).toEqual({ memory: 'mem-x', insight: 'ins-x' }); + }); + + it('listSessionDigests 走 sessions.listAll 并对每个 Session 取 memory/insight', async () => { + const storage = storageMock(); + const listAll = vi.fn().mockResolvedValue([ + { id: 'a', label: 'A', status: 'active', turnCount: 0, createdAt: '', updatedAt: '', lastActiveAt: '' }, + { id: 'b', label: 'B', status: 'archived', turnCount: 0, createdAt: '', updatedAt: '', lastActiveAt: '' }, + ]); + const agent = createStelloAgent({ + ...baseConfig({ sessions: { listAll } as unknown as SessionTree }), + storage, + }); + const digests = await agent.listSessionDigests({ status: 'active' }); + expect(digests).toEqual([ + { id: 'a', label: 'A', status: 'active', memory: 'mem-x', insight: 'ins-x' }, + ]); + }); + + it('listMessages 代理 storage.listRecords', async () => { + const storage = storageMock(); + const agent = createStelloAgent({ ...baseConfig(), storage }); + expect(await agent.listMessages('s1', { limit: 10 })).toEqual([ + { role: 'user', content: 'hi' }, + ]); + expect(storage.listRecords).toHaveBeenCalledWith('s1', { limit: 10 }); + }); + + it('putMemory / putInsight / clearInsight 代理 storage', async () => { + const storage = storageMock(); + const agent = createStelloAgent({ ...baseConfig(), storage }); + await agent.putMemory('s1', 'M'); + await agent.putInsight('s1', 'I'); + await agent.clearInsight('s1'); + expect(storage.putMemory).toHaveBeenCalledWith('s1', 'M'); + expect(storage.putInsight).toHaveBeenCalledWith('s1', 'I'); + expect(storage.clearInsight).toHaveBeenCalledWith('s1'); + }); +}); +``` + +- [ ] **Step 6: typecheck + 测试** + +```bash +pnpm --filter @stello-ai/core typecheck && pnpm --filter @stello-ai/core test +``` + +- [ ] **Step 7: Commit** + +```bash +git add packages/core/src/agent/stello-agent.ts \ + packages/core/src/agent/__tests__/stello-agent.test.ts \ + packages/core/src/index.ts +git commit -m "feat(core): add storage injection and data-IO SDK on StelloAgent" +``` + +--- + +## Task 14: core/types.ts 与 core/index.ts 导出收敛 + +**目标:** 终极收敛 core 包对外类型 / 值导出。所有 Main 相关符号全部消失。re-export 中的 `MainSession` / `MainStorage` / `IntegrateFn` 等被 session 包删除的类型也清掉。 + +**Files:** +- Modify: `packages/core/src/types.ts` +- Modify: `packages/core/src/index.ts` + +- [ ] **Step 1: 改 `types.ts`** + +整体重写为: + +```ts +// ─── Stello 全量类型定义统一导出 ─── + +export type { SessionStatus, SessionMeta, TopologyNode, SessionTreeNode, CreateSessionOptions, SessionTree } from './types/session'; + +export type { + InheritancePolicy, + CoreSchemaField, + CoreSchema, + TurnRecord, + AssembledContext, + MemoryEngine, +} from './types/memory'; + +export type { FileSystemAdapter } from './types/fs'; + +export type { + BootstrapResult, + AfterTurnResult, + Skill, + SkillRouter, + ToolDefinition, + ToolExecutionResult, + SplitProposal, + UpdateProposal, + ConfirmProtocol, +} from './types/lifecycle'; + +export type { + SplitStrategy, + CoreChangeEvent, + StelloError, + StelloEventMap, + StelloEngine, + EngineForkOptions, + SessionRuntimeResolver, +} from './types/engine'; + +export type { + SessionConfig, + SerializableSessionConfig, +} from './types/session-config'; +``` + +- [ ] **Step 2: 改 `core/index.ts` —— re-export 自 session 包的部分** + +把 `packages/core/src/index.ts:147-181` 的 re-export 段重写为: + +```ts +// Re-export @stello-ai/session 常用接口 +export { createSession, loadSession } from '@stello-ai/session'; +export { createClaude } from '@stello-ai/session'; +export { createGPT } from '@stello-ai/session'; +export { createOpenAICompatibleAdapter } from '@stello-ai/session'; +export { createAnthropicAdapter } from '@stello-ai/session'; +export { InMemoryStorageAdapter } from '@stello-ai/session'; +export { tool } from '@stello-ai/session'; +export { SessionArchivedError, NotImplementedError } from '@stello-ai/session'; +export type { + LLMAdapter, LLMResult, LLMChunk, LLMCompleteOptions, Message, + ClaudeModel, ClaudeOptions, + GPTModel, GPTOptions, + OpenAICompatibleOptions, + AnthropicAdapterOptions, + Session, SendResult, StreamResult, + MessageQueryOptions, + SessionMetaUpdate, SessionFilter, + ForkOptions, ForkContextFn, + SessionStorage, ListRecordsOptions, + CompressFn, ConsolidateFn, + CreateSessionOptions as SessionCreateOptions, + LoadSessionOptions, + Tool, CallToolResult, ToolAnnotations, +} from '@stello-ai/session'; +``` + +去掉所有:`createMainSession` / `loadMainSession` / `MainSession` / `MainStorage` / `CreateMainSessionOptions` / `LoadMainSessionOptions` / `IntegrateFn` / `IntegrateResult` / `ChildL2Summary`。 + +- [ ] **Step 3: typecheck + 测试 + 构建** + +```bash +pnpm --filter @stello-ai/core typecheck && \ +pnpm --filter @stello-ai/core test && \ +pnpm --filter @stello-ai/core build +``` + +Expected: 全绿;构建出的 `dist/index.d.ts` 无 Main 相关符号。 + +- [ ] **Step 4: Commit** + +```bash +git add packages/core/src/types.ts packages/core/src/index.ts +git commit -m "refactor(core): consolidate type exports after Main removal" +``` + +--- + +## Task 15: 全量验证 + CHANGELOG + 版本号 + +**目标:** 跨包跑全量 typecheck / test / build,更新 CHANGELOG,准备版本发布(不实际 publish,由用户决定时机)。 + +**Files:** +- Modify: `packages/session/CHANGELOG.md` +- Modify: `packages/core/CHANGELOG.md` +- Modify: `packages/session/package.json` (version bump) +- Modify: `packages/core/package.json` (version bump) +- Modify: `packages/core/src/index.ts:2`(VERSION 常量同步) + +- [ ] **Step 1: 跨包跑 typecheck** + +```bash +pnpm -r typecheck +``` + +Expected: 全部通过。`devtools` / `visualizer` / `demo` 不在范围内——它们极可能 fail,记录但不修。 + +- [ ] **Step 2: 跨包跑测试** + +```bash +pnpm -r --filter @stello-ai/session --filter @stello-ai/core test +``` + +Expected: session / core 两包测试全绿。 + +- [ ] **Step 3: 跨包构建** + +```bash +pnpm -r --filter @stello-ai/session --filter @stello-ai/core build +``` + +Expected: 两包均成功构建出 dist。 + +- [ ] **Step 4: 抓 grep 残留** + +```bash +grep -rln "MAIN_SESSION_ID\|MainSession\|createMainSession\|loadMainSession\|mainSessionConfig\|mainSessionLoader\|MainStorage\|IntegrateFn\|getAllSessionL2s" packages/session/src packages/core/src 2>&1 | grep -v node_modules | grep -v dist +``` + +Expected: 输出**为空**。如果有残留,回到对应任务补干净。 + +- [ ] **Step 5: 写 CHANGELOG** + +`packages/session/CHANGELOG.md` 头部加: + +```markdown +## Unreleased — Main Session Decouple + +### Breaking +- 删除 `MainSession` 接口、`createMainSession` / `loadMainSession` 工厂、`CreateMainSessionOptions` / `LoadMainSessionOptions` 选项 +- 删除 `MainStorage` 接口;其能力或合并入 `SessionStorage`(`listSessions`)或由 core 拓扑层接管(`putNode` 等);批量 L2 收集(`getAllSessionL2s`)转为 `StelloAgent.listSessionDigests` +- 删除 `IntegrateFn` / `IntegrateResult` / `ChildL2Summary` 类型 +- `SessionMeta` 删除 `role` / `tags` / `metadata` 三个字段;`SessionFilter.role` / `SessionFilter.tags` 同步删除 +- `ForkOptions` 删除 `tags` / `metadata` 两个字段 +- `assembleMainSessionContext` 函数删除——所有 Session 同构走 `assembleSessionContext` +- 应用域字段建议通过应用层 wrapper Session 承载(spec §4.7);Stello 不再模型化业务字段 +``` + +`packages/core/CHANGELOG.md` 头部加: + +```markdown +## Unreleased — Main Session Decouple + +### Breaking +- 删除 `MAIN_SESSION_ID` 常量 +- 删除 `MainSessionConfig` / `SerializableMainSessionConfig` 类型 +- 删除 `MainSessionCompatible` / `SessionCompatibleIntegrateFn` 适配类型 +- 删除 `DEFAULT_INTEGRATE_PROMPT` 与 `createDefaultIntegrateFn`(外包给 orchestrator client) +- `SessionTree` 接口收敛:删除 `createRoot` / `createChild` / `getRoot`;新增 `createSession({ parentId?, label?, sourceSessionId? })` 唯一入口、`listRoots()`;`getTree()` 改返回 `SessionTreeNode[]` 森林(多 root 合法) +- `StelloAgent` 删除:`createMainSession()` / `integrate()` / `StelloAgentConfig.mainSessionConfig` / `StelloAgentSessionConfig.mainSessionLoader` +- `StelloAgent` 新增:`createSession({ parentId?, label? })` 唯一会话创建入口 +- Engine 在 `forkSession` 中删除 `sourceSessionId === MAIN_SESSION_ID` 跳过分支——root 配置正常被子 fork 继承 + +### Added (orchestrator-facing SDK) +- `StelloAgentConfig.storage?: SessionStorage`(顶层注入;data-IO SDK 依赖) +- `StelloAgent.listSessions(filter?)` / `listRoots()` / `getTopology()` / `getTopologyNode(id)` +- `StelloAgent.getSessionMetadata(id)` → `{ memory, insight }` +- `StelloAgent.listSessionDigests(filter?)` → 取代旧 `getAllSessionL2s` +- `StelloAgent.listMessages(id, opts?)` +- `StelloAgent.putMemory(id, content)` / `putInsight(id, content)` / `clearInsight(id)` + +### Out of Scope +- demo / devtools / visualizer 暂不修,CHANGELOG 标注 breaking +- 旧 `'main'` 目录持久化数据不提供迁移工具(spec §7.3) +- `applyMetadataBatch` 批量原子写、未来 context 槽位(spec §6.1 标 "下轮再讨论") +``` + +- [ ] **Step 6: 版本号** + +按 spec §7.4,**直接发 minor,不发 deprecated alias**。把: + +- `packages/session/package.json:version` 从 `0.7.x` 升到 `0.8.0` +- `packages/core/package.json:version` 从 `0.8.x` 升到 `0.9.0` +- `packages/core/src/index.ts:2`:`export const VERSION = '0.9.0';` + +> 实际版本号根据 `git log` 上一个 release tag 微调;以上为示例。 + +- [ ] **Step 7: Commit** + +```bash +git add packages/session/CHANGELOG.md packages/core/CHANGELOG.md \ + packages/session/package.json packages/core/package.json \ + packages/core/src/index.ts +git commit -m "chore(release): main-session decouple — session@0.8.0 + core@0.9.0" +``` + +- [ ] **Step 8: 推 branch + 开 PR** + +```bash +git push -u origin refactor/decouple-main-session +gh pr create --title "refactor: decouple Main Session from Stello core" --body "$(cat <<'EOF' +## Summary +- 删除 Main Session 概念(类型、工厂、配置、存储) +- 统一为单一 Session;root = `parentId === null` 的普通 Session +- 跨 Session 综合 / insights 推送外包给外部 orchestrator +- 暴露 orchestrator-facing 数据 IO SDK(listSessionDigests / listMessages / put*) + +## Breaking +见 `packages/session/CHANGELOG.md` 与 `packages/core/CHANGELOG.md`。 + +## Out of Scope +- demo / devtools / visualizer 暂不修 +- 旧 'main' 目录持久化数据无自动迁移工具 + +## Test plan +- [x] `pnpm --filter @stello-ai/session typecheck && test && build` +- [x] `pnpm --filter @stello-ai/core typecheck && test && build` +- [x] grep 残留:`MAIN_SESSION_ID|MainSession|createMainSession|mainSessionConfig|mainSessionLoader|MainStorage|IntegrateFn|getAllSessionL2s` 在 src 下应为空 +EOF +)" +``` + +--- + +## 任务完成总结 + +| 任务 | 范围 | 关键删除 / 新增 | +|---|---|---| +| 1 | session | SessionMeta 瘦身 | +| 2 | session | SessionStorage 收敛 | +| 3 | session | MainSession 工厂 / 类型 / 测试删除 | +| 4 | session | index.ts 导出收敛 | +| 5 | core types | MAIN_SESSION_ID 删除 | +| 6 | core types | MainSessionConfig 删除 | +| 7 | core adapter | MainSessionCompatible / IntegrateFn 删除 | +| 8 | core llm | DEFAULT_INTEGRATE_PROMPT / createDefaultIntegrateFn 删除 | +| 9 | core sessions | SessionTreeImpl 重写多 root | +| 10 | core engine | fork-from-main 分支删除 | +| 11 | core agent | createMainSession/integrate/mainSessionConfig 删除;createSession 新增 | +| 12 | core agent | topology SDK 新增 | +| 13 | core agent | data-IO SDK + storage 注入 | +| 14 | core types | 全量导出收敛 | +| 15 | release | CHANGELOG + 版本号 + PR | diff --git a/docs/superpowers/specs/2026-05-16-decouple-main-session-design.md b/docs/superpowers/specs/2026-05-16-decouple-main-session-design.md new file mode 100644 index 0000000..6e0fd0d --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-decouple-main-session-design.md @@ -0,0 +1,345 @@ +# Main Session 解耦 — 架构设计文档 + +> **状态**:Spec(架构与 API 决策) +> **日期**:2026-05-16 +> **范围**:架构层与 API 层定调,不含具体实现细节。实现层细节(具体方法签名、文件级改动清单、测试列表)放到后续 plan 文档。 + +--- + +## 1. 背景与目标 + +### 1.1 现状 + +Stello 当前存在两种 Session 类型: + +- **普通 Session**:上下文组装为 `systemPrompt + identity + insight(消费) + memory + L3 + msg` +- **Main Session**:上下文组装为 `systemPrompt + synthesis(=memory) + L3 + msg`,并独占 `integrate()` 方法以收集所有子 Session 的 L2、调用 IntegrateFn、写回 synthesis 与 per-child insights + +围绕 Main Session 还有一整套配套:`MainSession` 接口、`createMainSession`/`loadMainSession` 工厂、`MainStorage`(SessionStorage 的 superset)、`MainSessionConfig`、`MAIN_SESSION_ID = 'main'` 常量、`StelloAgent.createMainSession` / `StelloAgent.integrate` 方法、`mergeSessionConfig` 中的 main 分支、Engine 在 `fork-from-main` 时的特殊跳过等。 + +### 1.2 重构目标 + +**把 Main Session 概念从 Stello 中彻底删除**: + +- Stello 内部只存在一种 Session +- "对话的起点" = root session = 拓扑中 `parentId === null` 的任一节点 = 普通 Session +- 原 Main Session 承担的"跨 Session 综合 + 定向 insight 推送"职责完全外包给**外部 orchestrator client**(Claude Code / Codex / 用户自写脚本) +- 跨会话能力通过 SDK 上若干**纯数据 API** 体现,不再有任何框架级 LLM 编排 + +### 1.3 非目标 + +- 不重做存储模型(getMemory/putMemory + getInsight/putInsight/clearInsight 等独立方法保持原结构) +- 不重做 tool loop、TurnRunner、ForkProfile、SkillRouter +- 不更新 demo、devtools、visualizer 等下游消费方(暂缓,CHANGELOG 标注) +- 不解决持久化数据从旧 'main' 目录到新 UUID root 的迁移工具 + +--- + +## 2. 架构鸟瞰 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Orchestrator Client(外部 — Claude Code / Codex / 用户脚本) │ +│ ├─ 通过 Stello SDK 调用纯数据 API │ +│ │ listSessions / getTopology / getSessionMetadata / │ +│ │ listSessionDigests / putMemory / putInsight ... │ +│ └─ 在自己的 LLM 上做 reflection,把结果写回 Stello │ +├─────────────────────────────────────────────────────────────┤ +│ Core (StelloAgent / Engine / Orchestrator / Tools / Skills) │ +│ ├─ 只持有一种 Session │ +│ ├─ SessionTree.createSession({ parentId? }) 统一入口 │ +│ ├─ 不再有 MAIN_SESSION_ID / MainSessionConfig / integrate │ +│ ├─ consolidate 仍由框架调度 │ +│ └─ 新增 orchestration-facing SDK 类别 │ +├─────────────────────────────────────────────────────────────┤ +│ Session (`@stello-ai/session`) │ +│ ├─ createSession / loadSession 唯一工厂 │ +│ ├─ SessionStorage 单接口 │ +│ └─ 上下文组装规则全 Session 同构 │ +└─────────────────────────────────────────────────────────────┘ + 依赖注入 ↓ + SessionStorage LLMAdapter ConsolidateFn CompressFn +``` + +**核心立场**:Stello 退回"会话拓扑 + 单 Session 对话 + L2/L3 数据层"。原 Main Session 承担的职责完全外包;框架只暴露数据读写 API + 拓扑查询。 + +--- + +## 3. 删除清单(breaking, 不留 deprecated stub) + +### 3.1 `@stello-ai/session` 层 + +| 删除项 | 性质 | 替代/迁移 | +|---|---|---| +| `MainSession` interface | 类型 | 用 `Session` | +| `createMainSession` / `loadMainSession` | 工厂 | 用 `createSession` / `loadSession` | +| `CreateMainSessionOptions` / `LoadMainSessionOptions` | 类型 | 用 `CreateSessionOptions` / `LoadSessionOptions` | +| `MainStorage` interface | 类型 | 用 `SessionStorage`(同时缩减) | +| `IntegrateFn` / `IntegrateResult` / `ChildL2Summary` | 类型/函数签名 | 外部 orchestrator 自定义 | +| `SessionMeta.role: 'standard' \| 'main'` | 字段 | 完全移除;root 由拓扑决定 | +| `SessionMeta.tags: string[]` | 字段 | 完全移除(见 §4.7 应用层扩展模式) | +| `SessionMeta.metadata: Record` | 字段 | 完全移除(同上) | +| `SessionFilter.role` / `SessionFilter.tags` | 字段 | 移除 | +| `assembleMainSessionContext` | 内部函数 | 用 `assembleSessionContext`(统一规则) | +| `MainSession.synthesis()` 方法 | API | 不再存在;语义并入 `memory()` | +| `MainStorage.getAllSessionL2s` | 存储方法 | 提升为 SDK 级别批量 API | +| `MainStorage.listSessions` | 存储方法 | 提升为 SDK 级别 API | +| `MainStorage.putNode / getChildren / removeNode` | 存储方法 | 完全移除(拓扑统一由 core SessionTree 持有) | +| `MainStorage.getGlobal / putGlobal` | 存储方法 | 移除(未使用) | + +### 3.2 `@stello-ai/core` 层 + +| 删除项 | 性质 | 替代/迁移 | +|---|---|---| +| `MAIN_SESSION_ID = 'main'` | 常量 | 完全移除 | +| `SessionTree.createRoot(label?)` | API | 统一为 `createSession({ parentId?, label? })` | +| `SessionTree.createChild(options)` | API | 同上,统一入口 | +| `SessionTree.getRoot()` | API | 替换为 `listRoots()` | +| `MainSessionConfig` / `SerializableMainSessionConfig` | 类型 | 删除;只保留 `SessionConfig` / `SerializableSessionConfig` | +| `StelloAgentConfig.mainSessionConfig` | 配置 | 删除 | +| `StelloAgentSessionConfig.mainSessionLoader` | 配置 | 删除 | +| `StelloAgent.createMainSession()` | 方法 | 统一为 `createSession({ parentId?, label? })` | +| `StelloAgent.integrate()` | 方法 | 完全移除 | +| `mergeSessionConfig` 中 MainSessionConfig 分支 | 逻辑 | 删除 | +| Engine `forkSession` 中 fork-from-main 跳过逻辑 | 逻辑 | 删除(root 配置正常被 fork 继承) | +| `MainSessionCompatible` / `SessionCompatibleIntegrateFn` | 适配类型 | 删除 | + +### 3.3 保留(不删) + +- `Session.consolidate()` 与 `ConsolidateFn` —— L3→L2 提炼仍在框架内调度 +- `Session.insight()` / `setInsight()` / 存储层 `getInsight/putInsight/clearInsight` +- `Session.memory()` / 存储层 `getMemory/putMemory` +- `Session.fork()`、`ForkOptions`、`ForkProfile` 全套 +- `assembleSessionContext`(成为唯一上下文组装函数) +- 上下文压缩、tool loop、TurnRunner、Engine hooks、SkillRouter + +--- + +## 4. Session 层(`@stello-ai/session`)重塑 + +### 4.1 `SessionMeta` 极简化 + +``` +SessionMeta { + id, label, status, createdAt, updatedAt +} + +SessionMetaUpdate { + label? +} + +SessionFilter { + status? +} +``` + +无 role、无 tags、无 metadata。应用层若需扩展,见 §4.7。 + +### 4.2 唯一 Session 接口 + +`Session` 提供: + +- `meta` / `send` / `stream` / `messages` +- `systemPrompt` / `setSystemPrompt` +- `insight` / `setInsight` / `memory` —— 读 insight 不消费(消费由 send 触发清除) +- `consolidate` —— L3→L2,按注入的 `consolidateFn` 提炼 +- `trimRecords` / `fork` / `updateMeta` / `archive` +- `setLLM` / `tools` / `setTools` + +**取消**:`synthesis()`、`integrate()`。root 不再有任何额外方法 —— 它就是个 Session。 + +### 4.3 工厂 + +唯一入口: + +- `createSession(options): Promise` +- `loadSession(id, options): Promise` + +Session 层**不感知拓扑**。`parentId` 是 core 层 SessionTree 的概念。 + +### 4.4 `SessionStorage` 单一接口 + +合并 MainStorage 后只剩一个接口,包含: + +- SessionMeta CRUD(`getSession` / `putSession`) +- L3(`appendRecord` / `listRecords` / `trimRecords`) +- system prompt(`getSystemPrompt` / `putSystemPrompt`) +- insight(`getInsight` / `putInsight` / `clearInsight`)—— 一次性,send 消费后清除 +- memory(L2)(`getMemory` / `putMemory`)—— 持久,每次 send 注入 +- 事务(`transaction`) + +不再有:拓扑节点 CRUD、`listSessions`、`getAllSessionL2s`、`getGlobal` / `putGlobal`。 + +### 4.5 上下文组装(全 Session 同构) + +``` +[system prompt] ++ [ with label] ++ [insight if present, consume on send] ++ [memory if present] ++ [L3 history with sanitize] ++ [user message] +``` + +Root 与子 Session 同一套规则。Root 的"synthesis"语义自然由 `memory` 承担:orchestrator 调 `putMemory(rootId, ...)` 写综合认知,每次 root.send 注入。**框架对 memory 的语义无感知** —— 它只负责注入。 + +### 4.6 外部数据视图(语义统一) + +无论 storage 怎么拆,对外语义统一为: + +``` +SessionMetadataView { + memory: string | null // 持久 + insight: string | null // 一次性 +} +``` + +SDK 层在调用侧聚合 `getMemory + getInsight` 提供该视图(实现细节见 core 层 §5)。 + +### 4.7 应用层扩展模式(约定) + +> 任何业务字段(conflicts / relations / priority / 自定义 flags ...)**不进入** Stello 的 SessionMeta。应用层定义自己的 wrapper:组合 Stello Session + 应用自己的 side-table 存储,向外暴露包装后的接口。 +> +> Stello 不知道、不约束、不解释应用域字段。SessionMeta 内核接口对所有应用收敛、稳定。 + +理由: + +1. Stello 不应模型化应用域 —— 各应用的 metadata schema 千差万别,强行用 `Record` 既不安全也不便携 +2. 跨会话关系(如 conflicts)天然是**边**而非节点属性;放节点上要应用层维护双向一致性 +3. 应用通过 composition 持有 Session + 私有数据,类型与责任都清晰 + +--- + +## 5. Core 层重塑 + +### 5.1 `SessionTree` API 统一 + +**删除**:`createRoot`、`createChild`、`getRoot`、`MAIN_SESSION_ID`。 + +**新增/调整**: + +- `createSession({ parentId?, label?, sourceSessionId? })` —— **唯一拓扑创建入口**。`parentId` 为空则为新 root。隐式支持多 root。 +- `listRoots()` —— 列出所有 `parentId === null` 的节点 +- `getTree()` —— 返回 `SessionTreeNode[]`(森林形态) + +保留:`get / listAll / archive / addRef / updateMeta / getNode / getAncestors / getSiblings / getConfig / putConfig`。 + +### 5.2 `TopologyNode` 语义微调 + +结构不变,仅"root 唯一"假设取消。多个 `parentId === null` 的节点合法。`getTree` 返回森林。 + +### 5.3 `SessionConfig` 路径简化 + +- 删除 `MainSessionConfig` / `SerializableMainSessionConfig` +- 保留 `SessionConfig` / `SerializableSessionConfig` 不变 +- `mergeSessionConfig` 删除 main 分支,所有 fork 走标准合成链:`defaults → parent → profile → forkOptions` +- **root 配置正常被子 fork 继承**(取消旧的"fork-from-main 跳过父配置"特殊逻辑) + +### 5.4 `Engine` 调整 + +唯一改动:`forkSession` 中删除 `sourceSessionId === MAIN_SESSION_ID` 特殊跳过分支。其余 Engine 逻辑(tool loop、TurnRunner、hooks、consolidate 调度、ForkProfile)不动。 + +### 5.5 `StelloAgent` 顶层 API + +**删除**: + +- `agent.createMainSession()` +- `agent.integrate()` +- `StelloAgentConfig.mainSessionConfig` +- `StelloAgentSessionConfig.mainSessionLoader` + +**新增/调整**: + +- `agent.createSession({ parentId?, label? }): Promise` —— 取代 `createMainSession`。语义:"起一个新会话":parentId 为空建 root;非空挂在该节点下,但**不继承父 Session 上下文 / 配置**。需要继承上下文(含 system prompt、L3、config 合成)应走 `forkSession` +- **新增 orchestration-facing SDK 类别**(具体签名留下轮讨论,本 spec 仅定类别):见 §6 + +**保留**:`enterSession / turn / stream / leaveSession / forkSession / archiveSession / attachSession / detachSession / consolidateSession / updateConfig / hasActiveEngine / getEngineRefCount`。 + +### 5.6 Adapter 层清理 + +- `MainSessionCompatible` 接口删除 +- `SessionCompatibleIntegrateFn` 类型删除 +- `adapters/session-runtime.ts` 中 MainSession 相关分支删除 +- `EngineRuntimeSession` 接口不变(不区分 root/child) + +--- + +## 6. Orchestrator-facing SDK 表面 + +> 本节定调"有哪些类别、挂在哪里、有什么约束",**不定最终签名**。表中括号内的形参/返回是**示意**,便于理解类别边界;具体参数、过滤条件、批量形态留到下轮专门讨论。 + +### 6.1 类别清单 + +| 类别 | 用途 | 本 spec 状态 | +|---|---|---| +| 拓扑查询 | `getTopology` 森林、`getTopologyNode(id)`、`listRoots` | 类别确定 | +| 会话列举 | `listSessions(filter?)` → `SessionMeta[]` | 类别确定 | +| 单会话视图 | `getSessionMetadata(id)` → `{ memory, insight }` | 类别确定 | +| 批量视图 | `listSessionDigests(filter?)` → 每会话 `{ id, label, memory, insight, ... }` | 类别确定,取代 `getAllSessionL2s` | +| L3 读取 | `listMessages(id, opts?)` | 类别确定 | +| 单会话写 | `putMemory / putInsight / clearInsight` | 类别确定 | +| 批量原子写 | `applyMetadataBatch(updates[])` | **下轮再讨论** | +| consolidate 触发 | `consolidateSession(id)` | 已存在 | +| 未来 context 字段扩展 | 未定 | **下轮再讨论** | + +### 6.2 约束(无须签名也可定) + +- **零隐式 LLM 调用**:所有方法都是数据 IO,不会触发 send / integrate / 任何隐式 LLM。`consolidateSession` 是显式动作,consolidateFn 由应用注入 +- **不感知 root/child**:方法对所有 Session 一视同仁,调用方靠拓扑自分 +- **挂在 `StelloAgent` 上**:不开新顶层类 +- **存储后端无关**:调用方只看 SDK,后端 SessionStorage 由应用注入 + +--- + +## 7. 范围、迁移、风险 + +### 7.1 范围**内** + +- `@stello-ai/session` 与 `@stello-ai/core` 两包按本 spec 实施改动 +- Orchestrator-facing SDK 类别与挂载点(具体签名下轮) + +### 7.2 范围**外**(暂缓) + +- `packages/devtools/server`、`packages/devtools/web`、`packages/visualizer` +- `demo/stello-agent-basic`、`demo/stello-agent-chat` +- 应用层 wrapper Session 的官方示例 +- §6.1 中"下轮讨论"的批量原子写、未来 context 扩展 +- 持久化数据(旧 'main' 目录)的自动迁移工具 + +### 7.3 风险与已知 break + +| 风险 | 说明 | 处置 | +|---|---|---| +| demo/devtools 跑不通 | 删 createMainSession / integrate 后下游不编译 | 接受;CHANGELOG 列入 breaking | +| 旧持久化数据 | 已有 file-system 存储里 root 可能写在 'main' 目录下 | 不强制迁移;新版本默认读不到旧 root,应用自行处理 | +| 多 root 边界 | `listRoots()` 为空、跨 root fork、`getTree()` 森林空数组 | 实施时按"多 root 合法"加测试覆盖 | +| 应用层 wrapper 缺示例 | 首次接触 wrapper 模式会困惑 | 文档说明 + 后续 sample;本 spec 只立约定 | +| fork 配置链路变化 | 删除 fork-from-main 跳过后,root 配置被子 fork 继承,可能不是某些 demo 预期 | CHANGELOG 列入 breaking | +| `MAIN_SESSION_ID` 残留 | core/session 两包及 test 都有 import | 实施时全文 grep + typecheck 把关 | + +### 7.4 版本与发布 + +- 直接发 minor,不发 deprecated alias(项目仍 0.x) +- 同时推 `core` 与 `session` 两包新 minor,CHANGELOG 集中说明 +- 暂不升 1.0 + +### 7.5 已知未决问题(spec 不解决,备忘) + +1. 批量原子写 API 形态(`applyMetadataBatch` 类) +2. 未来 context 字段扩展(除 memory/insight 外的新槽位) +3. 持久化文件迁移工具(旧 'main' 目录) +4. Storage 适配器命名(`InMemoryStorageAdapter` 等是否需要随接口收敛而重命名) +5. **StelloAgent 级共享 memory 机制**(Claude Code auto-memory 路线):所有 Session 共享一份 agent-writable memory,索引随 send 注入、详情用内置 tool 懒加载。本次 refactor 不实现,落地后单独 spec。预期影响面:(a) §4.5 在 system prompt 之上插入一个 agent-shared memory index 槽;(b) 新增 AgentStorage 兄弟接口(不挂 SessionStorage),或在 StelloAgent 注入处独立配置;(c) 两个新内置 tool(recall / remember);(d) 并发写策略(沿用 writeLock 模式还是新机制)。Refactor 实施时避免把这些扩展位封死。 + +--- + +## 8. 设计原则回顾 + +本次重构遵循的若干 KISS 立场,便于实施时落到细节决策: + +1. **职责单一** —— Stello 只做拓扑 + 数据 + Session 内调度;跨 Session 的综合判断完全外包 +2. **接口收敛** —— Session 只剩一种、SessionStorage 只剩一个;MainXxx 全删 +3. **composition 优于 data extension** —— 应用域字段通过 wrapper Session,不污染 SessionMeta +4. **不模型化应用域** —— 不预判 tags / metadata / conflicts / relations 等业务字段 +5. **零隐式 LLM 调用** —— orchestrator-facing API 全是纯数据 IO +6. **多 root 自然支持** —— 删除 root 唯一性约束既是删除 MAIN_SESSION_ID 的副产品,也是拓扑 API 自洽的结果 From 121d8dc6ad261a55737098abd2b79d8fe8af9c71 Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 17:28:12 +0800 Subject: [PATCH 02/40] refactor(session): drop role/tags/metadata from SessionMeta --- packages/session/src/__tests__/abort.test.ts | 3 -- .../session/src/__tests__/lifecycle.test.ts | 30 ++----------------- .../src/__tests__/main-session.test.ts | 5 ++-- packages/session/src/__tests__/meta.test.ts | 13 ++------ packages/session/src/create-main-session.ts | 9 +----- packages/session/src/create-session.ts | 8 ----- .../session/src/mocks/in-memory-storage.ts | 9 ++---- packages/session/src/types/functions.ts | 8 ----- packages/session/src/types/session.ts | 10 ------- 9 files changed, 11 insertions(+), 84 deletions(-) diff --git a/packages/session/src/__tests__/abort.test.ts b/packages/session/src/__tests__/abort.test.ts index 7877f27..95b617b 100644 --- a/packages/session/src/__tests__/abort.test.ts +++ b/packages/session/src/__tests__/abort.test.ts @@ -176,10 +176,7 @@ describe('orphaned tool_calls sanitization (abort recovery)', () => { await storage.putSession({ id: sessionId, label: 'Test', - role: 'standard', status: 'active', - tags: [], - metadata: {}, createdAt: now, updatedAt: now, }) diff --git a/packages/session/src/__tests__/lifecycle.test.ts b/packages/session/src/__tests__/lifecycle.test.ts index cc89074..c4e45bc 100644 --- a/packages/session/src/__tests__/lifecycle.test.ts +++ b/packages/session/src/__tests__/lifecycle.test.ts @@ -10,22 +10,10 @@ describe('updateMeta() + archive() + fork()', () => { expect(session.meta.label).toBe('Updated') }) - it('更新 tags', async () => { + it('重命名 label', async () => { const { session } = await makeSession() - await session.updateMeta({ tags: ['tag1', 'tag2'] }) - expect(session.meta.tags).toEqual(['tag1', 'tag2']) - }) - - it('更新 metadata', async () => { - const { session } = await makeSession() - await session.updateMeta({ metadata: { key: 'value' } }) - expect(session.meta.metadata).toEqual({ key: 'value' }) - }) - - it('部分更新不影响其他字段', async () => { - const { session } = await makeSession({ label: 'Keep', tags: ['keep'] }) - await session.updateMeta({ label: 'New' }) - expect(session.meta.tags).toEqual(['keep']) + await session.updateMeta({ label: 'Renamed' }) + expect(session.meta.label).toBe('Renamed') }) it('持久化到 storage', async () => { @@ -65,7 +53,6 @@ describe('updateMeta() + archive() + fork()', () => { const { session } = await makeSession({ label: 'Parent' }) const child = await session.fork({ label: 'Child' }) expect(child.meta.label).toBe('Child') - expect(child.meta.role).toBe('standard') expect(child.meta.status).toBe('active') }) @@ -76,17 +63,6 @@ describe('updateMeta() + archive() + fork()', () => { expect(await child.memory()).toBeNull() }) - it('fork 支持传入 tags 和 metadata', async () => { - const { session } = await makeSession() - const child = await session.fork({ - label: 'Child', - tags: ['forked'], - metadata: { source: 'fork' }, - }) - expect(child.meta.tags).toEqual(['forked']) - expect(child.meta.metadata).toEqual({ source: 'fork' }) - }) - it('fork 持久化子 Session 到 storage', async () => { const { session, storage } = await makeSession() const child = await session.fork({ label: 'Child' }) diff --git a/packages/session/src/__tests__/main-session.test.ts b/packages/session/src/__tests__/main-session.test.ts index 1e0c402..f2102b6 100644 --- a/packages/session/src/__tests__/main-session.test.ts +++ b/packages/session/src/__tests__/main-session.test.ts @@ -1,3 +1,4 @@ +// FIXME: Task 3 will delete this entire file import { describe, it, expect, vi } from 'vitest' import { createMainSession } from '../create-main-session.js' import { createSession } from '../create-session.js' @@ -44,10 +45,10 @@ async function makeWithChildren(integrateFn?: IntegrateFn) { return { main, storage, child1, child2 } } -describe('MainSession meta', () => { +describe.skip('MainSession meta', () => { it('创建后 role 为 main', async () => { const { main } = await makeMainSession() - expect(main.meta.role).toBe('main') + // FIXME: Task 3 will delete this entire test — assertion uses removed SessionMeta.role expect(main.meta.status).toBe('active') }) diff --git a/packages/session/src/__tests__/meta.test.ts b/packages/session/src/__tests__/meta.test.ts index 9f25254..60e5708 100644 --- a/packages/session/src/__tests__/meta.test.ts +++ b/packages/session/src/__tests__/meta.test.ts @@ -9,26 +9,17 @@ describe('meta 同步访问', () => { }) it('meta 包含所有必要字段', async () => { - const { session } = await makeSession({ tags: ['a', 'b'], metadata: { foo: 'bar' } }) + const { session } = await makeSession() const m = session.meta expect(m.id).toBeTruthy() - expect(m.role).toBe('standard') - expect(m.tags).toEqual(['a', 'b']) - expect(m.metadata).toEqual({ foo: 'bar' }) expect(m.createdAt).toBeTruthy() expect(m.updatedAt).toBeTruthy() }) - it('createSession 默认 role 为 standard', async () => { - const { session } = await makeSession() - expect(session.meta.role).toBe('standard') - }) - it('updateMeta 后 meta 同步更新', async () => { const { session } = await makeSession({ label: 'Old' }) - await session.updateMeta({ label: 'New', tags: ['x'] }) + await session.updateMeta({ label: 'New' }) expect(session.meta.label).toBe('New') - expect(session.meta.tags).toEqual(['x']) }) it('meta 更新后 updatedAt 变化', async () => { diff --git a/packages/session/src/create-main-session.ts b/packages/session/src/create-main-session.ts index 10c058c..01f9993 100644 --- a/packages/session/src/create-main-session.ts +++ b/packages/session/src/create-main-session.ts @@ -357,8 +357,6 @@ function buildMainSession( const updatedMeta: SessionMeta = { ...currentMeta, ...(updates.label !== undefined && { label: updates.label }), - ...(updates.tags !== undefined && { tags: updates.tags }), - ...(updates.metadata !== undefined && { metadata: updates.metadata }), updatedAt: new Date().toISOString(), } await storage.putSession(updatedMeta) @@ -377,8 +375,6 @@ function buildMainSession( label: forkOptions.label, systemPrompt: forkOptions.systemPrompt ?? await storage.getSystemPrompt(currentMeta.id) ?? undefined, tools: forkOptions.tools ?? options.tools, - tags: forkOptions.tags, - metadata: forkOptions.metadata, consolidateFn: forkOptions.consolidateFn, compressFn: forkOptions.compressFn, }) @@ -439,10 +435,7 @@ export async function createMainSession(options: CreateMainSessionOptions): Prom const meta: SessionMeta = { id, label: options.label ?? 'Main Session', - role: 'main', status: 'active', - tags: options.tags ?? [], - metadata: options.metadata ?? {}, createdAt: now, updatedAt: now, } @@ -462,6 +455,6 @@ export async function loadMainSession( options: LoadMainSessionOptions ): Promise { const meta = await options.storage.getSession(id) - if (!meta || meta.role !== 'main') return null + if (!meta) return null return buildMainSession(meta, options) } diff --git a/packages/session/src/create-session.ts b/packages/session/src/create-session.ts index e6ae7e9..5a69a2c 100644 --- a/packages/session/src/create-session.ts +++ b/packages/session/src/create-session.ts @@ -387,10 +387,7 @@ function buildSession( const childMeta: SessionMeta = { id: childId, label: forkOptions.label, - role: 'standard', status: 'active', - tags: forkOptions.tags ?? [], - metadata: forkOptions.metadata ?? {}, createdAt: now, updatedAt: now, } @@ -442,8 +439,6 @@ function buildSession( const updatedMeta: SessionMeta = { ...currentMeta, ...(updates.label !== undefined && { label: updates.label }), - ...(updates.tags !== undefined && { tags: updates.tags }), - ...(updates.metadata !== undefined && { metadata: updates.metadata }), updatedAt: new Date().toISOString(), } await storage.putSession(updatedMeta) @@ -484,10 +479,7 @@ export async function createSession(options: CreateSessionOptions): Promise { if (filter.status !== undefined && s.status !== filter.status) return false - if (filter.role !== undefined && s.role !== filter.role) return false - if (filter.tags && filter.tags.length > 0) { - const sessionTags = new Set(s.tags) - if (!filter.tags.every((t) => sessionTags.has(t))) return false - } return true }) } @@ -103,11 +98,11 @@ export class InMemoryStorageAdapter implements MainStorage { this.memories.set(sessionId, content) } - /** 扁平收集所有 standard session 的 L2 */ + /** 扁平收集所有 active session 的 L2 */ async getAllSessionL2s(): Promise { const result: ChildL2Summary[] = [] for (const session of this.sessions.values()) { - if (session.role !== 'standard' || session.status !== 'active') continue + if (session.status !== 'active') continue const l2 = this.memories.get(session.id) if (l2 === undefined) continue result.push({ sessionId: session.id, label: session.label, l2 }) diff --git a/packages/session/src/types/functions.ts b/packages/session/src/types/functions.ts index c0404f5..3b13162 100644 --- a/packages/session/src/types/functions.ts +++ b/packages/session/src/types/functions.ts @@ -40,10 +40,6 @@ export interface CreateSessionOptions { label?: string /** 系统提示词 */ systemPrompt?: string - /** 初始标签 */ - tags?: string[] - /** 初始元数据 */ - metadata?: Record /** 可用工具定义 */ tools?: LLMCompleteOptions['tools'] /** 上下文压缩函数(超阈值时调用) */ @@ -80,10 +76,6 @@ export interface CreateMainSessionOptions { label?: string /** 系统提示词 */ systemPrompt?: string - /** 初始标签 */ - tags?: string[] - /** 初始元数据 */ - metadata?: Record /** 可用工具定义 */ tools?: LLMCompleteOptions['tools'] /** 上下文压缩函数(超阈值时调用) */ diff --git a/packages/session/src/types/session.ts b/packages/session/src/types/session.ts index ebff5fe..e73442e 100644 --- a/packages/session/src/types/session.ts +++ b/packages/session/src/types/session.ts @@ -2,11 +2,7 @@ export interface SessionMeta { readonly id: string label: string - /** 'standard' 为普通会话,'main' 为根主会话 */ - role: 'standard' | 'main' status: 'active' | 'archived' - tags: string[] - metadata: Record createdAt: string updatedAt: string } @@ -14,15 +10,11 @@ export interface SessionMeta { /** 可更新的 SessionMeta 字段子集 */ export interface SessionMetaUpdate { label?: string - tags?: string[] - metadata?: Record } /** 列举 Session 时的过滤条件 */ export interface SessionFilter { status?: 'active' | 'archived' - role?: 'standard' | 'main' - tags?: string[] } import type { Message, LLMAdapter, LLMCompleteOptions } from './llm.js' @@ -46,8 +38,6 @@ export interface ForkOptions { llm?: LLMAdapter /** 覆盖父 Session 的工具列表 */ tools?: LLMCompleteOptions['tools'] - tags?: string[] - metadata?: Record /** 覆盖子 Session 的 consolidateFn(不提供则继承父 Session 的) */ consolidateFn?: ConsolidateFn /** 覆盖子 Session 的 compressFn(不提供则继承父 Session 的) */ From 2d5219ed3e122838e4f58cbfde380d8eb3f78818 Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 17:35:34 +0800 Subject: [PATCH 03/40] test(session): tighten meta/lifecycle tests after SessionMeta trim --- packages/session/src/__tests__/lifecycle.test.ts | 6 ------ packages/session/src/__tests__/meta.test.ts | 2 ++ 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/session/src/__tests__/lifecycle.test.ts b/packages/session/src/__tests__/lifecycle.test.ts index c4e45bc..bdfcef0 100644 --- a/packages/session/src/__tests__/lifecycle.test.ts +++ b/packages/session/src/__tests__/lifecycle.test.ts @@ -10,12 +10,6 @@ describe('updateMeta() + archive() + fork()', () => { expect(session.meta.label).toBe('Updated') }) - it('重命名 label', async () => { - const { session } = await makeSession() - await session.updateMeta({ label: 'Renamed' }) - expect(session.meta.label).toBe('Renamed') - }) - it('持久化到 storage', async () => { const { session, storage } = await makeSession({ label: 'Old' }) await session.updateMeta({ label: 'Persisted' }) diff --git a/packages/session/src/__tests__/meta.test.ts b/packages/session/src/__tests__/meta.test.ts index 60e5708..45eb8ab 100644 --- a/packages/session/src/__tests__/meta.test.ts +++ b/packages/session/src/__tests__/meta.test.ts @@ -14,6 +14,8 @@ describe('meta 同步访问', () => { expect(m.id).toBeTruthy() expect(m.createdAt).toBeTruthy() expect(m.updatedAt).toBeTruthy() + expect(m.label).toBeTruthy() + expect(m.status).toBe('active') }) it('updateMeta 后 meta 同步更新', async () => { From 9979b8f5179bf7e29fbfea772e37cbd5b5167744 Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 17:41:39 +0800 Subject: [PATCH 04/40] refactor(session): merge MainStorage into SessionStorage - Delete MainStorage interface; SessionStorage now carries listSessions. - Remove TopologyNode from session package (lives in core). - Drop getAllSessionL2s / putNode / getChildren / removeNode / getGlobal / putGlobal. - InMemoryStorageAdapter now implements SessionStorage only. - Update CreateMainSessionOptions / LoadMainSessionOptions to use SessionStorage. - Stub createMainSession.integrate() child collection; file is deleted in Task 3. - Skip MainSession integrate() / synthesis-from-integrate tests; will be removed in Task 3. --- .../src/__tests__/main-session.test.ts | 6 +- packages/session/src/create-main-session.ts | 5 +- packages/session/src/index.ts | 2 +- .../session/src/mocks/in-memory-storage.ts | 48 ++-------------- packages/session/src/types/functions.ts | 10 ++-- packages/session/src/types/storage.ts | 55 ++++--------------- 6 files changed, 29 insertions(+), 97 deletions(-) diff --git a/packages/session/src/__tests__/main-session.test.ts b/packages/session/src/__tests__/main-session.test.ts index f2102b6..a210650 100644 --- a/packages/session/src/__tests__/main-session.test.ts +++ b/packages/session/src/__tests__/main-session.test.ts @@ -77,7 +77,8 @@ describe('MainSession synthesis()', () => { expect(await main.synthesis()).toBeNull() }) - it('integrate 后 synthesis 可读', async () => { + // Task 2 stubs out getAllSessionL2s; this test will be removed in Task 3. + it.skip('integrate 后 synthesis 可读', async () => { const fn: IntegrateFn = async (children) => ({ synthesis: `共 ${children.length} 个子任务`, insights: [], @@ -90,7 +91,8 @@ describe('MainSession synthesis()', () => { }) }) -describe('MainSession integrate()', () => { +// Task 2 stubs out getAllSessionL2s; integrate() tests will be removed in Task 3. +describe.skip('MainSession integrate()', () => { it('IntegrateFn 接收所有子 Session 的 L2', async () => { const fn = vi.fn(async () => ({ synthesis: 'ok', diff --git a/packages/session/src/create-main-session.ts b/packages/session/src/create-main-session.ts index 01f9993..021042c 100644 --- a/packages/session/src/create-main-session.ts +++ b/packages/session/src/create-main-session.ts @@ -6,7 +6,7 @@ import type { SessionMeta, SessionMetaUpdate, ForkOptions } from './types/sessio import type { Message } from './types/llm.js' import type { IntegrateResult, CreateMainSessionOptions, LoadMainSessionOptions, - SendResult, StreamResult, + SendResult, StreamResult, ChildL2Summary, } from './types/functions.js' import { createSession } from './create-session.js' import { assembleMainSessionContext, createBuiltinCompressFn, type CompressionCache } from './context-utils.js' @@ -319,7 +319,8 @@ function buildMainSession( } // 1. 扁平收集所有子 Session 的 L2 - const childSummaries = await storage.getAllSessionL2s() + // FIXME: Task 3 deletes this file entirely. Stub so this file typechecks meanwhile. + const childSummaries: ChildL2Summary[] = [] const validChildSessionIds = new Set(childSummaries.map((child) => child.sessionId)) // 2. 读取当前 synthesis diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index 8d36204..8085cf9 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -1,6 +1,6 @@ // 类型导出 — Session export type { SessionMeta, SessionMetaUpdate, SessionFilter, ForkOptions, ForkContextFn } from './types/session.js' -export type { SessionStorage, MainStorage, ListRecordsOptions, TopologyNode } from './types/storage.js' +export type { SessionStorage, ListRecordsOptions } from './types/storage.js' export type { Message, ToolCall, LLMCompleteOptions, LLMResult, LLMChunk, LLMAdapter, } from './types/llm.js' diff --git a/packages/session/src/mocks/in-memory-storage.ts b/packages/session/src/mocks/in-memory-storage.ts index 82528e6..5af63a5 100644 --- a/packages/session/src/mocks/in-memory-storage.ts +++ b/packages/session/src/mocks/in-memory-storage.ts @@ -1,20 +1,16 @@ -import type { MainStorage, SessionStorage, ListRecordsOptions, TopologyNode } from '../types/storage.js' +import type { SessionStorage, ListRecordsOptions } from '../types/storage.js' import type { SessionMeta, SessionFilter } from '../types/session.js' -import type { ChildL2Summary } from '../types/functions.js' import type { Message } from '../types/llm.js' /** - * InMemoryStorageAdapter — 完整的内存存储实现,主要用于测试 - * 实现 MainStorage(superset),可按需当作 SessionStorage 使用 + * InMemoryStorageAdapter — SessionStorage 的内存实现,主要用于测试 */ -export class InMemoryStorageAdapter implements MainStorage { +export class InMemoryStorageAdapter implements SessionStorage { private sessions = new Map() private records = new Map() private memories = new Map() private systemPrompts = new Map() private insights = new Map() - private nodes = new Map() - private globals = new Map() async getSession(id: string): Promise { return this.sessions.get(id) ?? null @@ -27,11 +23,7 @@ export class InMemoryStorageAdapter implements MainStorage { async listSessions(filter?: SessionFilter): Promise { const all = Array.from(this.sessions.values()) if (!filter) return all - - return all.filter((s) => { - if (filter.status !== undefined && s.status !== filter.status) return false - return true - }) + return all.filter((s) => filter.status === undefined || s.status === filter.status) } async appendRecord(sessionId: string, record: Message): Promise { @@ -98,38 +90,6 @@ export class InMemoryStorageAdapter implements MainStorage { this.memories.set(sessionId, content) } - /** 扁平收集所有 active session 的 L2 */ - async getAllSessionL2s(): Promise { - const result: ChildL2Summary[] = [] - for (const session of this.sessions.values()) { - if (session.status !== 'active') continue - const l2 = this.memories.get(session.id) - if (l2 === undefined) continue - result.push({ sessionId: session.id, label: session.label, l2 }) - } - return result - } - - async putNode(node: TopologyNode): Promise { - this.nodes.set(node.id, { ...node }) - } - - async getChildren(parentId: string): Promise { - return Array.from(this.nodes.values()).filter((n) => n.parentId === parentId) - } - - async removeNode(nodeId: string): Promise { - this.nodes.delete(nodeId) - } - - async getGlobal(key: string): Promise { - return this.globals.get(key) ?? null - } - - async putGlobal(key: string, value: unknown): Promise { - this.globals.set(key, value) - } - async transaction(fn: (tx: SessionStorage) => Promise): Promise { return fn(this) } diff --git a/packages/session/src/types/functions.ts b/packages/session/src/types/functions.ts index 3b13162..1372c46 100644 --- a/packages/session/src/types/functions.ts +++ b/packages/session/src/types/functions.ts @@ -1,5 +1,5 @@ import type { Message, LLMAdapter, ToolCall, LLMCompleteOptions } from './llm.js' -import type { SessionStorage, MainStorage } from './storage.js' +import type { SessionStorage } from './storage.js' /** consolidate 函数签名:L3 → L2,接收当前 L2 和 L3 记录,返回新 L2 */ export type ConsolidateFn = (currentMemory: string | null, messages: Message[]) => Promise @@ -68,8 +68,8 @@ export interface LoadSessionOptions { /** createMainSession() 的选项 */ export interface CreateMainSessionOptions { - /** 指定存储适配器(Main Session 需要 MainStorage) */ - storage: MainStorage + /** 指定存储适配器 */ + storage: SessionStorage /** 指定 LLM 适配器 */ llm?: LLMAdapter /** Main Session 标签 */ @@ -87,8 +87,8 @@ export interface CreateMainSessionOptions { /** loadMainSession() 的选项 */ export interface LoadMainSessionOptions { - /** 指定存储适配器(Main Session 需要 MainStorage) */ - storage: MainStorage + /** 指定存储适配器 */ + storage: SessionStorage /** LLM 适配器 */ llm?: LLMAdapter /** 系统提示词 */ diff --git a/packages/session/src/types/storage.ts b/packages/session/src/types/storage.ts index a0a4ecd..0d9b989 100644 --- a/packages/session/src/types/storage.ts +++ b/packages/session/src/types/storage.ts @@ -1,6 +1,5 @@ import type { SessionMeta, SessionFilter } from './session.js' import type { Message } from './llm.js' -import type { ChildL2Summary } from './functions.js' /** 列举消息记录时的选项 */ export interface ListRecordsOptions { @@ -11,14 +10,18 @@ export interface ListRecordsOptions { } /** - * SessionStorage — 单个 Session 的数据操作接口 - * 普通 Session 注入此接口,只能操作自身数据,不感知其他 Session + * SessionStorage — Session 数据操作接口 + * + * 所有 Session(含 root)共用同一个接口。 + * 拓扑节点 CRUD 由 core SessionTree 持有,不在此接口职责内。 */ export interface SessionStorage { /** 读取 Session 元数据,不存在返回 null */ getSession(id: string): Promise /** 写入或更新 Session 元数据 */ putSession(session: SessionMeta): Promise + /** 列举 Session(按状态过滤) */ + listSessions(filter?: SessionFilter): Promise /** 追加一条对话记录(L3) */ appendRecord(sessionId: string, record: Message): Promise @@ -27,57 +30,23 @@ export interface SessionStorage { /** 裁剪旧 L3 记录,仅保留最近 keepRecent 条 */ trimRecords(sessionId: string, keepRecent: number): Promise - /** 读取 Session 的 system prompt,不存在返回 null */ + /** 读取 Session 的 system prompt */ getSystemPrompt(sessionId: string): Promise /** 写入 Session 的 system prompt */ putSystemPrompt(sessionId: string, content: string): Promise - /** 读取 Session 的 insight(Main Session 推送的洞察),不存在返回 null */ + /** 读取 Session 的 insight,一次性,send 消费后调用 clearInsight */ getInsight(sessionId: string): Promise /** 写入 Session 的 insight */ putInsight(sessionId: string, content: string): Promise - /** 清除 Session 的 insight(send 消费后调用) */ + /** 清除 Session 的 insight */ clearInsight(sessionId: string): Promise - /** 读取 Session 的记忆摘要(子 Session = L2,Main Session = synthesis) */ + /** 读取 Session 的持久 memory(原 L2 / 原 synthesis 统一槽位) */ getMemory(sessionId: string): Promise - /** 写入 Session 的记忆摘要 */ + /** 写入 Session 的 memory */ putMemory(sessionId: string, content: string): Promise - /** 在事务中执行操作(内存实现可直接执行 fn) */ + /** 事务(内存实现可直接执行 fn) */ transaction(fn: (tx: SessionStorage) => Promise): Promise } - -/** 拓扑树节点(轻量,仅供前端渲染用) */ -export interface TopologyNode { - /** 节点 ID,等于 sessionId */ - id: string - /** 树中的父节点 ID(null 表示根节点,即 Main Session) */ - parentId: string | null - /** 冗余存储的标签,避免渲染树时加载完整 SessionMeta */ - label: string -} - -/** - * MainStorage — Main Session 的存储接口,继承 SessionStorage - * 额外提供:批量 L2 收集、拓扑树操作、Session 列举、全局键值 - */ -export interface MainStorage extends SessionStorage { - /** 批量获取所有子 Session 的 L2(integration 专用,扁平收集,不走树) */ - getAllSessionL2s(): Promise - - /** 列举 Session,可按条件过滤 */ - listSessions(filter?: SessionFilter): Promise - - /** 添加拓扑树节点 */ - putNode(node: TopologyNode): Promise - /** 获取某节点的直接子节点(前端懒加载用) */ - getChildren(parentId: string): Promise - /** 删除拓扑树节点 */ - removeNode(nodeId: string): Promise - - /** 读取全局键值,不存在返回 null */ - getGlobal(key: string): Promise - /** 写入全局键值 */ - putGlobal(key: string, value: unknown): Promise -} From 61a7622745cbbd6fe4f18a232e5a99a096109d44 Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 17:51:36 +0800 Subject: [PATCH 05/40] refactor(session): delete MainSession interface, factory, and dedicated tests --- .../src/__tests__/context-compress.test.ts | 40 +- .../src/__tests__/integration-llm.test.ts | 27 - .../src/__tests__/main-session.test.ts | 401 --------------- packages/session/src/context-utils.ts | 86 ---- packages/session/src/create-main-session.ts | 461 ------------------ packages/session/src/index.ts | 13 - packages/session/src/types/functions.ts | 57 --- .../session/src/types/main-session-api.ts | 59 --- 8 files changed, 20 insertions(+), 1124 deletions(-) delete mode 100644 packages/session/src/__tests__/main-session.test.ts delete mode 100644 packages/session/src/create-main-session.ts delete mode 100644 packages/session/src/types/main-session-api.ts diff --git a/packages/session/src/__tests__/context-compress.test.ts b/packages/session/src/__tests__/context-compress.test.ts index a97f166..500908c 100644 --- a/packages/session/src/__tests__/context-compress.test.ts +++ b/packages/session/src/__tests__/context-compress.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest' import { makeSession, createMockLLM } from './helpers.js' -import { createMainSession } from '../create-main-session.js' +import { createSession } from '../create-session.js' import { InMemoryStorageAdapter } from '../mocks/in-memory-storage.js' import { SessionArchivedError } from '../types/session-api.js' import type { LLMResult, Message, LLMAdapter } from '../types/llm.js' @@ -220,8 +220,8 @@ describe('自动压缩 — Session', () => { }) }) -describe('自动压缩 — MainSession', () => { - it('超阈值时使用 compressFn 压缩(synthesis 保留)', async () => { +describe('自动压缩 — Session(compress + insight 共存)', () => { + it('超阈值时使用 compressFn 压缩(insight 保留)', async () => { const capturedMessages: Message[][] = [] const storage = new InMemoryStorageAdapter() const llm = createMockLLMWithContext([simpleResponse], 50) @@ -233,26 +233,26 @@ describe('自动压缩 — MainSession', () => { const compressFn: CompressFn = vi.fn(async () => 'main compressed') - const mainSession = await createMainSession({ storage, llm, compressFn }) - const id = mainSession.meta.id + const session = await createSession({ storage, llm, compressFn, label: 'Test Root' }) + const id = session.meta.id - await storage.putMemory(id, 'global synthesis') + await storage.putInsight(id, 'global insight') for (let i = 0; i < 10; i++) { await storage.appendRecord(id, { role: 'user', content: `msg ${i} with padding` }) await storage.appendRecord(id, { role: 'assistant', content: `reply ${i} with padding` }) } - await mainSession.send('new') + await session.send('new') const call = capturedMessages[0]! - // synthesis 仍在上下文中 - expect(call.some(m => m.content === 'global synthesis')).toBe(true) + // insight 仍在上下文中 + expect(call.some(m => m.content === 'global insight')).toBe(true) // 压缩摘要也在上下文中 expect(call.some(m => m.content === 'main compressed')).toBe(true) expect(call.length).toBeLessThan(22) }) - it('未传 compressFn 时 MainSession 自动使用内置 LLM 压缩(synthesis 保留)', async () => { + it('未传 compressFn 时 Session 自动使用内置 LLM 压缩(insight 保留)', async () => { const capturedMessages: Message[][] = [] const storage = new InMemoryStorageAdapter() const compressResponse: LLMResult = { content: 'main builtin compressed', usage: { promptTokens: 10, completionTokens: 5 } } @@ -263,37 +263,37 @@ describe('自动压缩 — MainSession', () => { return origComplete(msgs) } - const mainSession = await createMainSession({ storage, llm }) - const id = mainSession.meta.id + const session = await createSession({ storage, llm, label: 'Test Root' }) + const id = session.meta.id - await storage.putMemory(id, 'global synthesis') + await storage.putInsight(id, 'global insight') for (let i = 0; i < 10; i++) { await storage.appendRecord(id, { role: 'user', content: `msg ${i} with padding` }) await storage.appendRecord(id, { role: 'assistant', content: `reply ${i} with padding` }) } - await mainSession.send('new') + await session.send('new') // 第二次调用是实际 send const sendCall = capturedMessages[1]! - expect(sendCall.some(m => m.content === 'global synthesis')).toBe(true) + expect(sendCall.some(m => m.content === 'global insight')).toBe(true) expect(sendCall.some(m => m.content === 'main builtin compressed')).toBe(true) expect(sendCall.length).toBeLessThan(22) }) - it('MainSession trimRecords 正常工作', async () => { + it('Session trimRecords 正常工作', async () => { const storage = new InMemoryStorageAdapter() - const mainSession = await createMainSession({ storage }) - const id = mainSession.meta.id + const session = await createSession({ storage, label: 'Test Root' }) + const id = session.meta.id await storage.appendRecord(id, { role: 'user', content: 'a' }) await storage.appendRecord(id, { role: 'assistant', content: 'b' }) await storage.appendRecord(id, { role: 'user', content: 'c' }) await storage.appendRecord(id, { role: 'assistant', content: 'd' }) - await mainSession.trimRecords(2) + await session.trimRecords(2) - const msgs = await mainSession.messages() + const msgs = await session.messages() expect(msgs).toHaveLength(2) expect(msgs[0]!.content).toBe('c') }) diff --git a/packages/session/src/__tests__/integration-llm.test.ts b/packages/session/src/__tests__/integration-llm.test.ts index 9bc2619..cda57aa 100644 --- a/packages/session/src/__tests__/integration-llm.test.ts +++ b/packages/session/src/__tests__/integration-llm.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest' import { createSession } from '../create-session.js' -import { createMainSession } from '../create-main-session.js' import { InMemoryStorageAdapter } from '../mocks/in-memory-storage.js' import { createOpenAICompatibleAdapter } from '../adapters/openai-compatible.js' import { createAnthropicAdapter } from '../adapters/anthropic.js' @@ -53,30 +52,6 @@ function defineLLMTests(getLLM: () => LLMAdapter) { }, 60_000) } -/** MainSession 集成测试用例 */ -function defineMainSessionLLMTests(getLLM: () => LLMAdapter) { - it('MainSession 单轮对话:send() 返回非空内容并存 L3', async () => { - const storage = new InMemoryStorageAdapter() - const main = await createMainSession({ - storage, - llm: getLLM(), - systemPrompt: '你是一个简洁的助手,用一句话回答问题', - }) - - const result = await main.send('1+1等于几?') - console.log('[MainSession 单轮] content:', result.content) - console.log('[MainSession 单轮] usage:', result.usage) - - expect(result.content).toBeTruthy() - expect(result.usage).toBeDefined() - - const messages = await main.messages() - expect(messages).toHaveLength(2) - expect(messages[0]!.role).toBe('user') - expect(messages[1]!.role).toBe('assistant') - }, 30_000) -} - // --- OpenAI 兼容协议(MiniMax / DeepSeek / OpenAI 等) --- const openaiKey = process.env.OPENAI_API_KEY @@ -95,7 +70,6 @@ describe.skipIf(!openaiKey)('OpenAI 兼容集成测试', () => { }) } defineLLMTests(() => llm) - defineMainSessionLLMTests(() => llm) }) // --- Anthropic 原生协议 --- @@ -115,5 +89,4 @@ describe.skipIf(!anthropicKey)('Anthropic 集成测试', () => { }) } defineLLMTests(() => llm) - defineMainSessionLLMTests(() => llm) }) diff --git a/packages/session/src/__tests__/main-session.test.ts b/packages/session/src/__tests__/main-session.test.ts deleted file mode 100644 index a210650..0000000 --- a/packages/session/src/__tests__/main-session.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -// FIXME: Task 3 will delete this entire file -import { describe, it, expect, vi } from 'vitest' -import { createMainSession } from '../create-main-session.js' -import { createSession } from '../create-session.js' -import { InMemoryStorageAdapter } from '../mocks/in-memory-storage.js' -import { SessionArchivedError } from '../types/session-api.js' -import type { IntegrateFn } from '../types/functions.js' -import type { LLMAdapter, LLMResult, Message } from '../types/llm.js' - -/** 创建 mock LLMAdapter */ -function makeMockLLM(response: Partial = {}): LLMAdapter { - return { - maxContextTokens: 1_000_000, - complete: vi.fn(async () => ({ - content: response.content ?? 'mock response', - toolCalls: response.toolCalls, - usage: response.usage ?? { promptTokens: 10, completionTokens: 5 }, - })), - } -} - -/** 快速创建测试用 MainSession */ -async function makeMainSession(options?: { llm?: LLMAdapter; integrateFn?: IntegrateFn }) { - const storage = new InMemoryStorageAdapter() - const main = await createMainSession({ storage, label: 'Test Main', llm: options?.llm, integrateFn: options?.integrateFn }) - return { main, storage } -} - -/** 创建 MainSession + 带 L2 的子 Session */ -async function makeWithChildren(integrateFn?: IntegrateFn) { - const storage = new InMemoryStorageAdapter() - const main = await createMainSession({ storage, integrateFn }) - - const child1 = await createSession({ - storage, label: '选校', - }) - const child2 = await createSession({ - storage, label: '文书', - }) - - // 写入 L2 - await storage.putMemory(child1.meta.id, '已确定 top5 CS 项目') - await storage.putMemory(child2.meta.id, 'PS 初稿已完成') - - return { main, storage, child1, child2 } -} - -describe.skip('MainSession meta', () => { - it('创建后 role 为 main', async () => { - const { main } = await makeMainSession() - // FIXME: Task 3 will delete this entire test — assertion uses removed SessionMeta.role - expect(main.meta.status).toBe('active') - }) - - it('updateMeta 更新 label', async () => { - const { main } = await makeMainSession() - await main.updateMeta({ label: 'Updated' }) - expect(main.meta.label).toBe('Updated') - }) - - it('archive 后 status 变为 archived', async () => { - const { main } = await makeMainSession() - await main.archive() - expect(main.meta.status).toBe('archived') - }) - - it('archived 后 updateMeta 抛错', async () => { - const { main } = await makeMainSession() - await main.archive() - await expect(main.updateMeta({ label: 'X' })).rejects.toThrow(SessionArchivedError) - }) -}) - -describe('MainSession synthesis()', () => { - it('初始 synthesis 为 null', async () => { - const { main } = await makeMainSession() - expect(await main.synthesis()).toBeNull() - }) - - // Task 2 stubs out getAllSessionL2s; this test will be removed in Task 3. - it.skip('integrate 后 synthesis 可读', async () => { - const fn: IntegrateFn = async (children) => ({ - synthesis: `共 ${children.length} 个子任务`, - insights: [], - }) - const { main } = await makeWithChildren(fn) - - await main.integrate() - - expect(await main.synthesis()).toBe('共 2 个子任务') - }) -}) - -// Task 2 stubs out getAllSessionL2s; integrate() tests will be removed in Task 3. -describe.skip('MainSession integrate()', () => { - it('IntegrateFn 接收所有子 Session 的 L2', async () => { - const fn = vi.fn(async () => ({ - synthesis: 'ok', - insights: [], - })) - const { main } = await makeWithChildren(fn) - - await main.integrate() - - expect(fn).toHaveBeenCalledTimes(1) - const children = fn.mock.calls[0]![0] - expect(children).toHaveLength(2) - expect(children.map((c) => c.label).sort()).toEqual(['文书', '选校']) - expect(children.find((c) => c.label === '选校')?.l2).toBe('已确定 top5 CS 项目') - }) - - it('IntegrateFn 接收当前 synthesis', async () => { - // 先做一次 integrate,用第一个 fn - let callCount = 0 - const fn = vi.fn(async (_children, current) => { - callCount++ - if (callCount === 1) { - return { synthesis: 'first synthesis', insights: [] } - } - return { synthesis: `updated from: ${current}`, insights: [] } - }) - const { main } = await makeWithChildren(fn) - - // 第一次 integrate - await main.integrate() - // 第二次应收到 first synthesis - await main.integrate() - - expect(fn.mock.calls[1]![1]).toBe('first synthesis') - expect(await main.synthesis()).toBe('updated from: first synthesis') - }) - - it('insights 推送到子 Session', async () => { - const { main, storage, child1, child2 } = await makeWithChildren( - async () => ({ - synthesis: 'overview', - insights: [ - { sessionId: child1.meta.id, content: '加快进度' }, - { sessionId: child2.meta.id, content: 'DDL 临近' }, - ], - }) - ) - - await main.integrate() - - // 验证 insights 已写入子 Session - const insight1 = await storage.getInsight(child1.meta.id) - const insight2 = await storage.getInsight(child2.meta.id) - expect(insight1).toBeTruthy() - expect(insight2).toBeTruthy() - }) - - it('忽略返回给不存在 sessionId 的 insights', async () => { - const { main, storage, child1 } = await makeWithChildren( - async () => ({ - synthesis: 'overview', - insights: [ - { sessionId: child1.meta.id, content: '保留这条' }, - { sessionId: 'fake-session-id', content: '丢弃这条' }, - ], - }) - ) - - const result = await main.integrate() - - expect(result.insights).toEqual([ - { sessionId: child1.meta.id, content: '保留这条' }, - ]) - expect(await storage.getInsight(child1.meta.id)).toBe('保留这条') - expect(await storage.getInsight('fake-session-id')).toBeNull() - }) - - it('无子 Session 时 IntegrateFn 接收空数组', async () => { - const fn = vi.fn(async () => ({ - synthesis: 'empty', - insights: [], - })) - const { main } = await makeMainSession({ integrateFn: fn }) - - await main.integrate() - - expect(fn.mock.calls[0]![0]).toEqual([]) - }) - - it('archived 后 integrate 抛错', async () => { - const fn: IntegrateFn = async () => ({ synthesis: '', insights: [] }) - const { main } = await makeMainSession({ integrateFn: fn }) - await main.archive() - await expect(main.integrate()).rejects.toThrow(SessionArchivedError) - }) - - it('未配置 integrateFn 时 integrate() 抛错', async () => { - const { main } = await makeMainSession() - await expect(main.integrate()).rejects.toThrow('No integrateFn configured for this main session') - }) -}) - -describe('MainSession systemPrompt()', () => { - it('初始返回 null(未设置时)', async () => { - const { main } = await makeMainSession() - expect(await main.systemPrompt()).toBeNull() - }) - - it('createMainSession 传入 systemPrompt 后可读', async () => { - const storage = new InMemoryStorageAdapter() - const main = await createMainSession({ storage, systemPrompt: 'Main prompt' }) - expect(await main.systemPrompt()).toBe('Main prompt') - }) - - it('setSystemPrompt + systemPrompt 往返正确', async () => { - const { main } = await makeMainSession() - await main.setSystemPrompt('New prompt') - expect(await main.systemPrompt()).toBe('New prompt') - }) - - it('archived 后 setSystemPrompt 抛错', async () => { - const { main } = await makeMainSession() - await main.archive() - await expect(main.setSystemPrompt('x')).rejects.toThrow(SessionArchivedError) - }) -}) - -describe('MainSession send()', () => { - it('调用 LLM 并返回 SendResult', async () => { - const llm = makeMockLLM({ content: 'hello back' }) - const { main } = await makeMainSession({ llm }) - - const result = await main.send('hello') - - expect(result.content).toBe('hello back') - expect(result.usage).toBeDefined() - expect(llm.complete).toHaveBeenCalledTimes(1) - }) - - it('返回 toolCalls 时会把 assistant toolCalls 写入 L3', async () => { - const llm = makeMockLLM({ - content: '', - toolCalls: [{ id: 'tc_1', name: 'search', input: { q: 'test' } }], - }) - const { main } = await makeMainSession({ llm }) - - await main.send('搜索 test') - - const messages = await main.messages() - expect(messages).toHaveLength(2) - expect(messages[1]!.role).toBe('assistant') - expect(messages[1]!.toolCalls).toEqual([{ id: 'tc_1', name: 'search', input: { q: 'test' } }]) - }) - - it('toolResults continuation 会回放 assistant toolCalls 和 tool 消息', async () => { - const llm = { - maxContextTokens: 1_000_000, - complete: vi - .fn() - .mockResolvedValueOnce({ - content: '', - toolCalls: [{ id: 'tc_1', name: 'search', input: { q: 'test' } }], - }) - .mockResolvedValueOnce({ - content: '最终答案', - usage: { promptTokens: 10, completionTokens: 5 }, - }), - } satisfies LLMAdapter - const { main } = await makeMainSession({ llm }) - - await main.send('搜索 test') - await main.send(JSON.stringify({ - toolResults: [{ - toolCallId: 'tc_1', - toolName: 'search', - args: { q: 'test' }, - success: true, - data: { hits: 2 }, - error: null, - }], - })) - - const secondCall = (llm.complete as ReturnType).mock.calls[1]![0] as Array - expect(secondCall[0]).toMatchObject({ role: 'user', content: '搜索 test' }) - expect(secondCall[1]).toMatchObject({ - role: 'assistant', - content: '', - toolCalls: [{ id: 'tc_1', name: 'search', input: { q: 'test' } }], - }) - expect(secondCall[2]).toMatchObject({ role: 'tool', toolCallId: 'tc_1' }) - - const persisted = await main.messages() - expect(persisted.map((message) => message.role)).toEqual(['user', 'assistant', 'tool', 'assistant']) - }) - - it('自动存 L3(user + assistant)', async () => { - const llm = makeMockLLM() - const { main } = await makeMainSession({ llm }) - - await main.send('hello') - - const messages = await main.messages() - expect(messages).toHaveLength(2) - expect(messages[0]!.role).toBe('user') - expect(messages[0]!.content).toBe('hello') - expect(messages[1]!.role).toBe('assistant') - expect(messages[1]!.content).toBe('mock response') - }) - - it('上下文使用 synthesis 而非 insights', async () => { - const llm = makeMockLLM() - const storage = new InMemoryStorageAdapter() - const main = await createMainSession({ - storage, llm, systemPrompt: 'You are helpful', - }) - - // 写入 synthesis - await storage.putMemory(main.meta.id, 'synthesis content') - // 写入 insight(不应出现在上下文中) - await storage.putInsight(main.meta.id, 'insight content') - - await main.send('hello') - - const calledMessages = (llm.complete as ReturnType).mock.calls[0]![0] - const systemMessages = calledMessages.filter((m: { role: string }) => m.role === 'system') - expect(systemMessages).toHaveLength(2) - expect(systemMessages[0].content).toBe('You are helpful') - expect(systemMessages[1].content).toBe('synthesis content') - // insights 不应出现 - expect(calledMessages.every((m: { content: string }) => m.content !== 'insight content')).toBe(true) - }) - - it('无 LLM 时抛错', async () => { - const { main } = await makeMainSession() - await expect(main.send('hello')).rejects.toThrow('LLMAdapter is required for send()') - }) - - it('archived 时抛 SessionArchivedError', async () => { - const llm = makeMockLLM() - const { main } = await makeMainSession({ llm }) - await main.archive() - await expect(main.send('hello')).rejects.toThrow(SessionArchivedError) - }) - - it('stream() 流式输出', async () => { - const llm: LLMAdapter = { - maxContextTokens: 1_000_000, - complete: vi.fn(async () => ({ content: 'hello stream' })), - async *stream() { - yield { delta: 'hello ' } - yield { delta: 'stream' } - }, - } - const { main } = await makeMainSession({ llm }) - - const stream = main.stream('hello') - const chunks: string[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - const result = await stream.result - - expect(chunks).toEqual(['hello ', 'stream']) - expect(result.content).toBe('hello stream') - - const messages = await main.messages() - expect(messages).toHaveLength(2) - expect(messages[1]!.content).toBe('hello stream') - }) -}) - -describe('MainSession.setTools (per-session tool list mutation)', () => { - it('setTools replaces the tools auto-injected on next send', async () => { - const llmComplete = vi.fn().mockResolvedValue({ content: 'ok', toolCalls: [] }) - const llm = { complete: llmComplete, stream: vi.fn(), maxContextTokens: 1_000_000 } - const storage = new InMemoryStorageAdapter() - const main = await createMainSession({ - storage, - llm, - tools: [{ name: 'old', description: 'd', inputSchema: {} }], - }) - - expect(main.tools).toEqual([{ name: 'old', description: 'd', inputSchema: {} }]) - - main.setTools([{ name: 'new', description: 'd2', inputSchema: {} }]) - expect(main.tools).toEqual([{ name: 'new', description: 'd2', inputSchema: {} }]) - - await main.send('hi') - const passedTools = llmComplete.mock.calls[0]![1]?.tools - expect(passedTools).toEqual([{ name: 'new', description: 'd2', inputSchema: {} }]) - }) - - it('setTools(undefined) clears tools', async () => { - const llmComplete = vi.fn().mockResolvedValue({ content: 'ok', toolCalls: [] }) - const llm = { complete: llmComplete, stream: vi.fn(), maxContextTokens: 1_000_000 } - const storage = new InMemoryStorageAdapter() - const main = await createMainSession({ - storage, - llm, - tools: [{ name: 'x', description: 'd', inputSchema: {} }], - }) - main.setTools(undefined) - await main.send('hi') - expect(llmComplete.mock.calls[0]![1]?.tools).toBeUndefined() - }) -}) diff --git a/packages/session/src/context-utils.ts b/packages/session/src/context-utils.ts index 3ef8d52..8b1760a 100644 --- a/packages/session/src/context-utils.ts +++ b/packages/session/src/context-utils.ts @@ -281,89 +281,3 @@ async function compressWithFn( compressionCache: newCache, } } - -/** - * 组装 MainSession 上下文,支持自动压缩 - * - * MainSession 始终注入 synthesis。超阈值时调用 compressFn 压缩 + 近期 L3。 - */ -export async function assembleMainSessionContext( - sessionId: string, - storage: SessionStorage, - userContent: string, - compress: CompressContext, -): Promise<{ messages: Message[]; userTimestamp: string; compressed: boolean; compressionCache?: CompressionCache | null }> { - const prefixMessages: Message[] = [] - - // 1. system prompt - const sysPrompt = await storage.getSystemPrompt(sessionId) - if (sysPrompt) { - prefixMessages.push({ role: 'system', content: sysPrompt }) - } - - // 2. synthesis(始终注入) - const synthContent = await storage.getMemory(sessionId) - if (synthContent) { - prefixMessages.push({ role: 'system', content: synthContent }) - } - - const userTimestamp = new Date().toISOString() - const userMessage: Message = { role: 'user', content: userContent, timestamp: userTimestamp } - - // 净化历史:与 assembleSessionContext 对称,避免不完整 tool call 组流入 prompt - const history = removeIncompleteToolCallGroups(await storage.listRecords(sessionId)) - - // 估算全量 token 数 - const fullMessages = [...prefixMessages, ...history, userMessage] - const estimatedTokens = compress.lastPromptTokens !== null - ? compress.lastPromptTokens + estimateTokens([...history.slice(-2), userMessage]) - : estimateTokens(fullMessages) - - const threshold = compress.maxContextTokens * COMPRESS_THRESHOLD - - // 未超阈值 → 全量 - if (estimatedTokens < threshold) { - return { messages: fullMessages, userTimestamp, compressed: false } - } - - // 超阈值 → 调用 compressFn 压缩 - const fixedTokens = estimateTokens([...prefixMessages, userMessage]) - const cachedSummary = compress.compressionCache?.summary - const summaryEstimate = cachedSummary - ? Math.ceil(cachedSummary.length / 4) - : ESTIMATED_SUMMARY_TOKENS - const recentBudget = threshold - fixedTokens - summaryEstimate - const recentMessages = recentBudget > 0 - ? selectHistoryByBudget(history, recentBudget) - : [] - - const compressCount = history.length - recentMessages.length - - if (compressCount === 0) { - return { messages: fullMessages, userTimestamp, compressed: false } - } - - let summary: string - let newCache: CompressionCache - if (compress.compressionCache && compress.compressionCache.compressedCount === compressCount) { - summary = compress.compressionCache.summary - newCache = compress.compressionCache - } else { - summary = await compress.compressFn(history.slice(0, compressCount)) - newCache = { summary, compressedCount: compressCount } - } - - const summaryMessage: Message = { role: 'system', content: summary } - const actualFixedTokens = estimateTokens([...prefixMessages, summaryMessage, userMessage]) - const actualBudget = threshold - actualFixedTokens - const finalRecent = actualBudget > 0 - ? selectHistoryByBudget(history, actualBudget) - : [] - - return { - messages: [...prefixMessages, summaryMessage, ...finalRecent, userMessage], - userTimestamp, - compressed: true, - compressionCache: newCache, - } -} diff --git a/packages/session/src/create-main-session.ts b/packages/session/src/create-main-session.ts deleted file mode 100644 index 021042c..0000000 --- a/packages/session/src/create-main-session.ts +++ /dev/null @@ -1,461 +0,0 @@ -import { randomUUID } from 'node:crypto' -import type { MainSession } from './types/main-session-api.js' -import type { Session, MessageQueryOptions, SessionSendOptions } from './types/session-api.js' -import { SessionArchivedError } from './types/session-api.js' -import type { SessionMeta, SessionMetaUpdate, ForkOptions } from './types/session.js' -import type { Message } from './types/llm.js' -import type { - IntegrateResult, CreateMainSessionOptions, LoadMainSessionOptions, - SendResult, StreamResult, ChildL2Summary, -} from './types/functions.js' -import { createSession } from './create-session.js' -import { assembleMainSessionContext, createBuiltinCompressFn, type CompressionCache } from './context-utils.js' - -interface ToolResultEnvelope { - toolResults: Array<{ - toolCallId: string | null - toolName: string - args: Record - success: boolean - data: unknown - error: string | null - }> -} - -/** 判断输入是否是 TurnRunner 回灌的 toolResults 包。 */ -function parseToolResultEnvelope(content: string): ToolResultEnvelope | null { - try { - const parsed = JSON.parse(content) as Partial - if (!Array.isArray(parsed.toolResults)) return null - return { - toolResults: parsed.toolResults.map((item) => ({ - toolCallId: typeof item?.toolCallId === 'string' ? item.toolCallId : null, - toolName: typeof item?.toolName === 'string' ? item.toolName : 'unknown_tool', - args: typeof item?.args === 'object' && item.args ? item.args : {}, - success: Boolean(item?.success), - data: item?.data ?? null, - error: typeof item?.error === 'string' ? item.error : null, - })), - } - } catch { - return null - } -} - -/** 把 tool 执行结果序列化为 tool message content,对齐 OpenAI/Anthropic 标准(只含结果数据)。 */ -function serializeToolResultContent(result: ToolResultEnvelope['toolResults'][number]): string { - if (!result.success) { - return result.error ?? 'Unknown error' - } - if (typeof result.data === 'string') return result.data - if (result.data == null) return '' - return JSON.stringify(result.data) -} - -/** 为 MainSession 的 toolResults continuation 组装固定上下文与历史。 */ -async function assembleMainSessionReplayContext( - sessionId: string, - storage: CreateMainSessionOptions['storage'] | LoadMainSessionOptions['storage'], -): Promise { - const messages: Message[] = [] - - const sysPrompt = await storage.getSystemPrompt(sessionId) - if (sysPrompt) { - messages.push({ role: 'system', content: sysPrompt }) - } - - const synthContent = await storage.getMemory(sessionId) - if (synthContent) { - messages.push({ role: 'system', content: synthContent }) - } - - const history = await storage.listRecords(sessionId) - messages.push(...history) - return messages -} - -function createStreamResult( - processor: (push: (chunk: string) => void) => Promise -): StreamResult { - const queue: string[] = [] - let done = false - let notify: (() => void) | null = null - - const wake = () => { - if (!notify) return - const current = notify - notify = null - current() - } - - const push = (chunk: string) => { - if (!chunk) return - queue.push(chunk) - wake() - } - - const result = (async () => { - try { - return await processor(push) - } finally { - done = true - wake() - } - })() - - return { - result, - async *[Symbol.asyncIterator]() { - while (!done || queue.length > 0) { - if (queue.length > 0) { - yield queue.shift()! - continue - } - await new Promise((resolve) => { - notify = resolve - }) - } - }, - } -} - -/** 创建 MainSession 实例的内部工厂 */ -function buildMainSession( - meta: SessionMeta, - options: CreateMainSessionOptions | LoadMainSessionOptions -): MainSession { - let currentMeta = { ...meta } - const { storage } = options - let tools = options.tools - let lastPromptTokens: number | null = null - let compressionCache: CompressionCache | null = null - function resolveCompressFn() { - return options.compressFn ?? createBuiltinCompressFn(options.llm!) - } - - const mainSession: MainSession = { - get meta(): Readonly { - return currentMeta - }, - - async send(content: string, sendOptions?: SessionSendOptions): Promise { - if (currentMeta.status === 'archived') { - throw new SessionArchivedError(currentMeta.id) - } - if (!options.llm) { - throw new Error('LLMAdapter is required for send()') - } - sendOptions?.signal?.throwIfAborted() - - // 组装上下文(自动压缩) - const assembled = await assembleMainSessionContext( - currentMeta.id, storage, content, - { maxContextTokens: options.llm.maxContextTokens, lastPromptTokens, compressFn: resolveCompressFn(), compressionCache }, - ) - if (assembled.compressionCache !== undefined) { - compressionCache = assembled.compressionCache - } - - let promptMessages = assembled.messages - let recordsToPersist: Message[] = [{ role: 'user', content, timestamp: assembled.userTimestamp }] - const toolEnvelope = parseToolResultEnvelope(content) - if (toolEnvelope) { - const replayContext = await assembleMainSessionReplayContext(currentMeta.id, storage) - promptMessages = [ - ...replayContext, - ...toolEnvelope.toolResults.map((result) => ({ - role: 'tool' as const, - toolCallId: result.toolCallId ?? undefined, - content: serializeToolResultContent(result), - timestamp: assembled.userTimestamp, - })), - ] - recordsToPersist = promptMessages.slice(replayContext.length) - } - - // 调 LLM — abort 时直接向上传播;下方 L3 写入分支整体跳过 - const result = await options.llm.complete(promptMessages, { tools, signal: sendOptions?.signal }) - - // 更新 promptTokens 基线 - if (result.usage?.promptTokens) { - lastPromptTokens = result.usage.promptTokens - } - const assistantRecord: Message = { - role: 'assistant', - content: result.content ?? '', - ...(result.toolCalls && result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {}), - timestamp: new Date().toISOString(), - } - for (const record of recordsToPersist) { - await storage.appendRecord(currentMeta.id, record) - } - await storage.appendRecord(currentMeta.id, assistantRecord) - - return { - content: result.content, - toolCalls: result.toolCalls, - usage: result.usage, - } - }, - - stream(content: string, sendOptions?: SessionSendOptions): StreamResult { - if (currentMeta.status === 'archived') { - throw new SessionArchivedError(currentMeta.id) - } - if (!options.llm) { - throw new Error('LLMAdapter is required for stream()') - } - - return createStreamResult(async (push) => { - sendOptions?.signal?.throwIfAborted() - - // 组装上下文(自动压缩) - const assembled = await assembleMainSessionContext( - currentMeta.id, storage, content, - { maxContextTokens: options.llm!.maxContextTokens, lastPromptTokens, compressFn: resolveCompressFn(), compressionCache }, - ) - if (assembled.compressionCache !== undefined) { - compressionCache = assembled.compressionCache - } - - let promptMessages = assembled.messages - let recordsToPersist: Message[] = [{ role: 'user', content, timestamp: assembled.userTimestamp }] - const toolEnvelope = parseToolResultEnvelope(content) - if (toolEnvelope) { - const replayContext = await assembleMainSessionReplayContext(currentMeta.id, storage) - promptMessages = [ - ...replayContext, - ...toolEnvelope.toolResults.map((result) => ({ - role: 'tool' as const, - toolCallId: result.toolCallId ?? undefined, - content: serializeToolResultContent(result), - timestamp: assembled.userTimestamp, - })), - ] - recordsToPersist = promptMessages.slice(replayContext.length) - } - - if (!options.llm) { - throw new Error('LLM adapter not set. Call setLLM() first or pass llm to createMainSession().') - } - - let result: SendResult - if (options.llm.stream) { - let accumulated = '' - const toolCallsByIndex = new Map() - for await (const chunk of options.llm.stream(promptMessages, { tools, signal: sendOptions?.signal })) { - accumulated += chunk.delta - push(chunk.delta) - for (const delta of chunk.toolCallDeltas ?? []) { - const current = toolCallsByIndex.get(delta.index) ?? { input: '' } - if (delta.id) current.id = delta.id - if (delta.name) current.name = delta.name - if (delta.input) current.input += delta.input - toolCallsByIndex.set(delta.index, current) - } - } - const toolCalls = Array.from(toolCallsByIndex.values()).map((call, index) => ({ - id: call.id ?? `tool_${index}`, - name: call.name ?? 'unknown_tool', - input: call.input ? JSON.parse(call.input) as Record : {}, - })) - result = { content: accumulated, toolCalls } - } else { - result = await options.llm.complete(promptMessages, { tools, signal: sendOptions?.signal }) - if (result.content) { - push(result.content) - } - } - - const assistantRecord: Message = { - role: 'assistant', - content: result.content ?? '', - ...(result.toolCalls && result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {}), - timestamp: new Date().toISOString(), - } - for (const record of recordsToPersist) { - await storage.appendRecord(currentMeta.id, record) - } - await storage.appendRecord(currentMeta.id, assistantRecord) - - // 更新 promptTokens 基线 - if (result.usage?.promptTokens) { - lastPromptTokens = result.usage.promptTokens - } - - return { - content: result.content, - toolCalls: result.toolCalls, - usage: result.usage, - } - }) - }, - - async messages(queryOptions?: MessageQueryOptions): Promise { - return storage.listRecords(currentMeta.id, queryOptions) - }, - - async systemPrompt(): Promise { - return storage.getSystemPrompt(currentMeta.id) - }, - - async setSystemPrompt(content: string): Promise { - if (currentMeta.status === 'archived') { - throw new SessionArchivedError(currentMeta.id) - } - await storage.putSystemPrompt(currentMeta.id, content) - }, - - async synthesis(): Promise { - return storage.getMemory(currentMeta.id) - }, - - async integrate(): Promise { - if (currentMeta.status === 'archived') { - throw new SessionArchivedError(currentMeta.id) - } - if (!options.integrateFn) { - throw new Error('No integrateFn configured for this main session') - } - - // 1. 扁平收集所有子 Session 的 L2 - // FIXME: Task 3 deletes this file entirely. Stub so this file typechecks meanwhile. - const childSummaries: ChildL2Summary[] = [] - const validChildSessionIds = new Set(childSummaries.map((child) => child.sessionId)) - - // 2. 读取当前 synthesis - const currentSynthesis = await storage.getMemory(currentMeta.id) - - // 3. 调用 IntegrateFn - const result = await options.integrateFn(childSummaries, currentSynthesis) - const filteredInsights = result.insights.filter(({ sessionId }) => validChildSessionIds.has(sessionId)) - - // 4. 在事务中一起保存 synthesis 和有效 insights,避免部分写入。 - await storage.transaction(async (tx) => { - await tx.putMemory(currentMeta.id, result.synthesis) - for (const { sessionId, content } of filteredInsights) { - await tx.putInsight(sessionId, content) - } - }) - - return { ...result, insights: filteredInsights } - }, - - async trimRecords(keepRecent: number): Promise { - if (keepRecent < 0) { - throw new Error('keepRecent must be a non-negative integer') - } - if (currentMeta.status === 'archived') { - throw new SessionArchivedError(currentMeta.id) - } - await storage.trimRecords(currentMeta.id, keepRecent) - }, - - async updateMeta(updates: SessionMetaUpdate): Promise { - if (currentMeta.status === 'archived') { - throw new SessionArchivedError(currentMeta.id) - } - const updatedMeta: SessionMeta = { - ...currentMeta, - ...(updates.label !== undefined && { label: updates.label }), - updatedAt: new Date().toISOString(), - } - await storage.putSession(updatedMeta) - currentMeta = updatedMeta - }, - - async fork(forkOptions: ForkOptions): Promise { - const childId = forkOptions.id ?? randomUUID() - const now = new Date().toISOString() - - // 创建子 Session(标准 Session,非 MainSession) - const child = await createSession({ - id: childId, - storage, - llm: forkOptions.llm ?? options.llm!, - label: forkOptions.label, - systemPrompt: forkOptions.systemPrompt ?? await storage.getSystemPrompt(currentMeta.id) ?? undefined, - tools: forkOptions.tools ?? options.tools, - consolidateFn: forkOptions.consolidateFn, - compressFn: forkOptions.compressFn, - }) - - // 上下文策略:决定子 Session 继承多少父 L3 - const ctx = forkOptions.context ?? 'none' - if (ctx !== 'none') { - const parentRecords = await storage.listRecords(currentMeta.id) - const records = ctx === 'inherit' ? parentRecords : await ctx(parentRecords) - for (const record of records) { - await storage.appendRecord(childId, record) - } - } - - // 初始 prompt:写入子 Session 的第一条 assistant 开场消息 - if (forkOptions.prompt) { - await storage.appendRecord(childId, { - role: 'assistant', - content: forkOptions.prompt, - timestamp: now, - }) - } - - return child - }, - - async archive(): Promise { - const updatedMeta: SessionMeta = { - ...currentMeta, - status: 'archived', - updatedAt: new Date().toISOString(), - } - await storage.putSession(updatedMeta) - currentMeta = updatedMeta - }, - - setLLM(adapter) { - options.llm = adapter - }, - - get tools() { - return tools - }, - - setTools(newTools) { - tools = newTools - }, - } - - return mainSession -} - -/** createMainSession — 创建 Main Session */ -export async function createMainSession(options: CreateMainSessionOptions): Promise { - const id = randomUUID() - const now = new Date().toISOString() - - const meta: SessionMeta = { - id, - label: options.label ?? 'Main Session', - status: 'active', - createdAt: now, - updatedAt: now, - } - - await options.storage.putSession(meta) - - if (options.systemPrompt) { - await options.storage.putSystemPrompt(id, options.systemPrompt) - } - - return buildMainSession(meta, options) -} - -/** loadMainSession — 从存储中加载已有的 Main Session */ -export async function loadMainSession( - id: string, - options: LoadMainSessionOptions -): Promise { - const meta = await options.storage.getSession(id) - if (!meta) return null - return buildMainSession(meta, options) -} diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index 8085cf9..b2b62fe 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -14,22 +14,12 @@ export { NotImplementedError, } from './types/session-api.js' -// 类型导出 — MainSession -export type { - MainSession, -} from './types/main-session-api.js' - // 类型导出 — 函数签名与选项 export type { CompressFn, ConsolidateFn, - IntegrateFn, - IntegrateResult, - ChildL2Summary, CreateSessionOptions, LoadSessionOptions, - CreateMainSessionOptions, - LoadMainSessionOptions, SendResult, StreamResult, } from './types/functions.js' @@ -41,9 +31,6 @@ export { tool } from './tool.js' // Session 工厂函数 export { createSession, loadSession } from './create-session.js' -// MainSession 工厂函数 -export { createMainSession, loadMainSession } from './create-main-session.js' - // LLM Adapter — 高层工厂(推荐) export type { ClaudeModel, ClaudeOptions } from './adapters/claude.js' export { createClaude } from './adapters/claude.js' diff --git a/packages/session/src/types/functions.ts b/packages/session/src/types/functions.ts index 1372c46..3d538c2 100644 --- a/packages/session/src/types/functions.ts +++ b/packages/session/src/types/functions.ts @@ -7,27 +7,6 @@ export type ConsolidateFn = (currentMemory: string | null, messages: Message[]) /** 上下文压缩函数签名:接收需压缩的消息列表,返回摘要文本 */ export type CompressFn = (messages: Message[]) => Promise -/** 子 Session 的 L2 摘要,供 IntegrateFn 消费 */ -export interface ChildL2Summary { - sessionId: string - label: string - l2: string -} - -/** IntegrateFn 的返回结果 */ -export interface IntegrateResult { - /** Main Session 的综合认知 */ - synthesis: string - /** 推送给各子 Session 的定向 insights */ - insights: Array<{ sessionId: string; content: string }> -} - -/** integrate 函数签名:所有子 L2 + 当前 synthesis → 新 synthesis + per-child insights */ -export type IntegrateFn = ( - children: ChildL2Summary[], - currentSynthesis: string | null -) => Promise - /** createSession() 的选项 */ export interface CreateSessionOptions { /** 指定 session ID(用于与拓扑节点对齐)。不提供则自动生成。 */ @@ -66,42 +45,6 @@ export interface LoadSessionOptions { } -/** createMainSession() 的选项 */ -export interface CreateMainSessionOptions { - /** 指定存储适配器 */ - storage: SessionStorage - /** 指定 LLM 适配器 */ - llm?: LLMAdapter - /** Main Session 标签 */ - label?: string - /** 系统提示词 */ - systemPrompt?: string - /** 可用工具定义 */ - tools?: LLMCompleteOptions['tools'] - /** 上下文压缩函数(超阈值时调用) */ - compressFn?: CompressFn - /** 所有子 L2 → synthesis + insights,创建时绑定,供 integrate() 调用 */ - integrateFn?: IntegrateFn - -} - -/** loadMainSession() 的选项 */ -export interface LoadMainSessionOptions { - /** 指定存储适配器 */ - storage: SessionStorage - /** LLM 适配器 */ - llm?: LLMAdapter - /** 系统提示词 */ - systemPrompt?: string - /** 可用工具定义 */ - tools?: LLMCompleteOptions['tools'] - /** 上下文压缩函数(超阈值时调用) */ - compressFn?: CompressFn - /** 所有子 L2 → synthesis + insights,加载时绑定,供 integrate() 调用 */ - integrateFn?: IntegrateFn - -} - /** send() 的返回结果 */ export interface SendResult { /** LLM 文本响应 */ diff --git a/packages/session/src/types/main-session-api.ts b/packages/session/src/types/main-session-api.ts deleted file mode 100644 index 2c7ef38..0000000 --- a/packages/session/src/types/main-session-api.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { SessionMeta, SessionMetaUpdate, ForkOptions } from './session.js' -import type { Message, LLMAdapter, LLMCompleteOptions } from './llm.js' -import type { SendResult, StreamResult, IntegrateResult } from './functions.js' -import type { MessageQueryOptions, Session, SessionSendOptions } from './session-api.js' - -/** - * MainSession — 全局意识层对话单元 - * - * 与 Session 的核心区别: - * - 上下文使用 synthesis(integration 产出),而非 insights - * - 没有 L2,没有 consolidate — 取而代之的是 integrate() - * - 不接收 insights,而是通过 integrate 主动推送给子 Session - */ -export interface MainSession { - /** 同步读取元数据(role 始终为 'main') */ - readonly meta: Readonly - - /** 发送消息:组装上下文(system prompt + synthesis + L3 + msg)→ 调 LLM → 存 L3 */ - send(content: string, options?: SessionSendOptions): Promise - - /** 流式发送:同 send 但逐 chunk 输出 */ - stream(content: string, options?: SessionSendOptions): StreamResult - - /** 读取 L3 对话记录 */ - messages(options?: MessageQueryOptions): Promise - - /** 读取 system prompt */ - systemPrompt(): Promise - - /** 更新 system prompt(持久化到 storage) */ - setSystemPrompt(content: string): Promise - - /** 读取 synthesis — integration cycle 的产出 */ - synthesis(): Promise - - /** 执行 integration cycle:收集子 L2 → IntegrateFn → 保存 synthesis + 推送 insights */ - integrate(): Promise - - /** 裁剪旧 L3,保留最近 keepRecent 条。通常在 integrate() 后调用 */ - trimRecords(keepRecent: number): Promise - - /** 更新元数据 */ - updateMeta(updates: SessionMetaUpdate): Promise - - /** 归档 */ - archive(): Promise - - /** 派生子 Session,返回标准 Session。上下文继承策略同 Session.fork() */ - fork(options: ForkOptions): Promise - - /** 动态替换 LLM adapter(热更新,立即对后续 send/stream 生效) */ - setLLM(adapter: LLMAdapter): void - - /** Current tool list (auto-injected to LLM on send/stream) */ - readonly tools?: LLMCompleteOptions['tools'] - - /** Replace tool list. Effective immediately; next send/stream uses new value. */ - setTools(tools: LLMCompleteOptions['tools'] | undefined): void -} From e0aaa39653ac20654d71bf34742a8588d01a5021 Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 17:56:06 +0800 Subject: [PATCH 06/40] test(session): drop duplicate trimRecords case and add prompt ordering check --- .../src/__tests__/context-compress.test.ts | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/session/src/__tests__/context-compress.test.ts b/packages/session/src/__tests__/context-compress.test.ts index 500908c..0186db7 100644 --- a/packages/session/src/__tests__/context-compress.test.ts +++ b/packages/session/src/__tests__/context-compress.test.ts @@ -250,6 +250,18 @@ describe('自动压缩 — Session(compress + insight 共存)', () => { // 压缩摘要也在上下文中 expect(call.some(m => m.content === 'main compressed')).toBe(true) expect(call.length).toBeLessThan(22) + + // 验证 assembleSessionContext 的顺序:identity → insight → memory/summary → recent L3 → user message + const indices = { + insight: call.findIndex((m: Message) => m.content.includes('global insight')), + compressed: call.findIndex((m: Message) => m.content.includes('main compressed')), + user: call.findIndex((m: Message) => m.role === 'user' && m.content === 'new'), + } + expect(indices.insight).toBeGreaterThanOrEqual(0) + expect(indices.compressed).toBeGreaterThanOrEqual(0) + expect(indices.user).toBeGreaterThanOrEqual(0) + expect(indices.insight).toBeLessThan(indices.compressed) + expect(indices.compressed).toBeLessThan(indices.user) }) it('未传 compressFn 时 Session 自动使用内置 LLM 压缩(insight 保留)', async () => { @@ -280,23 +292,6 @@ describe('自动压缩 — Session(compress + insight 共存)', () => { expect(sendCall.some(m => m.content === 'main builtin compressed')).toBe(true) expect(sendCall.length).toBeLessThan(22) }) - - it('Session trimRecords 正常工作', async () => { - const storage = new InMemoryStorageAdapter() - const session = await createSession({ storage, label: 'Test Root' }) - const id = session.meta.id - - await storage.appendRecord(id, { role: 'user', content: 'a' }) - await storage.appendRecord(id, { role: 'assistant', content: 'b' }) - await storage.appendRecord(id, { role: 'user', content: 'c' }) - await storage.appendRecord(id, { role: 'assistant', content: 'd' }) - - await session.trimRecords(2) - - const msgs = await session.messages() - expect(msgs).toHaveLength(2) - expect(msgs[0]!.content).toBe('c') - }) }) describe('consolidate 与 compress 独立', () => { From 77f044eda30601d884c62e6f2b7af60dc53b1825 Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 18:06:24 +0800 Subject: [PATCH 07/40] refactor(core): drop MAIN_SESSION_ID, unify SessionTree interface skeleton --- .../src/agent/__tests__/stello-agent.test.ts | 30 +++--- packages/core/src/agent/stello-agent.ts | 4 +- .../engine/__tests__/fork-compress.test.ts | 4 +- .../engine/__tests__/stello-engine.test.ts | 52 +++++----- packages/core/src/engine/stello-engine.ts | 11 +-- packages/core/src/index.ts | 3 - .../session/__tests__/session-tree.test.ts | 19 ++-- packages/core/src/session/session-tree.ts | 55 ++++++++--- packages/core/src/types/session.ts | 96 +++++-------------- 9 files changed, 127 insertions(+), 147 deletions(-) diff --git a/packages/core/src/agent/__tests__/stello-agent.test.ts b/packages/core/src/agent/__tests__/stello-agent.test.ts index 0474a99..648865b 100644 --- a/packages/core/src/agent/__tests__/stello-agent.test.ts +++ b/packages/core/src/agent/__tests__/stello-agent.test.ts @@ -197,7 +197,7 @@ describe('StelloAgent', () => { fork: sessionFork, }; - const createChild = vi.fn().mockResolvedValue({ + const createSession = vi.fn().mockResolvedValue({ id: 'child-2', parentId: 'child-1', children: [], refs: [], depth: 2, index: 0, label: 'UI 2', }); @@ -209,7 +209,7 @@ describe('StelloAgent', () => { return null; }), archive: vi.fn(), - createChild, + createSession, getConfig: vi.fn().mockResolvedValue(null), putConfig: vi.fn().mockResolvedValue(undefined), } as unknown as SessionTree, @@ -249,7 +249,7 @@ describe('StelloAgent', () => { const result = await agent.forkSession('child-1', { label: 'UI 2' }); // engine 用 `options.topologyParentId ?? this.session.id` 默认挂到 source(child-1)下 - expect(createChild).toHaveBeenCalledWith(expect.objectContaining({ + expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ label: 'UI 2', parentId: 'child-1', })); @@ -462,23 +462,23 @@ describe('StelloAgent', () => { }); describe('createMainSession', () => { - /** 构建带 createRoot/putConfig/getConfig 能力的 sessions mock */ + /** 构建带 createSession/putConfig/getConfig 能力的 sessions mock */ function sessionsMock() { const store = new Map(); - const createRoot = vi.fn().mockImplementation(async (label?: string) => ({ + const createSession = vi.fn().mockImplementation(async (opts?: { label?: string }) => ({ id: 'root', parentId: null, children: [], refs: [], depth: 0, index: 0, - label: label ?? 'Root', + label: opts?.label ?? 'Root', })); const putConfig = vi.fn().mockImplementation(async (id: string, config: unknown) => { store.set(id, config); }); const getConfig = vi.fn().mockImplementation(async (id: string) => store.get(id) ?? null); - return { createRoot, putConfig, getConfig, store }; + return { createSession, putConfig, getConfig, store }; } it('createMainSession 返回根拓扑节点(指定 label)', async () => { @@ -486,7 +486,7 @@ describe('StelloAgent', () => { const agent = createStelloAgent( baseConfig({ sessions: { - createRoot: sessions.createRoot, + createSession: sessions.createSession, putConfig: sessions.putConfig, getConfig: sessions.getConfig, }, @@ -495,19 +495,19 @@ describe('StelloAgent', () => { const node = await agent.createMainSession({ label: 'Main' }); - expect(sessions.createRoot).toHaveBeenCalledWith('Main'); + expect(sessions.createSession).toHaveBeenCalledWith({ label: 'Main' }); expect(node.id).toBe('root'); expect(node.parentId).toBeNull(); expect(node.depth).toBe(0); expect(node.label).toBe('Main'); }); - it('createMainSession 无 label 时走 createRoot 默认值', async () => { + it('createMainSession 无 label 时走 createSession 默认值', async () => { const sessions = sessionsMock(); const agent = createStelloAgent( baseConfig({ sessions: { - createRoot: sessions.createRoot, + createSession: sessions.createSession, putConfig: sessions.putConfig, getConfig: sessions.getConfig, }, @@ -516,7 +516,7 @@ describe('StelloAgent', () => { const node = await agent.createMainSession(); - expect(sessions.createRoot).toHaveBeenCalledWith(undefined); + expect(sessions.createSession).toHaveBeenCalledWith({}); expect(node.label).toBe('Root'); }); @@ -525,7 +525,7 @@ describe('StelloAgent', () => { const agent = createStelloAgent({ ...baseConfig({ sessions: { - createRoot: sessions.createRoot, + createSession: sessions.createSession, putConfig: sessions.putConfig, getConfig: sessions.getConfig, }, @@ -555,7 +555,7 @@ describe('StelloAgent', () => { const agent = createStelloAgent({ ...baseConfig({ sessions: { - createRoot: sessions.createRoot, + createSession: sessions.createSession, putConfig: sessions.putConfig, getConfig: sessions.getConfig, }, @@ -580,7 +580,7 @@ describe('StelloAgent', () => { const agent = createStelloAgent( baseConfig({ sessions: { - createRoot: sessions.createRoot, + createSession: sessions.createSession, putConfig: sessions.putConfig, getConfig: sessions.getConfig, }, diff --git a/packages/core/src/agent/stello-agent.ts b/packages/core/src/agent/stello-agent.ts index eb15471..f012788 100644 --- a/packages/core/src/agent/stello-agent.ts +++ b/packages/core/src/agent/stello-agent.ts @@ -215,7 +215,9 @@ export class StelloAgent { /** 创建 main session(根节点),使用 mainSessionConfig 固化其配置 */ async createMainSession(options?: { label?: string }): Promise { - const node = await this.sessions.createRoot(options?.label); + const createArgs: { label?: string } = {}; + if (options?.label !== undefined) createArgs.label = options.label; + const node = await this.sessions.createSession(createArgs); const serialized = serializeMainSessionConfig(this.config.mainSessionConfig); await this.sessions.putConfig(node.id, serialized); return node; diff --git a/packages/core/src/engine/__tests__/fork-compress.test.ts b/packages/core/src/engine/__tests__/fork-compress.test.ts index 6ea51d0..89cf6aa 100644 --- a/packages/core/src/engine/__tests__/fork-compress.test.ts +++ b/packages/core/src/engine/__tests__/fork-compress.test.ts @@ -142,7 +142,7 @@ describe('forkSession compress integration', () => { getTree: vi.fn(), getConfig: vi.fn().mockResolvedValue(null), putConfig: vi.fn().mockResolvedValue(undefined), - createChild: vi.fn().mockResolvedValue({ + createSession: vi.fn().mockResolvedValue({ id: 'child-1', parentId: 's1', children: [], @@ -221,7 +221,7 @@ describe('forkSession compress integration', () => { await expect( engine.forkSession({ label: 'x', context: 'compress' }), ).rejects.toThrow(/compress/) - expect(fakeSessions.createChild).not.toHaveBeenCalled() + expect(fakeSessions.createSession).not.toHaveBeenCalled() }) it('context=compress + 父 L3 空 → 不追加,但 forwardedContext=none', async () => { diff --git a/packages/core/src/engine/__tests__/stello-engine.test.ts b/packages/core/src/engine/__tests__/stello-engine.test.ts index d577aa6..36d8e38 100644 --- a/packages/core/src/engine/__tests__/stello-engine.test.ts +++ b/packages/core/src/engine/__tests__/stello-engine.test.ts @@ -4,7 +4,6 @@ import type { MemoryEngine } from '../../types/memory'; import type { ConfirmProtocol, SkillRouter } from '../../types/lifecycle'; import { StelloEngineImpl } from '../stello-engine'; import { TurnRunner, type ToolCallParser } from '../turn-runner'; -import { MAIN_SESSION_ID } from '../../types/session'; import { ToolRegistryImpl, type ToolRegistryEntry } from '../../tool/tool-registry'; describe('StelloEngineImpl', () => { @@ -338,7 +337,7 @@ describe('StelloEngineImpl', () => { }); it('forkSession 会先过 splitGuard,再创建子 session', async () => { - const createChild = vi.fn().mockResolvedValue({ + const createSession = vi.fn().mockResolvedValue({ id: 'child-1', parentId: 's1', children: [], refs: [], depth: 1, index: 0, label: 'UI', }); @@ -362,7 +361,7 @@ describe('StelloEngineImpl', () => { setTools: vi.fn(), fork: sessionFork, }, - sessions: { ...sessions, createChild } as unknown as SessionTree, + sessions: { ...sessions, createSession } as unknown as SessionTree, memory, skills, confirm, @@ -381,7 +380,7 @@ describe('StelloEngineImpl', () => { const child = await engine.forkSession({ label: 'UI' }); expect(splitGuard.checkCanSplit).toHaveBeenCalledWith('s1'); - expect(createChild).toHaveBeenCalledWith(expect.objectContaining({ + expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ parentId: 's1', label: 'UI', sourceSessionId: 's1', })); expect(sessionFork).toHaveBeenCalledWith(expect.objectContaining({ @@ -393,7 +392,7 @@ describe('StelloEngineImpl', () => { it('splitGuard 拒绝时不会创建子 session', async () => { - const createChild = vi.fn(); + const createSession = vi.fn(); const sessionFork = vi.fn(); const splitGuard = { checkCanSplit: vi.fn().mockResolvedValue({ canSplit: false, reason: 'turns not enough' }), @@ -411,7 +410,7 @@ describe('StelloEngineImpl', () => { setTools: vi.fn(), fork: sessionFork, }, - sessions: { ...sessions, createChild } as unknown as SessionTree, + sessions: { ...sessions, createSession } as unknown as SessionTree, memory, skills, confirm, agent: {} as never, lifecycle: { bootstrap: vi.fn(), afterTurn: vi.fn() }, tools: { getToolDefinitions: vi.fn().mockReturnValue([]), executeTool: vi.fn() }, @@ -419,12 +418,12 @@ describe('StelloEngineImpl', () => { }); await expect(engine.forkSession({ label: 'UI' })).rejects.toThrow('turns not enough'); - expect(createChild).not.toHaveBeenCalled(); + expect(createSession).not.toHaveBeenCalled(); }); describe('forkSession 新路径(session.fork)', () => { - it('有 session.fork 时走新路径:createChild + session.fork', async () => { - const createChild = vi.fn().mockResolvedValue({ + it('有 session.fork 时走新路径:createSession + session.fork', async () => { + const createSession = vi.fn().mockResolvedValue({ id: 'child-1', parentId: 's1', children: [], refs: [], depth: 1, index: 0, label: 'UI', }); @@ -441,7 +440,7 @@ describe('StelloEngineImpl', () => { setTools: vi.fn(), fork: sessionFork, }, - sessions: { ...sessions, createChild } as unknown as SessionTree, + sessions: { ...sessions, createSession } as unknown as SessionTree, memory, skills, confirm, agent: {} as never, lifecycle: { bootstrap: vi.fn(), afterTurn: vi.fn() }, tools: { getToolDefinitions: vi.fn().mockReturnValue([]), executeTool: vi.fn() }, @@ -451,7 +450,7 @@ describe('StelloEngineImpl', () => { label: 'UI', systemPrompt: 'you are UI expert', prompt: 'hello', }); - expect(createChild).toHaveBeenCalledWith(expect.objectContaining({ + expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ parentId: 's1', label: 'UI', })); expect(sessionFork).toHaveBeenCalledWith(expect.objectContaining({ @@ -477,12 +476,13 @@ describe('StelloEngineImpl', () => { }); }); - describe('forkSession from main session (issue #55)', () => { + // FIXME: Task 10 will replace this with proper root-fork tests + describe.skip('forkSession from main session (issue #55)', () => { it('从 main fork 时不读 getConfig,parent 层不参与合成链', async () => { const getConfig = vi.fn().mockResolvedValue({ systemPrompt: 'main sys', skills: ['a'] }); const putConfig = vi.fn().mockResolvedValue(undefined); - const createChild = vi.fn().mockResolvedValue({ - id: 'child-1', parentId: MAIN_SESSION_ID, children: [], refs: [], + const createSession = vi.fn().mockResolvedValue({ + id: 'child-1', parentId: 'root', children: [], refs: [], depth: 1, index: 0, label: 'UI', }); const sessionFork = vi.fn().mockResolvedValue({ @@ -492,14 +492,14 @@ describe('StelloEngineImpl', () => { const engine = new StelloEngineImpl({ session: { - id: MAIN_SESSION_ID, - meta: { id: MAIN_SESSION_ID, turnCount: 0, status: 'active' as const }, + id: 'root', + meta: { id: 'root', turnCount: 0, status: 'active' as const }, turnCount: 0, send: vi.fn(), consolidate: vi.fn(), messages: vi.fn().mockResolvedValue([]), setTools: vi.fn(), fork: sessionFork, }, - sessions: { ...sessions, createChild, getConfig, putConfig } as unknown as SessionTree, + sessions: { ...sessions, createSession, getConfig, putConfig } as unknown as SessionTree, memory, skills, confirm, agent: {} as never, lifecycle: { bootstrap: vi.fn(), afterTurn: vi.fn() }, tools: { getToolDefinitions: vi.fn().mockReturnValue([]), executeTool: vi.fn() }, @@ -514,8 +514,8 @@ describe('StelloEngineImpl', () => { // 模拟宿主把 SerializableMainSessionConfig 存在同一 putConfig 槽位(issue #55 场景) const getConfig = vi.fn().mockResolvedValue({ systemPrompt: 'main sys', skills: ['a'] }); const putConfig = vi.fn().mockResolvedValue(undefined); - const createChild = vi.fn().mockResolvedValue({ - id: 'child-1', parentId: MAIN_SESSION_ID, children: [], refs: [], + const createSession = vi.fn().mockResolvedValue({ + id: 'child-1', parentId: 'root', children: [], refs: [], depth: 1, index: 0, label: 'UI', }); const sessionFork = vi.fn().mockResolvedValue({ @@ -525,14 +525,14 @@ describe('StelloEngineImpl', () => { const engine = new StelloEngineImpl({ session: { - id: MAIN_SESSION_ID, - meta: { id: MAIN_SESSION_ID, turnCount: 0, status: 'active' as const }, + id: 'root', + meta: { id: 'root', turnCount: 0, status: 'active' as const }, turnCount: 0, send: vi.fn(), consolidate: vi.fn(), messages: vi.fn().mockResolvedValue([]), setTools: vi.fn(), fork: sessionFork, }, - sessions: { ...sessions, createChild, getConfig, putConfig } as unknown as SessionTree, + sessions: { ...sessions, createSession, getConfig, putConfig } as unknown as SessionTree, memory, skills, confirm, agent: {} as never, lifecycle: { bootstrap: vi.fn(), afterTurn: vi.fn() }, tools: { getToolDefinitions: vi.fn().mockReturnValue([]), executeTool: vi.fn() }, @@ -553,7 +553,7 @@ describe('StelloEngineImpl', () => { it('从非 main session fork 时仍然正常读取 parent config', async () => { const getConfig = vi.fn().mockResolvedValue({ systemPrompt: 'parent sys' }); - const createChild = vi.fn().mockResolvedValue({ + const createSession = vi.fn().mockResolvedValue({ id: 'child-1', parentId: 's1', children: [], refs: [], depth: 2, index: 0, label: 'UI', }); @@ -570,7 +570,7 @@ describe('StelloEngineImpl', () => { setTools: vi.fn(), fork: sessionFork, }, - sessions: { ...sessions, createChild, getConfig } as unknown as SessionTree, + sessions: { ...sessions, createSession, getConfig } as unknown as SessionTree, memory, skills, confirm, agent: {} as never, lifecycle: { bootstrap: vi.fn(), afterTurn: vi.fn() }, tools: { getToolDefinitions: vi.fn().mockReturnValue([]), executeTool: vi.fn() }, @@ -654,7 +654,7 @@ describe('StelloEngineImpl', () => { setTools: childSetTools, }; const sessionFork = vi.fn().mockResolvedValue(childRuntime); - const createChild = vi.fn().mockResolvedValue({ + const createSession = vi.fn().mockResolvedValue({ id: 'child-1', parentId: 's1', children: [], refs: [], depth: 1, index: 0, label: 'UI', }); @@ -672,7 +672,7 @@ describe('StelloEngineImpl', () => { setTools: parentSetTools, fork: sessionFork, }, - sessions: { ...sessions, createChild } as unknown as SessionTree, + sessions: { ...sessions, createSession } as unknown as SessionTree, memory, skills, confirm, diff --git a/packages/core/src/engine/stello-engine.ts b/packages/core/src/engine/stello-engine.ts index 17286ec..de88913 100644 --- a/packages/core/src/engine/stello-engine.ts +++ b/packages/core/src/engine/stello-engine.ts @@ -1,5 +1,4 @@ import type { SessionTree } from '../types/session'; -import { MAIN_SESSION_ID } from '../types/session'; import type { MemoryEngine, TurnRecord } from '../types/memory'; import type { LLMCompleteOptions } from '@stello-ai/session'; import type { @@ -358,12 +357,8 @@ export class StelloEngineImpl implements StelloEngine { throw new Error('Fork 不可用:当前 session runtime 未实现 fork()'); } - // 从 main session fork 时不继承 main 的配置(invariant #6): - // main 的 SerializableMainSessionConfig 与 regular 的 SerializableSessionConfig 共用 - // 同一存储槽,需在读之前判断 source 角色、跳过读取。 - const parentFrozen = sourceSessionId === MAIN_SESSION_ID - ? null - : await this.sessions.getConfig(sourceSessionId); + // 读取 source session 固化配置;不存在则使用空对象作为继承基线。 + const parentFrozen = await this.sessions.getConfig(sourceSessionId); const parent: SessionConfig = parentFrozen ?? {}; // 合成最终配置:defaults → parent → profile → forkOptions @@ -391,7 +386,7 @@ export class StelloEngineImpl implements StelloEngine { }); // Topology-first:创建拓扑节点,获取 ID(sourceSessionId 作为一等字段持久化) - const child = await this.sessions.createChild({ + const child = await this.sessions.createSession({ parentId: topologyParentId, label: options.label, sourceSessionId, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e23d3a1..289ab07 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -49,9 +49,6 @@ export type { export type { ToolExecutionContext } from './types/tool'; -// 导出常量 -export { MAIN_SESSION_ID } from './types/session'; - // 导出实现 export { NodeFileSystemAdapter } from './fs'; export { SessionTreeImpl } from './session'; diff --git a/packages/core/src/session/__tests__/session-tree.test.ts b/packages/core/src/session/__tests__/session-tree.test.ts index 65df7a9..7f41579 100644 --- a/packages/core/src/session/__tests__/session-tree.test.ts +++ b/packages/core/src/session/__tests__/session-tree.test.ts @@ -4,7 +4,6 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { NodeFileSystemAdapter } from '../../fs/file-system-adapter'; import { SessionTreeImpl } from '../session-tree'; -import { MAIN_SESSION_ID } from '../../types/session'; describe('SessionTreeImpl', () => { let tmpDir: string; @@ -44,11 +43,6 @@ describe('SessionTreeImpl', () => { expect(await fs.exists(`sessions/${root.id}/index.md`)).toBe(true); }); - it('createRoot 返回固定的 MAIN_SESSION_ID 作为 id', async () => { - const root = await tree.createRoot('Any'); - expect(root.id).toBe(MAIN_SESSION_ID); - }); - it('createRoot 幂等:第二次调用返回现有节点,不覆写 label 与已写入的内容', async () => { const fs = new NodeFileSystemAdapter(tmpDir); const first = await tree.createRoot('Original'); @@ -269,8 +263,8 @@ describe('SessionTreeImpl', () => { const node = await tree.getNode(childId); expect(node?.sourceSessionId).toBe(rootId); - const treeData = await tree.getTree(); - expect(treeData.children[0]?.sourceSessionId).toBe(rootId); + const forest = await tree.getTree(); + expect(forest[0]?.children[0]?.sourceSessionId).toBe(rootId); }); // ─── getTree ─── @@ -281,7 +275,9 @@ describe('SessionTreeImpl', () => { const b = await tree.createChild({ parentId: root.id, label: 'B' }); await tree.createChild({ parentId: a.id, label: 'A1', sourceSessionId: a.id }); - const treeData = await tree.getTree(); + const forest = await tree.getTree(); + expect(forest).toHaveLength(1); + const treeData = forest[0]!; expect(treeData.id).toBe(root.id); expect(treeData.label).toBe('根'); expect(treeData.status).toBe('active'); @@ -298,8 +294,9 @@ describe('SessionTreeImpl', () => { expect(childB?.children).toHaveLength(0); }); - it('getTree 根不存在时抛错', async () => { - await expect(tree.getTree()).rejects.toThrow('根 Session 不存在'); + it('getTree 在没有 root 时返回空数组', async () => { + const forest = await tree.getTree(); + expect(forest).toEqual([]); }); // ─── getAncestors(返回 TopologyNode[]) ─── diff --git a/packages/core/src/session/session-tree.ts b/packages/core/src/session/session-tree.ts index a5ccfca..d6a9e64 100644 --- a/packages/core/src/session/session-tree.ts +++ b/packages/core/src/session/session-tree.ts @@ -7,7 +7,6 @@ import type { SessionTree, CreateSessionOptions, } from '../types/session'; -import { MAIN_SESSION_ID } from '../types/session'; import type { SerializableSessionConfig } from '../types/session-config'; /** @@ -110,18 +109,19 @@ export class SessionTreeImpl implements SessionTree { } /** - * 创建根 Session(main),初始化配套 .md 与 core.json + * 创建根 Session,初始化配套 .md 与 core.json * - * 幂等:若 `MAIN_SESSION_ID` 对应的 meta 已存在,直接返回现有 TopologyNode,不覆写任何数据。 + * 幂等:若 `'root'` 对应的 meta 已存在,直接返回现有 TopologyNode,不覆写任何数据。 + * Task 9 会完全重写该实现,支持真正的多 root 拓扑。 */ async createRoot(label = 'Root'): Promise { - const existing = await this.fs.readJSON(metaPath(MAIN_SESSION_ID)); + const existing = await this.fs.readJSON(metaPath('root')); if (existing !== null) { return toTopologyNode(existing); } const ts = now(); const stored: StoredMeta = { - id: MAIN_SESSION_ID, + id: 'root', parentId: null, children: [], refs: [], @@ -149,15 +149,17 @@ export class SessionTreeImpl implements SessionTree { /** 创建子 Session,写入 meta.json 并更新父节点 children 列表 */ async createChild(options: CreateSessionOptions): Promise { + if (!options.parentId) throw new Error('createChild 需要 parentId'); + const parentId = options.parentId; return this.withWriteLock(async () => { - const parent = await this.requireStored(options.parentId); + const parent = await this.requireStored(parentId); const ts = now(); const stored: StoredMeta = { id: randomUUID(), parentId: parent.id, children: [], refs: [], - label: options.label, + label: options.label ?? 'Session', index: parent.children.length, status: 'active', depth: parent.depth + 1, @@ -248,11 +250,42 @@ export class SessionTreeImpl implements SessionTree { return stored ? toTopologyNode(stored) : null; } - async getTree(): Promise { + /** + * 统一的 Session 创建入口。 + * - 不传 parentId:调用 createRoot(Task 9 会改为支持多 root) + * - 传 parentId:调用 createChild + * + * 此方法是 Task 5 的适配层,最小满足新 SessionTree interface; + * Task 9 会重写底层使其原生支持森林结构。 + */ + async createSession(options: CreateSessionOptions = {}): Promise { + if (!options.parentId) { + return this.createRoot(options.label); + } + const childOptions: CreateSessionOptions = { + parentId: options.parentId, + label: options.label ?? 'Session', + }; + if (options.sourceSessionId !== undefined) { + childOptions.sourceSessionId = options.sourceSessionId; + } + return this.createChild(childOptions); + } + + /** 列出所有 root(parentId === null) */ + async listRoots(): Promise { + const all = await this.listAllStored(); + return all.filter((s) => s.parentId === null).map(toTopologyNode); + } + + /** + * 返回拓扑森林(多 root 数组)。 + * 当前实现仍是单 root 上限,但接口已升级为数组以匹配新 SessionTree。 + */ + async getTree(): Promise { const all = await this.listAllStored(); const map = new Map(all.map((s) => [s.id, s])); - const root = all.find((s) => s.parentId === null); - if (!root) throw new Error('根 Session 不存在'); + const roots = all.filter((s) => s.parentId === null); // 递归构建树节点,sourceSessionId 走统一解析(兼容 legacy metadata) const buildNode = (stored: StoredMeta): SessionTreeNode => { @@ -271,7 +304,7 @@ export class SessionTreeImpl implements SessionTree { return node; }; - return buildNode(root); + return roots.map(buildNode); } async getAncestors(id: string): Promise { diff --git a/packages/core/src/types/session.ts b/packages/core/src/types/session.ts index 5f8962b..f492c27 100644 --- a/packages/core/src/types/session.ts +++ b/packages/core/src/types/session.ts @@ -2,42 +2,22 @@ import type { SerializableSessionConfig } from './session-config'; -/** - * Main Session 的固定 ID。 - * - * Stello 每个拓扑有且仅有一个 main session(root 节点),其 ID 必须为此值。 - * Engine 通过比较 `sourceSessionId === MAIN_SESSION_ID` 判断 fork 来源是否为 main, - * 从而在合成链中跳过 parent 层(见 fork-design invariant #6)。 - * - * 宿主若自行实现 `SessionTree`,`createRoot` 返回的 TopologyNode.id 必须等于此常量, - * 否则 fork-from-main 的行为会违反 spec。 - */ -export const MAIN_SESSION_ID = 'main'; - /** Session 状态 */ export type SessionStatus = 'active' | 'archived'; /** * Session 元数据 * - * Session 是 Stello 的原子单元——一个独立的对话空间。 + * Session 是 Stello 的原子单元——一个独立对话空间。 * 不包含树结构信息,Session 不感知自己在拓扑中的位置。 - * 清理后只保留核心标识/状态/时间戳,scope/tags/metadata 已迁出。 */ export interface SessionMeta { - /** 唯一标识 */ readonly id: string; - /** 显示名称 */ label: string; - /** 当前状态 */ status: SessionStatus; - /** 对话轮次数 */ turnCount: number; - /** 创建时间(ISO 8601) */ createdAt: string; - /** 最后更新时间(ISO 8601) */ updatedAt: string; - /** 最后活跃时间(ISO 8601) */ lastActiveAt: string; } @@ -45,78 +25,65 @@ export interface SessionMeta { * 拓扑节点 * * 树结构信息,独立于 Session 维护。id 与 SessionMeta.id 对应。 + * `parentId === null` 即为 root。多 root 合法。 */ export interface TopologyNode { - /** Session ID */ readonly id: string; - /** 父节点 ID,null 表示根 */ parentId: string | null; - /** 子节点 ID 列表 */ children: string[]; - /** 跨分支引用 ID 列表 */ refs: string[]; - /** 层级深度(根 = 0) */ depth: number; - /** 在兄弟节点中的排序序号 */ index: number; - /** 显示名称(冗余存放,渲染用) */ label: string; - /** fork 时的上下文来源 session ID(当 topologyParentId 被覆盖时可能 ≠ parentId) */ sourceSessionId?: string; } -/** - * 递归树节点(API 返回用) - * - * 前端可直接用于渲染星空图。 - */ +/** 递归树节点(API 返回用) */ export interface SessionTreeNode { - /** Session ID */ id: string; - /** 显示名称 */ label: string; - /** 展示层的 fork 来源 Session ID */ sourceSessionId?: string; - /** 当前状态 */ status: SessionStatus; - /** 对话轮次数 */ turnCount: number; - /** 子节点 */ children: SessionTreeNode[]; } /** - * 创建子 Session 的参数(纯拓扑信息) + * 创建 Session 的参数(纯拓扑信息) + * + * `parentId` 为空则为新 root;非空挂在该节点下。 */ export interface CreateSessionOptions { - /** 父 Session ID */ - parentId: string; + /** 父节点 ID;为空建 root */ + parentId?: string; /** 显示名称 */ - label: string; - /** fork 时的上下文来源 session(不传默认语义 = parentId) */ + label?: string; + /** fork 时的上下文来源 session */ sourceSessionId?: string; } /** * Session 树操作接口 * - * 管理对话的空间结构:创建、查询、归档、引用。 - * 不支持删除,只支持归档(归档不连带子 Session)。 + * 管理对话的空间结构。支持多 root(森林)。 */ export interface SessionTree { - /** 创建根 Session,返回拓扑节点(label 不传由实现兜底默认值) */ - createRoot(label?: string): Promise; - /** 创建子 Session,返回拓扑节点 */ - createChild(options: CreateSessionOptions): Promise; + /** + * 创建 Session 拓扑节点。 + * - `options.parentId` 为空:创建新 root(`parentId === null`) + * - 非空:挂在该节点下作为子节点 + * - **不**继承父 Session 上下文 / 配置(需要继承走 forkSession) + */ + createSession(options?: CreateSessionOptions): Promise; /** 获取单个 Session 元数据 */ get(id: string): Promise; - /** 获取根 Session */ - getRoot(): Promise; /** 列出所有 Session */ listAll(): Promise; + /** 列出所有 root(parentId === null) */ + listRoots(): Promise; /** 归档 Session(不连带子节点) */ archive(id: string): Promise; - /** 创建跨分支引用(不能引用自己或直系祖先/后代) */ + /** 创建跨分支引用 */ addRef(fromId: string, toId: string): Promise; /** 更新 Session 元数据 */ updateMeta( @@ -125,25 +92,14 @@ export interface SessionTree { ): Promise; /** 获取单个拓扑节点 */ getNode(id: string): Promise; - /** 获取完整递归树 */ - getTree(): Promise; - /** 获取所有祖先节点(从父到根) */ + /** 获取完整拓扑(森林) */ + getTree(): Promise; + /** 获取所有祖先节点 */ getAncestors(id: string): Promise; /** 获取同级兄弟节点 */ getSiblings(id: string): Promise; - /** - * 读取 Session 的固化配置(可序列化子集) - * - * 普通 Session 与 Main Session 共用同一存储槽。未写入或文件不存在时返回 null。 - * 仅包含可序列化字段(systemPrompt/skills),函数/适配器等运行时引用由 - * 应用层通过 sessionDefaults 重新合成。 - */ + /** 读取 Session 固化配置 */ getConfig(id: string): Promise; - /** - * 写入 Session 的固化配置(可序列化子集) - * - * 覆盖已有配置。调用方只传可序列化字段;若传入完整 SessionConfig 框架不会拒绝, - * 但非可序列化字段在反序列化时将被丢弃。 - */ + /** 写入 Session 固化配置 */ putConfig(id: string, config: SerializableSessionConfig): Promise; } From cc9b3d5135d00ceb1d2a8029073948daf328aeef Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 18:12:27 +0800 Subject: [PATCH 08/40] refactor(core): drop MainSessionConfig types --- .../src/agent/__tests__/stello-agent.test.ts | 12 +++--- packages/core/src/agent/stello-agent.ts | 14 +++--- packages/core/src/index.ts | 2 - packages/core/src/types.ts | 2 - packages/core/src/types/session-config.ts | 43 ++----------------- 5 files changed, 15 insertions(+), 58 deletions(-) diff --git a/packages/core/src/agent/__tests__/stello-agent.test.ts b/packages/core/src/agent/__tests__/stello-agent.test.ts index 648865b..682e4a5 100644 --- a/packages/core/src/agent/__tests__/stello-agent.test.ts +++ b/packages/core/src/agent/__tests__/stello-agent.test.ts @@ -328,17 +328,17 @@ describe('StelloAgent', () => { }); it('会保留 mainSessionConfig 独立配置(不参与 fork 合成链)', () => { - const integrateFn = vi.fn(); + const consolidateFn = vi.fn(); const agent = createStelloAgent({ ...baseConfig(), mainSessionConfig: { systemPrompt: 'main prompt', - integrateFn, + consolidateFn, }, }); expect(agent.config.mainSessionConfig?.systemPrompt).toBe('main prompt'); - expect(agent.config.mainSessionConfig?.integrateFn).toBe(integrateFn); + expect(agent.config.mainSessionConfig?.consolidateFn).toBe(consolidateFn); }); it('updateConfig 可热更新 runtime 配置', async () => { @@ -548,7 +548,7 @@ describe('StelloAgent', () => { }); }); - it('createMainSession 剔除非可序列化字段(llm/integrateFn 等)', async () => { + it('createMainSession 剔除非可序列化字段(llm/consolidateFn 等)', async () => { const sessions = sessionsMock(); const dummyLlm = { complete: vi.fn() } as never; const dummyFn = vi.fn(); @@ -563,7 +563,7 @@ describe('StelloAgent', () => { mainSessionConfig: { systemPrompt: 'P', llm: dummyLlm, - integrateFn: dummyFn, + consolidateFn: dummyFn, }, }); @@ -572,7 +572,7 @@ describe('StelloAgent', () => { expect(sessions.putConfig).toHaveBeenCalledWith('root', { systemPrompt: 'P' }); const stored = sessions.store.get('root') as Record; expect(stored).not.toHaveProperty('llm'); - expect(stored).not.toHaveProperty('integrateFn'); + expect(stored).not.toHaveProperty('consolidateFn'); }); it('createMainSession 无 mainSessionConfig 时写入空对象', async () => { diff --git a/packages/core/src/agent/stello-agent.ts b/packages/core/src/agent/stello-agent.ts index f012788..5727502 100644 --- a/packages/core/src/agent/stello-agent.ts +++ b/packages/core/src/agent/stello-agent.ts @@ -29,8 +29,6 @@ import type { EngineLifecycleAdapter, EngineToolRuntime } from '../engine/stello import type { ForkProfileRegistry } from '../engine/fork-profile'; import type { SplitGuard } from '../session/split-guard'; import type { - MainSessionConfig, - SerializableMainSessionConfig, SerializableSessionConfig, SessionConfig, } from '../types/session-config'; @@ -61,7 +59,7 @@ export interface StelloAgentSessionConfig { /** 加载 MainSession 与其固化配置(可选,仅在需要 integration 时提供) */ mainSessionLoader?: () => Promise<{ session: MainSessionCompatible; - config: SerializableMainSessionConfig | null; + config: SerializableSessionConfig | null; } | null>; /** send() 结果序列化方式,默认 JSON 序列化 */ serializeSendResult?: (result: SessionCompatibleSendResult) => string; @@ -100,7 +98,7 @@ export interface StelloAgentConfig { /** Regular session 的 agent 级默认配置,fork 合成链的最低优先级 */ sessionDefaults?: SessionConfig; /** Main session 独立配置(不参与 fork 合成链) */ - mainSessionConfig?: MainSessionConfig; + mainSessionConfig?: SessionConfig; session?: StelloAgentSessionConfig; capabilities: StelloAgentCapabilitiesConfig; runtime?: StelloAgentRuntimeConfig; @@ -132,11 +130,11 @@ function resolveRuntimeResolver(config: StelloAgentConfig): SessionRuntimeResolv ); } -/** 从 MainSessionConfig 抽取可序列化子集 */ +/** 从 SessionConfig 抽取 main session 可序列化子集 */ function serializeMainSessionConfig( - config: MainSessionConfig | undefined, -): SerializableMainSessionConfig { - const result: SerializableMainSessionConfig = {}; + config: SessionConfig | undefined, +): SerializableSessionConfig { + const result: SerializableSessionConfig = {}; if (config?.systemPrompt !== undefined) result.systemPrompt = config.systemPrompt; if (config?.skills !== undefined) result.skills = config.skills; return result; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 289ab07..cc1d9c3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -42,9 +42,7 @@ export type { SessionRuntimeResolver, // Session 统一配置类型 SessionConfig, - MainSessionConfig, SerializableSessionConfig, - SerializableMainSessionConfig, } from './types'; export type { ToolExecutionContext } from './types/tool'; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6dad12e..f313687 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -43,7 +43,5 @@ export type { // Session 统一配置 export type { SessionConfig, - MainSessionConfig, SerializableSessionConfig, - SerializableMainSessionConfig, } from './types/session-config'; diff --git a/packages/core/src/types/session-config.ts b/packages/core/src/types/session-config.ts index 39b9913..5bfca72 100644 --- a/packages/core/src/types/session-config.ts +++ b/packages/core/src/types/session-config.ts @@ -4,14 +4,13 @@ import type { LLMAdapter, LLMCompleteOptions } from '@stello-ai/session'; import type { SessionCompatibleConsolidateFn, SessionCompatibleCompressFn, - SessionCompatibleIntegrateFn, } from '../adapters/session-runtime'; /** - * 普通 Session 的配置字段集 + * Session 配置字段集 * - * 固化后写入存储,不可变。覆盖单个 Session 在上下文组装、LLM 调用、 - * tool 调度、L3→L2 提炼、上下文压缩等环节所需的全部可配置项。 + * 固化后写入存储。覆盖单个 Session 在上下文组装、LLM 调用、 + * tool 调度、L3→L2 提炼、上下文压缩等环节所需的可配置项。 */ export interface SessionConfig { /** 该 Session 的 system prompt */ @@ -28,33 +27,10 @@ export interface SessionConfig { compressFn?: SessionCompatibleCompressFn; } -/** - * Main Session 的配置字段集 - * - * 独立于 SessionConfig,不参与 fallback 链。覆盖 Main Session - * 在上下文组装、LLM 调用、tool 调度、integration、上下文压缩等 - * 环节所需的全部可配置项。 - */ -export interface MainSessionConfig { - /** Main Session 的 system prompt */ - systemPrompt?: string; - /** Main Session 使用的 LLM 适配器 */ - llm?: LLMAdapter; - /** 用户 tool 定义集合 */ - tools?: LLMCompleteOptions['tools']; - /** skill 白名单:undefined=继承全局;[]=禁用 activate_skill;['a','b']=仅允许指定 skill */ - skills?: string[]; - /** all L2s → synthesis + insights 的 integration 函数 */ - integrateFn?: SessionCompatibleIntegrateFn; - /** 上下文压缩函数 */ - compressFn?: SessionCompatibleCompressFn; -} - /** * SessionConfig 的可序列化子集 * * 用于存储固化配置:只保留纯数据字段,丢弃函数/适配器等运行时引用。 - * 运行时通过 mergeSessionConfig 与 sessionDefaults 重新合成完整 SessionConfig。 */ export interface SerializableSessionConfig { /** 该 Session 的 system prompt */ @@ -62,16 +38,3 @@ export interface SerializableSessionConfig { /** skill 白名单 */ skills?: string[]; } - -/** - * MainSessionConfig 的可序列化子集 - * - * 语义与 SerializableSessionConfig 相同,但独立声明以保持与 MainSessionConfig - * 的一一对应关系,便于未来各自扩展。 - */ -export interface SerializableMainSessionConfig { - /** Main Session 的 system prompt */ - systemPrompt?: string; - /** skill 白名单 */ - skills?: string[]; -} From ea629f022613fddd88a7c8bff10fa2749233c32b Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 18:16:16 +0800 Subject: [PATCH 09/40] refactor(core): drop MainSessionCompatible/SessionCompatibleIntegrateFn adapter types --- packages/core/src/adapters/session-runtime.ts | 14 -------------- packages/core/src/agent/stello-agent.ts | 3 +-- packages/core/src/index.ts | 11 ++++------- packages/core/src/llm/defaults.ts | 10 +++++++++- 4 files changed, 14 insertions(+), 24 deletions(-) diff --git a/packages/core/src/adapters/session-runtime.ts b/packages/core/src/adapters/session-runtime.ts index 33a34db..e0b261a 100644 --- a/packages/core/src/adapters/session-runtime.ts +++ b/packages/core/src/adapters/session-runtime.ts @@ -35,15 +35,6 @@ export type SessionCompatibleCompressFn = ( messages: Array<{ role: string; content: string; timestamp?: string }>, ) => Promise; -/** 结构兼容 @stello-ai/session 的 integrate 函数签名 */ -export type SessionCompatibleIntegrateFn = ( - children: Array<{ sessionId: string; label: string; l2: string }>, - currentSynthesis: string | null, -) => Promise<{ - synthesis: string; - insights: Array<{ sessionId: string; content: string }>; -}>; - /** 结构兼容 @stello-ai/session 的 ForkOptions */ export interface SessionCompatibleForkOptions { id?: string; @@ -89,11 +80,6 @@ export interface SessionCompatible { setTools(tools: LLMCompleteOptions['tools'] | undefined): void; } -/** 结构兼容 @stello-ai/session 的 MainSession */ -export interface MainSessionCompatible { - integrate(): Promise; -} - /** Session -> EngineRuntime 适配配置 */ export interface SessionRuntimeAdapterOptions { /** 上下文压缩函数(可选) */ diff --git a/packages/core/src/agent/stello-agent.ts b/packages/core/src/agent/stello-agent.ts index 5727502..a6228b4 100644 --- a/packages/core/src/agent/stello-agent.ts +++ b/packages/core/src/agent/stello-agent.ts @@ -18,7 +18,6 @@ import { adaptSessionToEngineRuntime, serializeSessionSendResult, sessionSendResultParser, - type MainSessionCompatible, type SessionCompatible, type SessionCompatibleSendResult, } from '../adapters/session-runtime'; @@ -58,7 +57,7 @@ export interface StelloAgentSessionConfig { }>; /** 加载 MainSession 与其固化配置(可选,仅在需要 integration 时提供) */ mainSessionLoader?: () => Promise<{ - session: MainSessionCompatible; + session: { integrate(): Promise }; config: SerializableSessionConfig | null; } | null>; /** send() 结果序列化方式,默认 JSON 序列化 */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cc1d9c3..f77a073 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -65,11 +65,9 @@ export { export type { SessionRuntimeAdapterOptions, SessionCompatible, - MainSessionCompatible, SessionCompatibleToolCall, SessionCompatibleSendResult, SessionCompatibleConsolidateFn, - SessionCompatibleIntegrateFn, SessionCompatibleCompressFn, SessionCompatibleForkOptions, } from './adapters/session-runtime'; @@ -141,7 +139,6 @@ export type { LLMCallFn, DefaultFnOptions } from './llm/defaults'; // Re-export @stello-ai/session 常用接口,core 用户无需额外 import session 包 export { createSession, loadSession } from '@stello-ai/session'; -export { createMainSession, loadMainSession } from '@stello-ai/session'; export { createClaude } from '@stello-ai/session'; export { createGPT } from '@stello-ai/session'; export { createOpenAICompatibleAdapter } from '@stello-ai/session'; @@ -159,18 +156,18 @@ export type { OpenAICompatibleOptions, AnthropicAdapterOptions, // Session API - Session, MainSession, SendResult, StreamResult, + Session, SendResult, StreamResult, MessageQueryOptions, // Session 元数据 SessionMetaUpdate, SessionFilter, // Fork ForkOptions, ForkContextFn, // 存储 - SessionStorage, MainStorage, ListRecordsOptions, + SessionStorage, ListRecordsOptions, // 函数签名 - CompressFn, ConsolidateFn, IntegrateFn, IntegrateResult, ChildL2Summary, + CompressFn, ConsolidateFn, CreateSessionOptions as SessionCreateOptions, - LoadSessionOptions, CreateMainSessionOptions, LoadMainSessionOptions, + LoadSessionOptions, // 工具 Tool, CallToolResult, ToolAnnotations, } from '@stello-ai/session'; diff --git a/packages/core/src/llm/defaults.ts b/packages/core/src/llm/defaults.ts index c4b811c..dec5cbc 100644 --- a/packages/core/src/llm/defaults.ts +++ b/packages/core/src/llm/defaults.ts @@ -1,10 +1,18 @@ import type { LLMAdapter } from '@stello-ai/session' import type { SessionCompatibleConsolidateFn, - SessionCompatibleIntegrateFn, SessionCompatibleCompressFn, } from '../adapters/session-runtime.js' +/** integrate 函数签名(消费所有子 L2,输出 synthesis + insights) */ +type SessionCompatibleIntegrateFn = ( + children: Array<{ sessionId: string; label: string; l2: string }>, + currentSynthesis: string | null, +) => Promise<{ + synthesis: string + insights: Array<{ sessionId: string; content: string }> +}> + /** 最小 LLM 调用接口,仅用于 consolidation/integration 内置默认实现 */ export type LLMCallFn = ( messages: Array<{ role: string; content: string }>, From f89a6a4d442c295ee031435b13fd6f4a79a579b7 Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 18:19:17 +0800 Subject: [PATCH 10/40] refactor(core): drop createDefaultIntegrateFn and DEFAULT_INTEGRATE_PROMPT --- packages/core/src/index.ts | 2 - .../core/src/llm/__tests__/defaults.test.ts | 55 ---------------- packages/core/src/llm/defaults.ts | 62 ------------------- 3 files changed, 119 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f77a073..c189d04 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -129,10 +129,8 @@ export { createSessionTool, activateSkillTool } from './builtin-tools'; // 导出 LLM 默认实现 export { createDefaultConsolidateFn, - createDefaultIntegrateFn, createDefaultCompressFn, DEFAULT_CONSOLIDATE_PROMPT, - DEFAULT_INTEGRATE_PROMPT, DEFAULT_COMPRESS_PROMPT, } from './llm/defaults'; export type { LLMCallFn, DefaultFnOptions } from './llm/defaults'; diff --git a/packages/core/src/llm/__tests__/defaults.test.ts b/packages/core/src/llm/__tests__/defaults.test.ts index 13e3d59..39cc7dd 100644 --- a/packages/core/src/llm/__tests__/defaults.test.ts +++ b/packages/core/src/llm/__tests__/defaults.test.ts @@ -3,67 +3,12 @@ import type { LLMAdapter } from '@stello-ai/session' import { createDefaultCompressFn, createDefaultConsolidateFn, - createDefaultIntegrateFn, DEFAULT_COMPRESS_PROMPT, DEFAULT_CONSOLIDATE_PROMPT, - DEFAULT_INTEGRATE_PROMPT, llmCallFnFromAdapter, type LLMCallFn, } from '../defaults.js' -describe('createDefaultIntegrateFn', () => { - it('在传给 LLM 的子 Session 摘要中包含真实 sessionId', async () => { - const llm = vi.fn(async () => JSON.stringify({ - synthesis: '综合结果', - insights: [{ sessionId: 'sess-1', content: '继续推进' }], - })) - const fn = createDefaultIntegrateFn(DEFAULT_INTEGRATE_PROMPT, llm) - - await fn([ - { sessionId: 'sess-1', label: '选校', l2: '已完成第一轮筛选' }, - { sessionId: 'sess-2', label: '文书', l2: 'PS 初稿待修改' }, - ], null) - - expect(llm).toHaveBeenCalledTimes(1) - const [messages] = llm.mock.calls[0]! - expect(messages[1]?.content).toContain('[sessionId=sess-1] 选校: 已完成第一轮筛选') - expect(messages[1]?.content).toContain('[sessionId=sess-2] 文书: PS 初稿待修改') - }) - - it('无 roleContext 时不额外注入 system 消息(向后兼容)', async () => { - const llm = vi.fn(async () => JSON.stringify({ synthesis: '', insights: [] })) - const fn = createDefaultIntegrateFn(DEFAULT_INTEGRATE_PROMPT, llm) - await fn([{ sessionId: 's1', label: 'x', l2: 'y' }], null) - const [messages] = llm.mock.calls[0]! - expect(messages).toHaveLength(2) - expect(messages[0]?.role).toBe('system') - expect(messages[1]?.role).toBe('user') - }) - - it('传入 roleContext 时在任务 prompt 后插入 system 消息', async () => { - const llm = vi.fn(async () => JSON.stringify({ synthesis: '', insights: [] })) - const fn = createDefaultIntegrateFn(DEFAULT_INTEGRATE_PROMPT, llm, { - roleContext: '你是 MainSession 的协调者', - }) - await fn([{ sessionId: 's1', label: 'x', l2: 'y' }], null) - const [messages] = llm.mock.calls[0]! - expect(messages).toHaveLength(3) - expect(messages[0]?.role).toBe('system') - expect(messages[0]?.content).toBe(DEFAULT_INTEGRATE_PROMPT) - expect(messages[1]?.role).toBe('system') - expect(messages[1]?.content).toBe('\n你是 MainSession 的协调者\n') - expect(messages[2]?.role).toBe('user') - }) - - it('roleContext 为空字符串时视为未传(不注入)', async () => { - const llm = vi.fn(async () => JSON.stringify({ synthesis: '', insights: [] })) - const fn = createDefaultIntegrateFn(DEFAULT_INTEGRATE_PROMPT, llm, { roleContext: '' }) - await fn([{ sessionId: 's1', label: 'x', l2: 'y' }], null) - const [messages] = llm.mock.calls[0]! - expect(messages).toHaveLength(2) - }) -}) - describe('createDefaultConsolidateFn', () => { it('无 roleContext 时消息结构为 [system:prompt, user:content]', async () => { const llm = vi.fn(async () => '摘要结果') diff --git a/packages/core/src/llm/defaults.ts b/packages/core/src/llm/defaults.ts index dec5cbc..35e810c 100644 --- a/packages/core/src/llm/defaults.ts +++ b/packages/core/src/llm/defaults.ts @@ -4,15 +4,6 @@ import type { SessionCompatibleCompressFn, } from '../adapters/session-runtime.js' -/** integrate 函数签名(消费所有子 L2,输出 synthesis + insights) */ -type SessionCompatibleIntegrateFn = ( - children: Array<{ sessionId: string; label: string; l2: string }>, - currentSynthesis: string | null, -) => Promise<{ - synthesis: string - insights: Array<{ sessionId: string; content: string }> -}> - /** 最小 LLM 调用接口,仅用于 consolidation/integration 内置默认实现 */ export type LLMCallFn = ( messages: Array<{ role: string; content: string }>, @@ -41,23 +32,6 @@ export const DEFAULT_CONSOLIDATE_PROMPT = `你是对话摘要助手。请将对 - 输出一段连贯文字,不用列表或 Markdown 标记 - 语言精炼客观,像一条工作备忘` -/** 默认 integration 提示词 */ -export const DEFAULT_INTEGRATE_PROMPT = `你是一个跨会话综合分析助手。请根据所有子会话的摘要,生成综合分析和给各子会话的建议。 - -输出 JSON 格式: -{ - "synthesis": "综合分析文本", - "insights": [ - {"sessionId": "子会话ID", "content": "给该子会话的建议"} - ] -} - -要求: -- synthesis 综合所有子会话的核心发现 -- insights 给每个子会话提供跨会话视角的建议 -- insights.sessionId 必须使用输入里提供的 sessionId 原样返回,不要使用 label,也不要编造值 -- 用中文输出` - /** * 默认 fn 的可选参数。 * @@ -103,42 +77,6 @@ export function createDefaultConsolidateFn( } } -/** 根据 prompt 创建默认 integrateFn:prompt + 所有子 L2 + 当前 synthesis → synthesis + insights */ -export function createDefaultIntegrateFn( - prompt: string, - llm: LLMCallFn, - options?: DefaultFnOptions, -): SessionCompatibleIntegrateFn { - return async (children, currentSynthesis) => { - const parts: string[] = [] - if (currentSynthesis) { - parts.push(`当前综合:\n${currentSynthesis}`) - } - parts.push( - `子 Session 摘要:\n${children.map((c) => `- [sessionId=${c.sessionId}] ${c.label}: ${c.l2}`).join('\n')}`, - ) - const raw = await llm([ - { role: 'system', content: prompt }, - ...roleContextMessages(options), - { role: 'user', content: parts.join('\n\n') }, - ]) - /* 容错:清除 标签,提取 JSON 块 */ - const cleaned = raw.replace(/[\s\S]*?<\/think>\s*/g, '').trim() - const jsonMatch = cleaned.match(/\{[\s\S]*\}/) - if (!jsonMatch) { - return { synthesis: cleaned, insights: [] } - } - try { - return JSON.parse(jsonMatch[0]) as { - synthesis: string - insights: Array<{ sessionId: string; content: string }> - } - } catch { - return { synthesis: cleaned, insights: [] } - } - } -} - /** 默认 context 压缩提示词 */ export const DEFAULT_COMPRESS_PROMPT = `你是对话压缩助手。请将以下对话历史压缩为一段简洁的摘要,保留关键上下文信息。 要求: From bbaf24e39ebf9f1f447852ef255cb4c0443451c4 Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 18:24:43 +0800 Subject: [PATCH 11/40] refactor(core): unify SessionTreeImpl under createSession with multi-root support --- packages/core/src/engine/stello-engine.ts | 2 +- .../file-system-memory-engine.test.ts | 36 +- .../session/__tests__/session-tree.test.ts | 398 +++++++++--------- .../src/session/__tests__/split-guard.test.ts | 4 +- packages/core/src/session/session-tree.ts | 148 +++---- 5 files changed, 286 insertions(+), 302 deletions(-) diff --git a/packages/core/src/engine/stello-engine.ts b/packages/core/src/engine/stello-engine.ts index de88913..5af9ab3 100644 --- a/packages/core/src/engine/stello-engine.ts +++ b/packages/core/src/engine/stello-engine.ts @@ -370,7 +370,7 @@ export class StelloEngineImpl implements StelloEngine { forkOptions: options, }); - // 解析有效 context 并按需执行压缩。必须在 createChild 之前运行: + // 解析有效 context 并按需执行压缩。必须在 createSession 之前运行: // 若 compress 缺少 compressFn/llm 而抛错,避免产生孤儿拓扑节点。 const effectiveContext = options.context ?? profile?.context; const llmCallFn: LLMCallFn | undefined = merged.llm diff --git a/packages/core/src/memory/__tests__/file-system-memory-engine.test.ts b/packages/core/src/memory/__tests__/file-system-memory-engine.test.ts index 4adf05f..63cc721 100644 --- a/packages/core/src/memory/__tests__/file-system-memory-engine.test.ts +++ b/packages/core/src/memory/__tests__/file-system-memory-engine.test.ts @@ -96,7 +96,7 @@ describe('FileSystemMemoryEngine', () => { }); it('writeMemory / readMemory round-trip', async () => { - const node = await sessions.createRoot('Test Root'); + const node = await sessions.createSession({ label: 'Test Root' }); await engine.writeMemory(node.id, '# Memory\nSome content'); const result = await engine.readMemory(node.id); expect(result).toBe('# Memory\nSome content'); @@ -107,7 +107,7 @@ describe('FileSystemMemoryEngine', () => { }); it('writeScope / readScope round-trip', async () => { - const node = await sessions.createRoot('Root'); + const node = await sessions.createSession({ label: 'Root' }); await engine.writeScope(node.id, '# Scope'); expect(await engine.readScope(node.id)).toBe('# Scope'); }); @@ -117,7 +117,7 @@ describe('FileSystemMemoryEngine', () => { }); it('writeIndex / readIndex round-trip', async () => { - const node = await sessions.createRoot('Root'); + const node = await sessions.createSession({ label: 'Root' }); await engine.writeIndex(node.id, '# Index'); expect(await engine.readIndex(node.id)).toBe('# Index'); }); @@ -139,7 +139,7 @@ describe('FileSystemMemoryEngine', () => { }); it('appendRecord / readRecords round-trip', async () => { - const node = await sessions.createRoot('Root'); + const node = await sessions.createSession({ label: 'Root' }); const record = makeRecord('user', 'Hello'); await engine.appendRecord(node.id, record); const records = await engine.readRecords(node.id); @@ -148,7 +148,7 @@ describe('FileSystemMemoryEngine', () => { }); it('appendRecord preserves insertion order', async () => { - const node = await sessions.createRoot('Root'); + const node = await sessions.createSession({ label: 'Root' }); await engine.appendRecord(node.id, makeRecord('user', 'first')); await engine.appendRecord(node.id, makeRecord('assistant', 'second')); await engine.appendRecord(node.id, makeRecord('user', 'third')); @@ -160,7 +160,7 @@ describe('FileSystemMemoryEngine', () => { }); it('replaceRecords overwrites all records', async () => { - const node = await sessions.createRoot('Root'); + const node = await sessions.createSession({ label: 'Root' }); await engine.appendRecord(node.id, makeRecord('user', 'old')); const newRecords: TurnRecord[] = [ makeRecord('user', 'new1'), @@ -174,7 +174,7 @@ describe('FileSystemMemoryEngine', () => { }); it('replaceRecords with empty array clears records', async () => { - const node = await sessions.createRoot('Root'); + const node = await sessions.createSession({ label: 'Root' }); await engine.appendRecord(node.id, makeRecord('user', 'data')); await engine.replaceRecords(node.id, []); const records = await engine.readRecords(node.id); @@ -182,7 +182,7 @@ describe('FileSystemMemoryEngine', () => { }); it('appendRecord preserves metadata field', async () => { - const node = await sessions.createRoot('Root'); + const node = await sessions.createSession({ label: 'Root' }); const record: TurnRecord = { role: 'tool', content: 'result', @@ -195,7 +195,7 @@ describe('FileSystemMemoryEngine', () => { }); it('readRecords skips corrupt lines', async () => { - const node = await (sessions as SessionTreeImpl).createRoot('test') + const node = await (sessions as SessionTreeImpl).createSession({ label: 'test' }) const good: TurnRecord = { role: 'user', content: 'hi', timestamp: '2026-01-01T00:00:00Z' } await engine.appendRecord(node.id, good) // Manually inject a corrupt line @@ -210,8 +210,8 @@ describe('FileSystemMemoryEngine', () => { describe('assembleContext', () => { it('returns empty core and no memories for root with no data', async () => { - const root = await sessions.createRoot('Root'); - // Need core.json to exist (createRoot does this) + const root = await sessions.createSession({ label: 'Root' }); + // Need core.json to exist (createSession does this for roots) const ctx = await engine.assembleContext(root.id); expect(ctx.core).toEqual({}); expect(ctx.memories).toEqual([]); @@ -220,7 +220,7 @@ describe('FileSystemMemoryEngine', () => { }); it('includes currentMemory and scope for session', async () => { - const root = await sessions.createRoot('Root'); + const root = await sessions.createSession({ label: 'Root' }); await engine.writeMemory(root.id, '# Root Memory'); await engine.writeScope(root.id, '# Root Scope'); const ctx = await engine.assembleContext(root.id); @@ -229,11 +229,11 @@ describe('FileSystemMemoryEngine', () => { }); it('collects ancestor memories from parent to root', async () => { - const root = await sessions.createRoot('Root'); + const root = await sessions.createSession({ label: 'Root' }); await engine.writeMemory(root.id, '# Root Memory'); - const child = await sessions.createChild({ parentId: root.id, label: 'Child' }); + const child = await sessions.createSession({ parentId: root.id, label: 'Child' }); await engine.writeMemory(child.id, '# Child Memory'); - const grandchild = await sessions.createChild({ parentId: child.id, label: 'Grandchild' }); + const grandchild = await sessions.createSession({ parentId: child.id, label: 'Grandchild' }); const ctx = await engine.assembleContext(grandchild.id); // ancestors from parent to root: [child, root] @@ -244,16 +244,16 @@ describe('FileSystemMemoryEngine', () => { }); it('includes L1 core data in context', async () => { - const root = await sessions.createRoot('Root'); + const root = await sessions.createSession({ label: 'Root' }); await engine.writeCore('user', 'Bob'); const ctx = await engine.assembleContext(root.id); expect(ctx.core).toEqual({ user: 'Bob' }); }); it('skips ancestors with no memory', async () => { - const root = await sessions.createRoot('Root'); + const root = await sessions.createSession({ label: 'Root' }); // no memory written to root - const child = await sessions.createChild({ parentId: root.id, label: 'Child' }); + const child = await sessions.createSession({ parentId: root.id, label: 'Child' }); const ctx = await engine.assembleContext(child.id); expect(ctx.memories).toEqual([]); }); diff --git a/packages/core/src/session/__tests__/session-tree.test.ts b/packages/core/src/session/__tests__/session-tree.test.ts index 7f41579..7e4fbf3 100644 --- a/packages/core/src/session/__tests__/session-tree.test.ts +++ b/packages/core/src/session/__tests__/session-tree.test.ts @@ -19,109 +19,150 @@ describe('SessionTreeImpl', () => { await rm(tmpDir, { recursive: true, force: true }); }); - // ─── createRoot ─── - - it('createRoot 返回 TopologyNode', async () => { - const root = await tree.createRoot('我的根'); - expect(root.parentId).toBeNull(); - expect(root.depth).toBe(0); - expect(root.index).toBe(0); - expect(root.label).toBe('我的根'); - expect(root.children).toEqual([]); - expect(root.refs).toEqual([]); - // core.json 已初始化 - const fs = new NodeFileSystemAdapter(tmpDir); - const core = await fs.readJSON('core.json'); - expect(core).toEqual({}); - }); + // ─── createSession(无 parentId 即 root) ─── + + describe('createSession (multi-root)', () => { + it('createSession 无 parentId 时建 root,parentId 为 null,depth=0', async () => { + const root = await tree.createSession({ label: '我的根' }); + expect(root.parentId).toBeNull(); + expect(root.depth).toBe(0); + expect(root.index).toBe(0); + expect(root.label).toBe('我的根'); + expect(root.children).toEqual([]); + expect(root.refs).toEqual([]); + // core.json 已初始化 + const fs = new NodeFileSystemAdapter(tmpDir); + const core = await fs.readJSON('core.json'); + expect(core).toEqual({}); + }); - it('createRoot 后 memory.md / scope.md / index.md 存在', async () => { - const fs = new NodeFileSystemAdapter(tmpDir); - const root = await tree.createRoot(); - expect(await fs.exists(`sessions/${root.id}/memory.md`)).toBe(true); - expect(await fs.exists(`sessions/${root.id}/scope.md`)).toBe(true); - expect(await fs.exists(`sessions/${root.id}/index.md`)).toBe(true); - }); + it('createSession 无 label 时 root 默认 "Root"', async () => { + const root = await tree.createSession(); + expect(root.label).toBe('Root'); + }); - it('createRoot 幂等:第二次调用返回现有节点,不覆写 label 与已写入的内容', async () => { - const fs = new NodeFileSystemAdapter(tmpDir); - const first = await tree.createRoot('Original'); - // 模拟用户在 memory.md 写入内容,确认后续 createRoot 不覆写 - await fs.writeFile(`sessions/${first.id}/memory.md`, 'user content'); - - const second = await tree.createRoot('Ignored'); - expect(second.id).toBe(first.id); - expect(second.label).toBe('Original'); - expect(await fs.readFile(`sessions/${first.id}/memory.md`)).toBe('user content'); - }); + it('createSession 后 root 的 memory.md / scope.md / index.md 存在', async () => { + const fs = new NodeFileSystemAdapter(tmpDir); + const root = await tree.createSession(); + expect(await fs.exists(`sessions/${root.id}/memory.md`)).toBe(true); + expect(await fs.exists(`sessions/${root.id}/scope.md`)).toBe(true); + expect(await fs.exists(`sessions/${root.id}/index.md`)).toBe(true); + }); - // ─── createChild ─── - - it('createChild 返回 TopologyNode', async () => { - const root = await tree.createRoot(); - const child = await tree.createChild({ parentId: root.id, label: '子节点' }); - expect(child.parentId).toBe(root.id); - expect(child.depth).toBe(1); - expect(child.index).toBe(0); - expect(child.children).toEqual([]); - expect(child.refs).toEqual([]); - // 父的 children 已更新(通过 getNode 验证) - const updatedRoot = await tree.getNode(root.id); - expect(updatedRoot?.children).toContain(child.id); - }); + it('多 root 合法:每次调用产生新 UUID,listRoots 返回所有 root', async () => { + const r1 = await tree.createSession({ label: 'R1' }); + const r2 = await tree.createSession({ label: 'R2' }); + expect(r1.id).not.toBe(r2.id); + const roots = await tree.listRoots(); + expect(roots.map((r) => r.id).sort()).toEqual([r1.id, r2.id].sort()); + for (const r of roots) { + expect(r.parentId).toBeNull(); + expect(r.depth).toBe(0); + } + }); - it('createChild 后 memory.md / scope.md / index.md 存在', async () => { - const fs = new NodeFileSystemAdapter(tmpDir); - const root = await tree.createRoot(); - const child = await tree.createChild({ parentId: root.id, label: '子' }); - expect(await fs.exists(`sessions/${child.id}/memory.md`)).toBe(true); - expect(await fs.exists(`sessions/${child.id}/scope.md`)).toBe(true); - expect(await fs.exists(`sessions/${child.id}/index.md`)).toBe(true); - }); + it('listRoots 在没有 root 时返回空数组', async () => { + const roots = await tree.listRoots(); + expect(roots).toEqual([]); + }); - it('createChild 父不存在抛错', async () => { - await expect(tree.createChild({ parentId: 'fake-id', label: 'test' })).rejects.toThrow( - 'Session 不存在', - ); - }); + it('createSession 带 parentId 时挂在父下,depth = parent.depth + 1', async () => { + const root = await tree.createSession({ label: '根' }); + const child = await tree.createSession({ parentId: root.id, label: '子节点' }); + expect(child.parentId).toBe(root.id); + expect(child.depth).toBe(1); + expect(child.index).toBe(0); + expect(child.children).toEqual([]); + expect(child.refs).toEqual([]); + // 父的 children 已更新 + const updatedRoot = await tree.getNode(root.id); + expect(updatedRoot?.children).toContain(child.id); + }); - it('createChild 多个子节点 index 递增', async () => { - const root = await tree.createRoot(); - const a = await tree.createChild({ parentId: root.id, label: 'A' }); - const b = await tree.createChild({ parentId: root.id, label: 'B' }); - expect(a.index).toBe(0); - expect(b.index).toBe(1); - }); + it('createSession 无 label 时 child 默认 "Session"', async () => { + const root = await tree.createSession(); + const child = await tree.createSession({ parentId: root.id }); + expect(child.label).toBe('Session'); + }); - it('createChild 并发同父节点不会丢失子引用(写锁串行化 RMW)', async () => { - const root = await tree.createRoot(); - const N = 8; - // 模拟一轮内多个 stello_create_session 并行执行 - const labels = Array.from({ length: N }, (_, i) => `P${i}`); - const children = await Promise.all( - labels.map((label) => tree.createChild({ parentId: root.id, label })), - ); - - // 所有子 id 唯一 - const ids = new Set(children.map((c) => c.id)); - expect(ids.size).toBe(N); - - // 父节点的 children 列表完整记录所有子,未被 RMW 竞态丢失 - const parentNode = await tree.getNode(root.id); - expect(parentNode?.children).toHaveLength(N); - for (const c of children) { - expect(parentNode?.children).toContain(c.id); - } + it('createSession 后 child 的 memory.md / scope.md / index.md 存在', async () => { + const fs = new NodeFileSystemAdapter(tmpDir); + const root = await tree.createSession(); + const child = await tree.createSession({ parentId: root.id, label: '子' }); + expect(await fs.exists(`sessions/${child.id}/memory.md`)).toBe(true); + expect(await fs.exists(`sessions/${child.id}/scope.md`)).toBe(true); + expect(await fs.exists(`sessions/${child.id}/index.md`)).toBe(true); + }); + + it('createSession 父不存在抛错', async () => { + await expect( + tree.createSession({ parentId: 'fake-id', label: 'test' }), + ).rejects.toThrow('Session 不存在'); + }); + + it('createSession 多个子节点 index 递增', async () => { + const root = await tree.createSession(); + const a = await tree.createSession({ parentId: root.id, label: 'A' }); + const b = await tree.createSession({ parentId: root.id, label: 'B' }); + expect(a.index).toBe(0); + expect(b.index).toBe(1); + }); + + it('createSession 并发同父节点不会丢失子引用(写锁串行化 RMW)', async () => { + const root = await tree.createSession(); + const N = 8; + // 模拟一轮内多个 stello_create_session 并行执行 + const labels = Array.from({ length: N }, (_, i) => `P${i}`); + const children = await Promise.all( + labels.map((label) => tree.createSession({ parentId: root.id, label })), + ); + + // 所有子 id 唯一 + const ids = new Set(children.map((c) => c.id)); + expect(ids.size).toBe(N); + + // 父节点的 children 列表完整记录所有子,未被 RMW 竞态丢失 + const parentNode = await tree.getNode(root.id); + expect(parentNode?.children).toHaveLength(N); + for (const c of children) { + expect(parentNode?.children).toContain(c.id); + } + + // index 单调递增(串行写入) + const indices = children.map((c) => c.index).sort((a, b) => a - b); + expect(indices).toEqual(Array.from({ length: N }, (_, i) => i)); + }); - // index 单调递增(串行写入) - const indices = children.map((c) => c.index).sort((a, b) => a - b); - expect(indices).toEqual(Array.from({ length: N }, (_, i) => i)); + it('createSession 持久化 sourceSessionId 字段(子节点)', async () => { + const root = await tree.createSession(); + const a = await tree.createSession({ parentId: root.id, label: 'A' }); + const b = await tree.createSession({ + parentId: root.id, + label: 'B', + sourceSessionId: a.id, + }); + + // createSession 返回值直接带 sourceSessionId + expect(b.sourceSessionId).toBe(a.id); + + // 持久化后 getNode 仍能读到 + const node = await tree.getNode(b.id); + expect(node?.sourceSessionId).toBe(a.id); + }); + + it('createSession 未传 sourceSessionId 时拓扑节点该字段为 undefined', async () => { + const root = await tree.createSession(); + const child = await tree.createSession({ parentId: root.id, label: 'C' }); + expect(child.sourceSessionId).toBeUndefined(); + const node = await tree.getNode(child.id); + expect(node?.sourceSessionId).toBeUndefined(); + }); }); // ─── get(返回 SessionMeta,不含拓扑字段) ─── it('get 返回 SessionMeta 或 null', async () => { - const root = await tree.createRoot('测试'); + const root = await tree.createSession({ label: '测试' }); const found = await tree.get(root.id); expect(found).not.toBeNull(); expect(found?.id).toBe(root.id); @@ -143,24 +184,12 @@ describe('SessionTreeImpl', () => { expect(notFound).toBeNull(); }); - // ─── getRoot(返回 SessionMeta) ─── - - it('getRoot 返回根节点的 SessionMeta', async () => { - const root = await tree.createRoot('根'); - await tree.createChild({ parentId: root.id, label: 'A' }); - const foundRoot = await tree.getRoot(); - expect(foundRoot.id).toBe(root.id); - expect(foundRoot.label).toBe('根'); - // SessionMeta 不含 parentId - expect(foundRoot).not.toHaveProperty('parentId'); - }); - // ─── listAll(返回 SessionMeta[]) ─── it('listAll 列出所有 Session 的 SessionMeta', async () => { - const root = await tree.createRoot(); - await tree.createChild({ parentId: root.id, label: 'A' }); - await tree.createChild({ parentId: root.id, label: 'B' }); + const root = await tree.createSession(); + await tree.createSession({ parentId: root.id, label: 'A' }); + await tree.createSession({ parentId: root.id, label: 'B' }); const all = await tree.listAll(); expect(all).toHaveLength(3); // 每个元素都是 SessionMeta,不含拓扑字段 @@ -174,8 +203,8 @@ describe('SessionTreeImpl', () => { // ─── getNode ─── it('getNode 返回 TopologyNode 或 null', async () => { - const root = await tree.createRoot('根'); - const child = await tree.createChild({ parentId: root.id, label: '子' }); + const root = await tree.createSession({ label: '根' }); + const child = await tree.createSession({ parentId: root.id, label: '子' }); const rootNode = await tree.getNode(root.id); expect(rootNode).not.toBeNull(); @@ -196,32 +225,7 @@ describe('SessionTreeImpl', () => { expect(notFound).toBeNull(); }); - // ─── getNode.sourceSessionId(一等字段) ─── - - it('createChild 写入 sourceSessionId 后 getNode 暴露该字段', async () => { - const root = await tree.createRoot(); - const a = await tree.createChild({ parentId: root.id, label: 'A' }); - const b = await tree.createChild({ - parentId: root.id, - label: 'B', - sourceSessionId: a.id, - }); - - // createChild 返回值直接带 sourceSessionId - expect(b.sourceSessionId).toBe(a.id); - - // 持久化后 getNode 仍能读到 - const node = await tree.getNode(b.id); - expect(node?.sourceSessionId).toBe(a.id); - }); - - it('createChild 未传 sourceSessionId 时拓扑节点该字段为 undefined', async () => { - const root = await tree.createRoot(); - const child = await tree.createChild({ parentId: root.id, label: 'C' }); - expect(child.sourceSessionId).toBeUndefined(); - const node = await tree.getNode(child.id); - expect(node?.sourceSessionId).toBeUndefined(); - }); + // ─── getNode.sourceSessionId(legacy 回填) ─── it('回填读取:顶层 sourceSessionId 缺失时从 legacy metadata.sourceSessionId 取', async () => { // 直接写一份 legacy 格式的 meta.json(仅含 metadata.sourceSessionId) @@ -267,44 +271,56 @@ describe('SessionTreeImpl', () => { expect(forest[0]?.children[0]?.sourceSessionId).toBe(rootId); }); - // ─── getTree ─── - - it('getTree 返回递归树结构', async () => { - const root = await tree.createRoot('根'); - const a = await tree.createChild({ parentId: root.id, label: 'A' }); - const b = await tree.createChild({ parentId: root.id, label: 'B' }); - await tree.createChild({ parentId: a.id, label: 'A1', sourceSessionId: a.id }); + // ─── getTree(森林) ─── + + describe('getTree (forest)', () => { + it('getTree 返回递归树结构', async () => { + const root = await tree.createSession({ label: '根' }); + const a = await tree.createSession({ parentId: root.id, label: 'A' }); + const b = await tree.createSession({ parentId: root.id, label: 'B' }); + await tree.createSession({ parentId: a.id, label: 'A1', sourceSessionId: a.id }); + + const forest = await tree.getTree(); + expect(forest).toHaveLength(1); + const treeData = forest[0]!; + expect(treeData.id).toBe(root.id); + expect(treeData.label).toBe('根'); + expect(treeData.status).toBe('active'); + expect(treeData.children).toHaveLength(2); + + const childA = treeData.children.find((c) => c.id === a.id); + expect(childA?.label).toBe('A'); + expect(childA?.children).toHaveLength(1); + expect(childA?.children[0]?.label).toBe('A1'); + expect(childA?.children[0]?.sourceSessionId).toBe(a.id); + + const childB = treeData.children.find((c) => c.id === b.id); + expect(childB?.label).toBe('B'); + expect(childB?.children).toHaveLength(0); + }); - const forest = await tree.getTree(); - expect(forest).toHaveLength(1); - const treeData = forest[0]!; - expect(treeData.id).toBe(root.id); - expect(treeData.label).toBe('根'); - expect(treeData.status).toBe('active'); - expect(treeData.children).toHaveLength(2); - - const childA = treeData.children.find((c) => c.id === a.id); - expect(childA?.label).toBe('A'); - expect(childA?.children).toHaveLength(1); - expect(childA?.children[0]?.label).toBe('A1'); - expect(childA?.children[0]?.sourceSessionId).toBe(a.id); - - const childB = treeData.children.find((c) => c.id === b.id); - expect(childB?.label).toBe('B'); - expect(childB?.children).toHaveLength(0); - }); + it('getTree 返回多 root 的森林', async () => { + const r1 = await tree.createSession({ label: 'R1' }); + const r2 = await tree.createSession({ label: 'R2' }); + await tree.createSession({ parentId: r1.id, label: 'C1' }); + const forest = await tree.getTree(); + expect(forest).toHaveLength(2); + expect(forest.find((n) => n.id === r1.id)?.children).toHaveLength(1); + expect(forest.find((n) => n.id === r2.id)?.children).toHaveLength(0); + }); - it('getTree 在没有 root 时返回空数组', async () => { - const forest = await tree.getTree(); - expect(forest).toEqual([]); + it('getTree 在没有 root 时返回空数组', async () => { + const forest = await tree.getTree(); + expect(forest).toEqual([]); + }); }); // ─── getAncestors(返回 TopologyNode[]) ─── it('getAncestors 返回祖先拓扑节点链', async () => { - const root = await tree.createRoot('根'); - const child = await tree.createChild({ parentId: root.id, label: '子' }); - const grandchild = await tree.createChild({ parentId: child.id, label: '孙' }); + const root = await tree.createSession({ label: '根' }); + const child = await tree.createSession({ parentId: root.id, label: '子' }); + const grandchild = await tree.createSession({ parentId: child.id, label: '孙' }); const ancestors = await tree.getAncestors(grandchild.id); expect(ancestors).toHaveLength(2); expect(ancestors[0]?.id).toBe(child.id); @@ -317,7 +333,7 @@ describe('SessionTreeImpl', () => { }); it('getAncestors 根节点无祖先', async () => { - const root = await tree.createRoot(); + const root = await tree.createSession(); const ancestors = await tree.getAncestors(root.id); expect(ancestors).toHaveLength(0); }); @@ -325,10 +341,10 @@ describe('SessionTreeImpl', () => { // ─── getSiblings(返回 TopologyNode[]) ─── it('getSiblings 返回兄弟拓扑节点', async () => { - const root = await tree.createRoot(); - const a = await tree.createChild({ parentId: root.id, label: 'A' }); - const b = await tree.createChild({ parentId: root.id, label: 'B' }); - const c = await tree.createChild({ parentId: root.id, label: 'C' }); + const root = await tree.createSession(); + const a = await tree.createSession({ parentId: root.id, label: 'A' }); + const b = await tree.createSession({ parentId: root.id, label: 'B' }); + const c = await tree.createSession({ parentId: root.id, label: 'C' }); const siblings = await tree.getSiblings(b.id); const siblingIds = siblings.map((s) => s.id).sort(); expect(siblingIds).toEqual([a.id, c.id].sort()); @@ -342,7 +358,7 @@ describe('SessionTreeImpl', () => { }); it('getSiblings 根节点无兄弟', async () => { - const root = await tree.createRoot(); + const root = await tree.createSession(); const siblings = await tree.getSiblings(root.id); expect(siblings).toHaveLength(0); }); @@ -350,8 +366,8 @@ describe('SessionTreeImpl', () => { // ─── archive ─── it('archive 归档不连带子节点', async () => { - const root = await tree.createRoot(); - const child = await tree.createChild({ parentId: root.id, label: '子' }); + const root = await tree.createSession(); + const child = await tree.createSession({ parentId: root.id, label: '子' }); await tree.archive(root.id); const archivedRoot = await tree.get(root.id); expect(archivedRoot?.status).toBe('archived'); @@ -362,9 +378,9 @@ describe('SessionTreeImpl', () => { // ─── addRef ─── it('addRef 正常创建引用', async () => { - const root = await tree.createRoot(); - const a = await tree.createChild({ parentId: root.id, label: 'A' }); - const b = await tree.createChild({ parentId: root.id, label: 'B' }); + const root = await tree.createSession(); + const a = await tree.createSession({ parentId: root.id, label: 'A' }); + const b = await tree.createSession({ parentId: root.id, label: 'B' }); await tree.addRef(a.id, b.id); // 通过 getNode 验证 refs(TopologyNode 包含 refs) const node = await tree.getNode(a.id); @@ -372,26 +388,26 @@ describe('SessionTreeImpl', () => { }); it('addRef 不能引用自己', async () => { - const root = await tree.createRoot(); + const root = await tree.createSession(); await expect(tree.addRef(root.id, root.id)).rejects.toThrow('不能引用自己'); }); it('addRef 不能引用直系祖先', async () => { - const root = await tree.createRoot(); - const child = await tree.createChild({ parentId: root.id, label: '子' }); + const root = await tree.createSession(); + const child = await tree.createSession({ parentId: root.id, label: '子' }); await expect(tree.addRef(child.id, root.id)).rejects.toThrow('不能引用直系祖先'); }); it('addRef 不能引用直系后代', async () => { - const root = await tree.createRoot(); - const child = await tree.createChild({ parentId: root.id, label: '子' }); + const root = await tree.createSession(); + const child = await tree.createSession({ parentId: root.id, label: '子' }); await expect(tree.addRef(root.id, child.id)).rejects.toThrow('不能引用直系后代'); }); it('addRef 重复引用幂等', async () => { - const root = await tree.createRoot(); - const a = await tree.createChild({ parentId: root.id, label: 'A' }); - const b = await tree.createChild({ parentId: root.id, label: 'B' }); + const root = await tree.createSession(); + const a = await tree.createSession({ parentId: root.id, label: 'A' }); + const b = await tree.createSession({ parentId: root.id, label: 'B' }); await tree.addRef(a.id, b.id); await tree.addRef(a.id, b.id); const node = await tree.getNode(a.id); @@ -401,7 +417,7 @@ describe('SessionTreeImpl', () => { // ─── updateMeta(返回 SessionMeta) ─── it('updateMeta 更新 label/turnCount 并返回 SessionMeta', async () => { - const root = await tree.createRoot(); + const root = await tree.createSession(); const updated = await tree.updateMeta(root.id, { label: '新名称', turnCount: 3, @@ -424,7 +440,7 @@ describe('SessionTreeImpl', () => { // ─── getConfig / putConfig(固化 SessionConfig 可序列化子集) ─── it('putConfig → getConfig 往返读取相同内容', async () => { - const root = await tree.createRoot(); + const root = await tree.createSession(); const config = { systemPrompt: '你是一个助手', skills: ['math', 'code'] }; await tree.putConfig(root.id, config); const read = await tree.getConfig(root.id); @@ -432,13 +448,13 @@ describe('SessionTreeImpl', () => { }); it('getConfig 在未写入配置时返回 null', async () => { - const root = await tree.createRoot(); + const root = await tree.createSession(); const read = await tree.getConfig(root.id); expect(read).toBeNull(); }); it('putConfig 覆盖已有配置', async () => { - const root = await tree.createRoot(); + const root = await tree.createSession(); await tree.putConfig(root.id, { systemPrompt: 'A', skills: ['a'] }); await tree.putConfig(root.id, { systemPrompt: 'B', skills: ['b', 'c'] }); const read = await tree.getConfig(root.id); @@ -446,7 +462,7 @@ describe('SessionTreeImpl', () => { }); it('putConfig 仅含 systemPrompt 的部分配置', async () => { - const root = await tree.createRoot(); + const root = await tree.createSession(); await tree.putConfig(root.id, { systemPrompt: '只有 prompt' }); const read = await tree.getConfig(root.id); expect(read).toEqual({ systemPrompt: '只有 prompt' }); @@ -454,7 +470,7 @@ describe('SessionTreeImpl', () => { }); it('putConfig 仅含 skills 的部分配置', async () => { - const root = await tree.createRoot(); + const root = await tree.createSession(); await tree.putConfig(root.id, { skills: ['only-skills'] }); const read = await tree.getConfig(root.id); expect(read).toEqual({ skills: ['only-skills'] }); @@ -462,14 +478,14 @@ describe('SessionTreeImpl', () => { }); it('putConfig 空对象也能存读', async () => { - const root = await tree.createRoot(); + const root = await tree.createSession(); await tree.putConfig(root.id, {}); const read = await tree.getConfig(root.id); expect(read).toEqual({}); }); it('putConfig 与 updateMeta 互不干扰', async () => { - const root = await tree.createRoot('初始'); + const root = await tree.createSession({ label: '初始' }); await tree.putConfig(root.id, { systemPrompt: '固化 prompt', skills: ['s'] }); // 更新 meta 不应影响 config await tree.updateMeta(root.id, { label: '新名称', turnCount: 5 }); diff --git a/packages/core/src/session/__tests__/split-guard.test.ts b/packages/core/src/session/__tests__/split-guard.test.ts index f07bd5f..a52b3b0 100644 --- a/packages/core/src/session/__tests__/split-guard.test.ts +++ b/packages/core/src/session/__tests__/split-guard.test.ts @@ -17,7 +17,7 @@ describe('SplitGuard — 拆分保护机制', () => { const fs = new NodeFileSystemAdapter(tmpDir); tree = new SessionTreeImpl(fs); guard = new SplitGuard(tree, { minTurns: 3, cooldownTurns: 5 }); - const root = await tree.createRoot('根'); + const root = await tree.createSession({ label: '根' }); rootId = root.id; }); @@ -102,7 +102,7 @@ describe('SplitGuard — 拆分保护机制', () => { }); it('不同 Session 的冷却期独立', async () => { - const child = await tree.createChild({ parentId: rootId, label: '子' }); + const child = await tree.createSession({ parentId: rootId, label: '子' }); await tree.updateMeta(rootId, { turnCount: 5 }); guard.recordSplit(rootId, 5); await tree.updateMeta(child.id, { turnCount: 5 }); diff --git a/packages/core/src/session/session-tree.ts b/packages/core/src/session/session-tree.ts index d6a9e64..a9fe9f9 100644 --- a/packages/core/src/session/session-tree.ts +++ b/packages/core/src/session/session-tree.ts @@ -87,15 +87,15 @@ function toTopologyNode(stored: StoredMeta): TopologyNode { /** * SessionTree 的默认实现 * - * 管理对话的树状空间结构,用 FileSystemAdapter 做持久化。 + * 管理对话的树状空间结构(森林),用 FileSystemAdapter 做持久化。 * 内部以 StoredMeta 统一存储,对外按 SessionMeta / TopologyNode 分离返回。 */ export class SessionTreeImpl implements SessionTree { /** - * 串行化父节点 RMW 的写锁。createChild / addRef 都需要先读父节点的 children/refs - * 数组、追加新元素、再整体写回;并发执行(如同一轮内多个 stello_create_session - * 工具调用)会因 last-write-wins 丢失先到的修改。这里用一条 Promise 链强制单线 - * 执行。fork/ref 不是吞吐路径,串行代价可忽略。 + * 串行化父节点 RMW 的写锁。createSession(带 parentId) / addRef 都需要先读父节点 + * 的 children/refs 数组、追加新元素、再整体写回;并发执行(如同一轮内多个 + * stello_create_session 工具调用)会因 last-write-wins 丢失先到的修改。这里用一 + * 条 Promise 链强制单线执行。fork/ref 不是吞吐路径,串行代价可忽略。 */ private writeLock: Promise = Promise.resolve(); @@ -109,53 +109,48 @@ export class SessionTreeImpl implements SessionTree { } /** - * 创建根 Session,初始化配套 .md 与 core.json + * 创建 Session 拓扑节点。 * - * 幂等:若 `'root'` 对应的 meta 已存在,直接返回现有 TopologyNode,不覆写任何数据。 - * Task 9 会完全重写该实现,支持真正的多 root 拓扑。 + * - `options.parentId` 为空:创建新 root(`parentId === null`,`depth === 0`), + * 多 root 合法;同时初始化 core.json(如果尚未存在) + * - 非空:挂在该节点下作为子节点,更新父的 children 列表 + * - 始终初始化 memory.md / scope.md / index.md */ - async createRoot(label = 'Root'): Promise { - const existing = await this.fs.readJSON(metaPath('root')); - if (existing !== null) { - return toTopologyNode(existing); - } - const ts = now(); - const stored: StoredMeta = { - id: 'root', - parentId: null, - children: [], - refs: [], - label, - index: 0, - status: 'active', - depth: 0, - turnCount: 0, - createdAt: ts, - updatedAt: ts, - lastActiveAt: ts, - }; - await this.fs.writeJSON(metaPath(stored.id), stored); - // 初始化三个 .md 内容文件 - await this.fs.writeFile(`sessions/${stored.id}/memory.md`, ''); - await this.fs.writeFile(`sessions/${stored.id}/scope.md`, ''); - await this.fs.writeFile(`sessions/${stored.id}/index.md`, ''); - // 初始化 core.json(如果不存在) - const coreExisting = await this.fs.readJSON('core.json'); - if (coreExisting === null) { - await this.fs.writeJSON('core.json', {}); - } - return toTopologyNode(stored); - } - - /** 创建子 Session,写入 meta.json 并更新父节点 children 列表 */ - async createChild(options: CreateSessionOptions): Promise { - if (!options.parentId) throw new Error('createChild 需要 parentId'); - const parentId = options.parentId; + async createSession(options: CreateSessionOptions = {}): Promise { return this.withWriteLock(async () => { - const parent = await this.requireStored(parentId); const ts = now(); + const id = randomUUID(); + + if (!options.parentId) { + const stored: StoredMeta = { + id, + parentId: null, + children: [], + refs: [], + label: options.label ?? 'Root', + index: 0, + status: 'active', + depth: 0, + turnCount: 0, + createdAt: ts, + updatedAt: ts, + lastActiveAt: ts, + }; + if (options.sourceSessionId !== undefined) { + stored.sourceSessionId = options.sourceSessionId; + } + await this.fs.writeJSON(metaPath(id), stored); + await this.initSessionFiles(id); + const coreExisting = await this.fs.readJSON('core.json'); + if (coreExisting === null) { + await this.fs.writeJSON('core.json', {}); + } + return toTopologyNode(stored); + } + + const parent = await this.requireStored(options.parentId); const stored: StoredMeta = { - id: randomUUID(), + id, parentId: parent.id, children: [], refs: [], @@ -171,35 +166,36 @@ export class SessionTreeImpl implements SessionTree { if (options.sourceSessionId !== undefined) { stored.sourceSessionId = options.sourceSessionId; } - // 写子 Session meta.json - await this.fs.writeJSON(metaPath(stored.id), stored); - // 初始化三个 .md 内容文件 - await this.fs.writeFile(`sessions/${stored.id}/memory.md`, ''); - await this.fs.writeFile(`sessions/${stored.id}/scope.md`, ''); - await this.fs.writeFile(`sessions/${stored.id}/index.md`, ''); - // 更新父的 children 列表 - parent.children.push(stored.id); + await this.fs.writeJSON(metaPath(id), stored); + await this.initSessionFiles(id); + parent.children.push(id); parent.updatedAt = now(); await this.fs.writeJSON(metaPath(parent.id), parent); return toTopologyNode(stored); }); } + /** 初始化 Session 的三个 .md 内容文件 */ + private async initSessionFiles(id: string): Promise { + await this.fs.writeFile(`sessions/${id}/memory.md`, ''); + await this.fs.writeFile(`sessions/${id}/scope.md`, ''); + await this.fs.writeFile(`sessions/${id}/index.md`, ''); + } + async get(id: string): Promise { const stored = await this.fs.readJSON(metaPath(id)); return stored ? toSessionMeta(stored) : null; } - async getRoot(): Promise { + async listAll(): Promise { const all = await this.listAllStored(); - const root = all.find((s) => s.parentId === null); - if (!root) throw new Error('根 Session 不存在'); - return toSessionMeta(root); + return all.map(toSessionMeta); } - async listAll(): Promise { + /** 列出所有 root(parentId === null) */ + async listRoots(): Promise { const all = await this.listAllStored(); - return all.map(toSessionMeta); + return all.filter((s) => s.parentId === null).map(toTopologyNode); } async archive(id: string): Promise { @@ -250,37 +246,9 @@ export class SessionTreeImpl implements SessionTree { return stored ? toTopologyNode(stored) : null; } - /** - * 统一的 Session 创建入口。 - * - 不传 parentId:调用 createRoot(Task 9 会改为支持多 root) - * - 传 parentId:调用 createChild - * - * 此方法是 Task 5 的适配层,最小满足新 SessionTree interface; - * Task 9 会重写底层使其原生支持森林结构。 - */ - async createSession(options: CreateSessionOptions = {}): Promise { - if (!options.parentId) { - return this.createRoot(options.label); - } - const childOptions: CreateSessionOptions = { - parentId: options.parentId, - label: options.label ?? 'Session', - }; - if (options.sourceSessionId !== undefined) { - childOptions.sourceSessionId = options.sourceSessionId; - } - return this.createChild(childOptions); - } - - /** 列出所有 root(parentId === null) */ - async listRoots(): Promise { - const all = await this.listAllStored(); - return all.filter((s) => s.parentId === null).map(toTopologyNode); - } - /** * 返回拓扑森林(多 root 数组)。 - * 当前实现仍是单 root 上限,但接口已升级为数组以匹配新 SessionTree。 + * 没有任何 root 时返回空数组。 */ async getTree(): Promise { const all = await this.listAllStored(); From c33dcc7a33fffb9ac5b227a2f0982f7e16246b7f Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 18:28:17 +0800 Subject: [PATCH 12/40] test(core): replace fork-from-main skipped tests with root-fork test --- .../engine/__tests__/stello-engine.test.ts | 120 +++++------------- 1 file changed, 31 insertions(+), 89 deletions(-) diff --git a/packages/core/src/engine/__tests__/stello-engine.test.ts b/packages/core/src/engine/__tests__/stello-engine.test.ts index 36d8e38..de5e291 100644 --- a/packages/core/src/engine/__tests__/stello-engine.test.ts +++ b/packages/core/src/engine/__tests__/stello-engine.test.ts @@ -476,112 +476,54 @@ describe('StelloEngineImpl', () => { }); }); - // FIXME: Task 10 will replace this with proper root-fork tests - describe.skip('forkSession from main session (issue #55)', () => { - it('从 main fork 时不读 getConfig,parent 层不参与合成链', async () => { - const getConfig = vi.fn().mockResolvedValue({ systemPrompt: 'main sys', skills: ['a'] }); + describe('forkSession from root session', () => { + it('从 root session fork 时正常读取 root 的 getConfig 并继承 systemPrompt', async () => { + const getConfig = vi.fn().mockResolvedValue({ systemPrompt: 'root sys' }); const putConfig = vi.fn().mockResolvedValue(undefined); - const createSession = vi.fn().mockResolvedValue({ - id: 'child-1', parentId: 'root', children: [], refs: [], - depth: 1, index: 0, label: 'UI', - }); - const sessionFork = vi.fn().mockResolvedValue({ - id: 'child-1', meta: { id: 'child-1', turnCount: 0, status: 'active' }, - turnCount: 0, send: vi.fn(), consolidate: vi.fn(), setTools: vi.fn(), - }); - - const engine = new StelloEngineImpl({ - session: { - id: 'root', - meta: { id: 'root', turnCount: 0, status: 'active' as const }, - turnCount: 0, send: vi.fn(), consolidate: vi.fn(), - messages: vi.fn().mockResolvedValue([]), - setTools: vi.fn(), - fork: sessionFork, - }, - sessions: { ...sessions, createSession, getConfig, putConfig } as unknown as SessionTree, - memory, skills, confirm, agent: {} as never, - lifecycle: { bootstrap: vi.fn(), afterTurn: vi.fn() }, - tools: { getToolDefinitions: vi.fn().mockReturnValue([]), executeTool: vi.fn() }, - }); - - await engine.forkSession({ label: 'UI' }); - - expect(getConfig).not.toHaveBeenCalled(); - }); - - it('从 main fork 时子 session 的 putConfig 不继承 main 的 systemPrompt/skills', async () => { - // 模拟宿主把 SerializableMainSessionConfig 存在同一 putConfig 槽位(issue #55 场景) - const getConfig = vi.fn().mockResolvedValue({ systemPrompt: 'main sys', skills: ['a'] }); - const putConfig = vi.fn().mockResolvedValue(undefined); - const createSession = vi.fn().mockResolvedValue({ - id: 'child-1', parentId: 'root', children: [], refs: [], - depth: 1, index: 0, label: 'UI', - }); - const sessionFork = vi.fn().mockResolvedValue({ - id: 'child-1', meta: { id: 'child-1', turnCount: 0, status: 'active' }, - turnCount: 0, send: vi.fn(), consolidate: vi.fn(), setTools: vi.fn(), - }); - - const engine = new StelloEngineImpl({ - session: { - id: 'root', - meta: { id: 'root', turnCount: 0, status: 'active' as const }, - turnCount: 0, send: vi.fn(), consolidate: vi.fn(), - messages: vi.fn().mockResolvedValue([]), - setTools: vi.fn(), - fork: sessionFork, - }, - sessions: { ...sessions, createSession, getConfig, putConfig } as unknown as SessionTree, - memory, skills, confirm, agent: {} as never, - lifecycle: { bootstrap: vi.fn(), afterTurn: vi.fn() }, - tools: { getToolDefinitions: vi.fn().mockReturnValue([]), executeTool: vi.fn() }, - }); - - await engine.forkSession({ label: 'UI' }); - - // 子 session 的 putConfig 要么未被调用(合成结果为空),要么不含 main 的值 - for (const [, cfg] of putConfig.mock.calls) { - expect(cfg.systemPrompt).toBeUndefined(); - expect(cfg.skills).toBeUndefined(); - } - // session.fork 传给子 session 的也不应包含 main 的 systemPrompt - expect(sessionFork).toHaveBeenCalledWith(expect.not.objectContaining({ - systemPrompt: 'main sys', - })); - }); - - it('从非 main session fork 时仍然正常读取 parent config', async () => { - const getConfig = vi.fn().mockResolvedValue({ systemPrompt: 'parent sys' }); - const createSession = vi.fn().mockResolvedValue({ - id: 'child-1', parentId: 's1', children: [], refs: [], - depth: 2, index: 0, label: 'UI', + const createSessionFn = vi.fn().mockResolvedValue({ + id: 'child-1', + parentId: 'root-id', + children: [], + refs: [], + depth: 1, + index: 0, + label: 'UI', }); const sessionFork = vi.fn().mockResolvedValue({ - id: 'child-1', meta: { id: 'child-1', turnCount: 0, status: 'active' }, - turnCount: 0, send: vi.fn(), consolidate: vi.fn(), setTools: vi.fn(), + id: 'child-1', + meta: { id: 'child-1', turnCount: 0, status: 'active' as const }, + turnCount: 0, + send: vi.fn(), + consolidate: vi.fn(), + setTools: vi.fn(), }); const engine = new StelloEngineImpl({ session: { - id: 's1', meta: { id: 's1', turnCount: 0, status: 'active' as const }, - turnCount: 0, send: vi.fn(), consolidate: vi.fn(), + id: 'root-id', + meta: { id: 'root-id', turnCount: 0, status: 'active' as const }, + turnCount: 0, + send: vi.fn(), + consolidate: vi.fn(), messages: vi.fn().mockResolvedValue([]), setTools: vi.fn(), fork: sessionFork, }, - sessions: { ...sessions, createSession, getConfig } as unknown as SessionTree, - memory, skills, confirm, agent: {} as never, + sessions: { ...sessions, createSession: createSessionFn, getConfig, putConfig } as unknown as SessionTree, + memory, + skills, + confirm, + agent: {} as never, lifecycle: { bootstrap: vi.fn(), afterTurn: vi.fn() }, tools: { getToolDefinitions: vi.fn().mockReturnValue([]), executeTool: vi.fn() }, }); await engine.forkSession({ label: 'UI' }); - expect(getConfig).toHaveBeenCalledWith('s1'); - expect(sessionFork).toHaveBeenCalledWith(expect.objectContaining({ - systemPrompt: 'parent sys', - })); + expect(getConfig).toHaveBeenCalledWith('root-id'); + expect(sessionFork).toHaveBeenCalledWith( + expect.objectContaining({ systemPrompt: 'root sys' }), + ); }); }); From 556eda7ad01055bc39cbe6ad193a84b9c325adfd Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 18:31:58 +0800 Subject: [PATCH 13/40] refactor(core): remove createMainSession/integrate from StelloAgent; add createSession --- .../src/agent/__tests__/stello-agent.test.ts | 177 ++++-------------- packages/core/src/agent/stello-agent.ts | 54 ++---- 2 files changed, 50 insertions(+), 181 deletions(-) diff --git a/packages/core/src/agent/__tests__/stello-agent.test.ts b/packages/core/src/agent/__tests__/stello-agent.test.ts index 682e4a5..60a84d6 100644 --- a/packages/core/src/agent/__tests__/stello-agent.test.ts +++ b/packages/core/src/agent/__tests__/stello-agent.test.ts @@ -327,20 +327,6 @@ describe('StelloAgent', () => { expect(agent.config.sessionDefaults?.skills).toEqual(['read_file']); }); - it('会保留 mainSessionConfig 独立配置(不参与 fork 合成链)', () => { - const consolidateFn = vi.fn(); - const agent = createStelloAgent({ - ...baseConfig(), - mainSessionConfig: { - systemPrompt: 'main prompt', - consolidateFn, - }, - }); - - expect(agent.config.mainSessionConfig?.systemPrompt).toBe('main prompt'); - expect(agent.config.mainSessionConfig?.consolidateFn).toBe(consolidateFn); - }); - it('updateConfig 可热更新 runtime 配置', async () => { vi.useFakeTimers(); @@ -382,24 +368,6 @@ describe('StelloAgent', () => { expect(runtimeSession.consolidate).toHaveBeenCalledTimes(1); }); - it('integrate 调用 mainSession.integrate', async () => { - const integrateFn = vi.fn().mockResolvedValue({ synthesis: 's', insights: [] }); - const mainSession = { integrate: integrateFn }; - const agent = createStelloAgent({ - ...baseConfig(), - session: { - mainSessionLoader: vi.fn().mockResolvedValue({ session: mainSession, config: null }), - }, - }); - await agent.integrate(); - expect(integrateFn).toHaveBeenCalledTimes(1); - }); - - it('integrate 未配置 mainSessionLoader 时抛错', async () => { - const agent = createStelloAgent(baseConfig()); - await expect(agent.integrate()).rejects.toThrow('No mainSessionLoader configured'); - }); - it('支持通过 session.sessionLoader 正式接入 Session 配置', async () => { const session = { meta: { @@ -461,135 +429,58 @@ describe('StelloAgent', () => { expect(result.turn.toolCallsExecuted).toBe(1); }); - describe('createMainSession', () => { - /** 构建带 createSession/putConfig/getConfig 能力的 sessions mock */ - function sessionsMock() { - const store = new Map(); - const createSession = vi.fn().mockImplementation(async (opts?: { label?: string }) => ({ - id: 'root', + describe('createSession', () => { + it('createSession 无 parentId 时建 root', async () => { + const createSession = vi.fn().mockResolvedValue({ + id: 'root-id', parentId: null, children: [], refs: [], depth: 0, index: 0, - label: opts?.label ?? 'Root', - })); - const putConfig = vi.fn().mockImplementation(async (id: string, config: unknown) => { - store.set(id, config); + label: 'My Root', }); - const getConfig = vi.fn().mockImplementation(async (id: string) => store.get(id) ?? null); - return { createSession, putConfig, getConfig, store }; - } - - it('createMainSession 返回根拓扑节点(指定 label)', async () => { - const sessions = sessionsMock(); const agent = createStelloAgent( - baseConfig({ - sessions: { - createSession: sessions.createSession, - putConfig: sessions.putConfig, - getConfig: sessions.getConfig, - }, - }), + baseConfig({ sessions: { createSession } as unknown as SessionTree }), ); - - const node = await agent.createMainSession({ label: 'Main' }); - - expect(sessions.createSession).toHaveBeenCalledWith({ label: 'Main' }); - expect(node.id).toBe('root'); + const node = await agent.createSession({ label: 'My Root' }); + expect(createSession).toHaveBeenCalledWith({ label: 'My Root' }); expect(node.parentId).toBeNull(); - expect(node.depth).toBe(0); - expect(node.label).toBe('Main'); }); - it('createMainSession 无 label 时走 createSession 默认值', async () => { - const sessions = sessionsMock(); + it('createSession 带 parentId 时挂在父下', async () => { + const createSession = vi.fn().mockResolvedValue({ + id: 'child-id', + parentId: 'root-id', + children: [], + refs: [], + depth: 1, + index: 0, + label: 'Child', + }); const agent = createStelloAgent( - baseConfig({ - sessions: { - createSession: sessions.createSession, - putConfig: sessions.putConfig, - getConfig: sessions.getConfig, - }, - }), + baseConfig({ sessions: { createSession } as unknown as SessionTree }), ); - - const node = await agent.createMainSession(); - - expect(sessions.createSession).toHaveBeenCalledWith({}); - expect(node.label).toBe('Root'); + const node = await agent.createSession({ parentId: 'root-id', label: 'Child' }); + expect(createSession).toHaveBeenCalledWith({ parentId: 'root-id', label: 'Child' }); + expect(node.parentId).toBe('root-id'); }); - it('createMainSession 将 mainSessionConfig 的可序列化字段写入 putConfig', async () => { - const sessions = sessionsMock(); - const agent = createStelloAgent({ - ...baseConfig({ - sessions: { - createSession: sessions.createSession, - putConfig: sessions.putConfig, - getConfig: sessions.getConfig, - }, - }), - mainSessionConfig: { - systemPrompt: 'P', - skills: ['a'], - }, - }); - - await agent.createMainSession({ label: 'Main' }); - - expect(sessions.putConfig).toHaveBeenCalledWith('root', { - systemPrompt: 'P', - skills: ['a'], - }); - expect(await agent.sessions.getConfig('root')).toEqual({ - systemPrompt: 'P', - skills: ['a'], - }); - }); - - it('createMainSession 剔除非可序列化字段(llm/consolidateFn 等)', async () => { - const sessions = sessionsMock(); - const dummyLlm = { complete: vi.fn() } as never; - const dummyFn = vi.fn(); - const agent = createStelloAgent({ - ...baseConfig({ - sessions: { - createSession: sessions.createSession, - putConfig: sessions.putConfig, - getConfig: sessions.getConfig, - }, - }), - mainSessionConfig: { - systemPrompt: 'P', - llm: dummyLlm, - consolidateFn: dummyFn, - }, + it('createSession 不传参数时调用 sessions.createSession({})', async () => { + const createSession = vi.fn().mockResolvedValue({ + id: 'r', + parentId: null, + children: [], + refs: [], + depth: 0, + index: 0, + label: 'Root', }); - - await agent.createMainSession({ label: 'Main' }); - - expect(sessions.putConfig).toHaveBeenCalledWith('root', { systemPrompt: 'P' }); - const stored = sessions.store.get('root') as Record; - expect(stored).not.toHaveProperty('llm'); - expect(stored).not.toHaveProperty('consolidateFn'); - }); - - it('createMainSession 无 mainSessionConfig 时写入空对象', async () => { - const sessions = sessionsMock(); const agent = createStelloAgent( - baseConfig({ - sessions: { - createSession: sessions.createSession, - putConfig: sessions.putConfig, - getConfig: sessions.getConfig, - }, - }), + baseConfig({ sessions: { createSession } as unknown as SessionTree }), ); - - await agent.createMainSession({ label: 'Main' }); - - expect(sessions.putConfig).toHaveBeenCalledWith('root', {}); + await agent.createSession(); + expect(createSession).toHaveBeenCalledWith({}); }); }); }); diff --git a/packages/core/src/agent/stello-agent.ts b/packages/core/src/agent/stello-agent.ts index a6228b4..be40e12 100644 --- a/packages/core/src/agent/stello-agent.ts +++ b/packages/core/src/agent/stello-agent.ts @@ -55,11 +55,6 @@ export interface StelloAgentSessionConfig { session: SessionCompatible; config: SerializableSessionConfig | null; }>; - /** 加载 MainSession 与其固化配置(可选,仅在需要 integration 时提供) */ - mainSessionLoader?: () => Promise<{ - session: { integrate(): Promise }; - config: SerializableSessionConfig | null; - } | null>; /** send() 结果序列化方式,默认 JSON 序列化 */ serializeSendResult?: (result: SessionCompatibleSendResult) => string; /** TurnRunner 用的 tool call parser,默认 sessionSendResultParser */ @@ -96,8 +91,6 @@ export interface StelloAgentConfig { memory: MemoryEngine; /** Regular session 的 agent 级默认配置,fork 合成链的最低优先级 */ sessionDefaults?: SessionConfig; - /** Main session 独立配置(不参与 fork 合成链) */ - mainSessionConfig?: SessionConfig; session?: StelloAgentSessionConfig; capabilities: StelloAgentCapabilitiesConfig; runtime?: StelloAgentRuntimeConfig; @@ -129,16 +122,6 @@ function resolveRuntimeResolver(config: StelloAgentConfig): SessionRuntimeResolv ); } -/** 从 SessionConfig 抽取 main session 可序列化子集 */ -function serializeMainSessionConfig( - config: SessionConfig | undefined, -): SerializableSessionConfig { - const result: SerializableSessionConfig = {}; - if (config?.systemPrompt !== undefined) result.systemPrompt = config.systemPrompt; - if (config?.skills !== undefined) result.skills = config.skills; - return result; -} - function resolveTurnRunner(config: StelloAgentConfig): TurnRunner | undefined { if (config.orchestration?.turnRunner) { return config.orchestration.turnRunner; @@ -210,14 +193,22 @@ export class StelloAgent { ); } - /** 创建 main session(根节点),使用 mainSessionConfig 固化其配置 */ - async createMainSession(options?: { label?: string }): Promise { - const createArgs: { label?: string } = {}; - if (options?.label !== undefined) createArgs.label = options.label; - const node = await this.sessions.createSession(createArgs); - const serialized = serializeMainSessionConfig(this.config.mainSessionConfig); - await this.sessions.putConfig(node.id, serialized); - return node; + /** + * 创建一个新的 Session 拓扑节点。 + * + * - `parentId` 为空:建 root(parentId === null) + * - 非空:挂在该节点下作为子节点(**不**继承父 Session 上下文 / 配置) + * + * 需要继承上下文(systemPrompt / L3 / 合成配置)应走 `forkSession`。 + */ + async createSession(options?: { + parentId?: string; + label?: string; + }): Promise { + const treeOptions: { parentId?: string; label?: string } = {}; + if (options?.parentId !== undefined) treeOptions.parentId = options.parentId; + if (options?.label !== undefined) treeOptions.label = options.label; + return this.sessions.createSession(treeOptions); } /** 进入指定 session 的整轮对话 */ @@ -286,19 +277,6 @@ export class StelloAgent { return this.orchestrator.consolidateSession(sessionId); } - /** 对 main session 执行 integration */ - async integrate(): Promise { - const mainSessionLoader = this.config.session?.mainSessionLoader; - if (!mainSessionLoader) { - throw new Error('No mainSessionLoader configured'); - } - const loaded = await mainSessionLoader(); - if (!loaded) { - throw new Error('MainSession not found'); - } - return loaded.session.integrate(); - } - /** 热更新运行时配置(仅支持值类型字段) */ updateConfig(patch: StelloAgentHotConfig): void { if (patch.runtime && 'updateRecyclePolicy' in this.runtimeManager) { From 5321929462813c990a6de14688d6bdefaae71ba9 Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 18:33:36 +0800 Subject: [PATCH 14/40] feat(core): add orchestrator-facing topology SDK on StelloAgent --- .../src/agent/__tests__/stello-agent.test.ts | 52 +++++++++++++++++++ packages/core/src/agent/stello-agent.ts | 28 +++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agent/__tests__/stello-agent.test.ts b/packages/core/src/agent/__tests__/stello-agent.test.ts index 60a84d6..cd2e91d 100644 --- a/packages/core/src/agent/__tests__/stello-agent.test.ts +++ b/packages/core/src/agent/__tests__/stello-agent.test.ts @@ -483,4 +483,56 @@ describe('StelloAgent', () => { expect(createSession).toHaveBeenCalledWith({}); }); }); + + describe('orchestrator-facing topology SDK', () => { + it('listSessions 代理 sessions.listAll', async () => { + const listAll = vi.fn().mockResolvedValue([ + { id: 'a', label: 'A', status: 'active', turnCount: 0, createdAt: '', updatedAt: '', lastActiveAt: '' }, + { id: 'b', label: 'B', status: 'archived', turnCount: 0, createdAt: '', updatedAt: '', lastActiveAt: '' }, + ]); + const agent = createStelloAgent( + baseConfig({ sessions: { listAll } as unknown as SessionTree }), + ); + expect(await agent.listSessions()).toHaveLength(2); + const activeOnly = await agent.listSessions({ status: 'active' }); + expect(activeOnly).toHaveLength(1); + expect(activeOnly[0]?.id).toBe('a'); + }); + + it('listRoots 代理 sessions.listRoots', async () => { + const listRoots = vi.fn().mockResolvedValue([ + { id: 'r1', parentId: null, children: [], refs: [], depth: 0, index: 0, label: 'R1' }, + ]); + const agent = createStelloAgent( + baseConfig({ sessions: { listRoots } as unknown as SessionTree }), + ); + const roots = await agent.listRoots(); + expect(roots).toHaveLength(1); + expect(roots[0]?.parentId).toBeNull(); + }); + + it('getTopology 代理 sessions.getTree 并返回森林', async () => { + const getTree = vi.fn().mockResolvedValue([ + { id: 'r1', label: 'R1', status: 'active', turnCount: 0, children: [] }, + { id: 'r2', label: 'R2', status: 'active', turnCount: 0, children: [] }, + ]); + const agent = createStelloAgent( + baseConfig({ sessions: { getTree } as unknown as SessionTree }), + ); + const forest = await agent.getTopology(); + expect(forest).toHaveLength(2); + expect(forest.map((n) => n.id).sort()).toEqual(['r1', 'r2']); + }); + + it('getTopologyNode 代理 sessions.getNode', async () => { + const getNode = vi.fn().mockResolvedValue({ + id: 'x', parentId: null, children: [], refs: [], depth: 0, index: 0, label: 'X', + }); + const agent = createStelloAgent( + baseConfig({ sessions: { getNode } as unknown as SessionTree }), + ); + const node = await agent.getTopologyNode('x'); + expect(node?.id).toBe('x'); + }); + }); }); diff --git a/packages/core/src/agent/stello-agent.ts b/packages/core/src/agent/stello-agent.ts index be40e12..2ef49d3 100644 --- a/packages/core/src/agent/stello-agent.ts +++ b/packages/core/src/agent/stello-agent.ts @@ -21,7 +21,7 @@ import { type SessionCompatible, type SessionCompatibleSendResult, } from '../adapters/session-runtime'; -import type { SessionTree, TopologyNode } from '../types/session'; +import type { SessionMeta, SessionTree, SessionTreeNode, TopologyNode } from '../types/session'; import type { MemoryEngine } from '../types/memory'; import type { ConfirmProtocol, SkillRouter } from '../types/lifecycle'; import type { EngineLifecycleAdapter, EngineToolRuntime } from '../engine/stello-engine'; @@ -211,6 +211,32 @@ export class StelloAgent { return this.sessions.createSession(treeOptions); } + /** + * 列出所有 Session(可按状态过滤)。 + * + * 这是 orchestrator-facing SDK 的拓扑入口之一,代理给 SessionTree.listAll。 + */ + async listSessions(filter?: { status?: 'active' | 'archived' }): Promise { + const all = await this.sessions.listAll(); + if (!filter || filter.status === undefined) return all; + return all.filter((s) => s.status === filter.status); + } + + /** 列出所有 root(parentId === null) */ + listRoots(): Promise { + return this.sessions.listRoots(); + } + + /** 获取完整拓扑(森林) */ + getTopology(): Promise { + return this.sessions.getTree(); + } + + /** 获取单个拓扑节点 */ + getTopologyNode(id: string): Promise { + return this.sessions.getNode(id); + } + /** 进入指定 session 的整轮对话 */ enterSession(sessionId: string): Promise { return this.orchestrator.enterSession(sessionId); From c935672d60ee329a18bc7163db0e368349619190 Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 18:36:33 +0800 Subject: [PATCH 15/40] feat(core): add storage injection and data-IO SDK on StelloAgent --- .../src/agent/__tests__/stello-agent.test.ts | 63 +++++++++++++ packages/core/src/agent/stello-agent.ts | 94 +++++++++++++++++++ packages/core/src/index.ts | 2 + 3 files changed, 159 insertions(+) diff --git a/packages/core/src/agent/__tests__/stello-agent.test.ts b/packages/core/src/agent/__tests__/stello-agent.test.ts index cd2e91d..32ffc86 100644 --- a/packages/core/src/agent/__tests__/stello-agent.test.ts +++ b/packages/core/src/agent/__tests__/stello-agent.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import type { SessionStorage } from '@stello-ai/session'; import type { SessionTree } from '../../types/session'; import type { MemoryEngine } from '../../types/memory'; import type { ConfirmProtocol, SkillRouter } from '../../types/lifecycle'; @@ -535,4 +536,66 @@ describe('StelloAgent', () => { expect(node?.id).toBe('x'); }); }); + + describe('orchestrator-facing data-IO SDK', () => { + function storageMock() { + return { + getMemory: vi.fn().mockResolvedValue('mem-x'), + putMemory: vi.fn().mockResolvedValue(undefined), + getInsight: vi.fn().mockResolvedValue('ins-x'), + putInsight: vi.fn().mockResolvedValue(undefined), + clearInsight: vi.fn().mockResolvedValue(undefined), + listRecords: vi.fn().mockResolvedValue([{ role: 'user', content: 'hi' }]), + } as unknown as SessionStorage; + } + + it('未注入 storage 时数据 IO 抛错', async () => { + const agent = createStelloAgent(baseConfig()); + await expect(agent.getSessionMetadata('x')).rejects.toThrow( + 'StelloAgent.getSessionMetadata 需要 StelloAgentConfig.storage', + ); + }); + + it('getSessionMetadata 聚合 memory + insight', async () => { + const storage = storageMock(); + const agent = createStelloAgent({ ...baseConfig(), storage }); + expect(await agent.getSessionMetadata('s1')).toEqual({ memory: 'mem-x', insight: 'ins-x' }); + }); + + it('listSessionDigests 走 sessions.listAll 并对每个 Session 取 memory/insight', async () => { + const storage = storageMock(); + const listAll = vi.fn().mockResolvedValue([ + { id: 'a', label: 'A', status: 'active', turnCount: 0, createdAt: '', updatedAt: '', lastActiveAt: '' }, + { id: 'b', label: 'B', status: 'archived', turnCount: 0, createdAt: '', updatedAt: '', lastActiveAt: '' }, + ]); + const agent = createStelloAgent({ + ...baseConfig({ sessions: { listAll } as unknown as SessionTree }), + storage, + }); + const digests = await agent.listSessionDigests({ status: 'active' }); + expect(digests).toEqual([ + { id: 'a', label: 'A', status: 'active', memory: 'mem-x', insight: 'ins-x' }, + ]); + }); + + it('listMessages 代理 storage.listRecords', async () => { + const storage = storageMock(); + const agent = createStelloAgent({ ...baseConfig(), storage }); + expect(await agent.listMessages('s1', { limit: 10 })).toEqual([ + { role: 'user', content: 'hi' }, + ]); + expect(storage.listRecords).toHaveBeenCalledWith('s1', { limit: 10 }); + }); + + it('putMemory / putInsight / clearInsight 代理 storage', async () => { + const storage = storageMock(); + const agent = createStelloAgent({ ...baseConfig(), storage }); + await agent.putMemory('s1', 'M'); + await agent.putInsight('s1', 'I'); + await agent.clearInsight('s1'); + expect(storage.putMemory).toHaveBeenCalledWith('s1', 'M'); + expect(storage.putInsight).toHaveBeenCalledWith('s1', 'I'); + expect(storage.clearInsight).toHaveBeenCalledWith('s1'); + }); + }); }); diff --git a/packages/core/src/agent/stello-agent.ts b/packages/core/src/agent/stello-agent.ts index 2ef49d3..08d2ab2 100644 --- a/packages/core/src/agent/stello-agent.ts +++ b/packages/core/src/agent/stello-agent.ts @@ -31,6 +31,9 @@ import type { SerializableSessionConfig, SessionConfig, } from '../types/session-config'; +import type { + SessionStorage, ListRecordsOptions, Message, +} from '@stello-ai/session'; /** Session 能力相关配置 */ export interface StelloAgentCapabilitiesConfig { @@ -89,6 +92,13 @@ export interface StelloAgentOrchestrationConfig { export interface StelloAgentConfig { sessions: SessionTree; memory: MemoryEngine; + /** + * Session 数据存储(L3 / system prompt / insight / memory)。 + * + * 用于 orchestrator-facing SDK(getSessionMetadata / listMessages / putMemory / ...)。 + * 应用层应保证 sessions(拓扑)与 storage(内容)指向同一份持久化后端。 + */ + storage?: SessionStorage; /** Regular session 的 agent 级默认配置,fork 合成链的最低优先级 */ sessionDefaults?: SessionConfig; session?: StelloAgentSessionConfig; @@ -97,6 +107,21 @@ export interface StelloAgentConfig { orchestration?: StelloAgentOrchestrationConfig; } +/** 单 Session 的外部数据视图(memory + insight 聚合) */ +export interface SessionMetadataView { + memory: string | null; + insight: string | null; +} + +/** Session digest:批量视图条目(取代旧 getAllSessionL2s) */ +export interface SessionDigest { + id: string; + label: string; + status: 'active' | 'archived'; + memory: string | null; + insight: string | null; +} + function resolveRuntimeResolver(config: StelloAgentConfig): SessionRuntimeResolver { if (config.runtime?.resolver) { @@ -155,6 +180,9 @@ export class StelloAgent { /** 暴露 MemoryEngine,方便调用方做数据读写 */ readonly memory: StelloAgentConfig['memory']; + /** 注入的数据存储;data-IO SDK 方法依赖该字段 */ + readonly storage?: SessionStorage; + /** 暴露 ForkProfileRegistry,供 tool 在运行时校验 profile 名称 */ get profiles(): ForkProfileRegistry | undefined { return this.config.capabilities.profiles; @@ -167,6 +195,7 @@ export class StelloAgent { this.config = config; this.sessions = config.sessions; this.memory = config.memory; + this.storage = config.storage; const engineFactory = new DefaultEngineFactory({ sessions: config.sessions, memory: config.memory, @@ -237,6 +266,71 @@ export class StelloAgent { return this.sessions.getNode(id); } + /** 读取单个 Session 的 memory / insight 视图 */ + async getSessionMetadata(id: string): Promise { + const storage = this.requireStorage('getSessionMetadata'); + const [memory, insight] = await Promise.all([ + storage.getMemory(id), + storage.getInsight(id), + ]); + return { memory, insight }; + } + + /** + * 列出所有 Session 的 digest(id / label / status / memory / insight)。 + * + * 取代旧 `MainStorage.getAllSessionL2s()`:调用方自行根据 memory 字段做 reflection。 + */ + async listSessionDigests(filter?: { status?: 'active' | 'archived' }): Promise { + const storage = this.requireStorage('listSessionDigests'); + const metas = await this.sessions.listAll(); + const filtered = filter?.status + ? metas.filter((m) => m.status === filter.status) + : metas; + return Promise.all( + filtered.map(async (m) => { + const [memory, insight] = await Promise.all([ + storage.getMemory(m.id), + storage.getInsight(m.id), + ]); + return { id: m.id, label: m.label, status: m.status, memory, insight }; + }), + ); + } + + /** 读取指定 Session 的 L3 消息 */ + listMessages(id: string, options?: ListRecordsOptions): Promise { + const storage = this.requireStorage('listMessages'); + return storage.listRecords(id, options); + } + + /** 写入指定 Session 的 memory(持久;每次 send 注入) */ + putMemory(id: string, content: string): Promise { + const storage = this.requireStorage('putMemory'); + return storage.putMemory(id, content); + } + + /** 写入指定 Session 的 insight(一次性;被 send 消费后清除) */ + putInsight(id: string, content: string): Promise { + const storage = this.requireStorage('putInsight'); + return storage.putInsight(id, content); + } + + /** 清除指定 Session 的 insight */ + clearInsight(id: string): Promise { + const storage = this.requireStorage('clearInsight'); + return storage.clearInsight(id); + } + + private requireStorage(method: string): SessionStorage { + if (!this.storage) { + throw new Error( + `StelloAgent.${method} 需要 StelloAgentConfig.storage;请在创建 agent 时注入 SessionStorage`, + ); + } + return this.storage; + } + /** 进入指定 session 的整轮对话 */ enterSession(sessionId: string): Promise { return this.orchestrator.enterSession(sessionId); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c189d04..9ce86ba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -121,6 +121,8 @@ export type { StelloAgentCapabilitiesConfig, StelloAgentRuntimeConfig, StelloAgentOrchestrationConfig, + SessionMetadataView, + SessionDigest, } from './agent/stello-agent'; // 内置 tool 工厂(builtin-tools redesign) From 4937898fc7899fa7fee21e9bf60410dd91165293 Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 18:41:09 +0800 Subject: [PATCH 16/40] =?UTF-8?q?chore(release):=20main-session=20decouple?= =?UTF-8?q?=20=E2=80=94=20session@0.8.0=20+=20core@0.10.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/CHANGELOG.md | 28 ++++++++++++++++++++++++++++ packages/core/package.json | 2 +- packages/core/src/index.ts | 2 +- packages/session/CHANGELOG.md | 12 ++++++++++++ packages/session/package.json | 2 +- 5 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 0cdc780..37e9b54 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,33 @@ # @stello-ai/core +## 0.10.0 + +### Breaking + +- 删除 `MAIN_SESSION_ID` 常量 +- 删除 `MainSessionConfig` / `SerializableMainSessionConfig` 类型 +- 删除 `MainSessionCompatible` / `SessionCompatibleIntegrateFn` 适配类型 +- 删除 `DEFAULT_INTEGRATE_PROMPT` 与 `createDefaultIntegrateFn`(外包给 orchestrator client) +- `SessionTree` 接口收敛:删除 `createRoot` / `createChild` / `getRoot`;新增 `createSession({ parentId?, label?, sourceSessionId? })` 唯一入口、`listRoots()`;`getTree()` 改返回 `SessionTreeNode[]` 森林(多 root 合法) +- `StelloAgent` 删除:`createMainSession()` / `integrate()` / `StelloAgentConfig.mainSessionConfig` / `StelloAgentSessionConfig.mainSessionLoader` +- `StelloAgent` 新增:`createSession({ parentId?, label? })` 唯一会话创建入口 +- Engine 在 `forkSession` 中删除 `sourceSessionId === MAIN_SESSION_ID` 跳过分支——root 配置正常被子 fork 继承 + +### Added — orchestrator-facing SDK + +- `StelloAgentConfig.storage?: SessionStorage`(顶层注入;data-IO SDK 依赖) +- `StelloAgent.listSessions(filter?)` / `listRoots()` / `getTopology()` / `getTopologyNode(id)` +- `StelloAgent.getSessionMetadata(id)` → `{ memory, insight }` +- `StelloAgent.listSessionDigests(filter?)` → 取代旧 `getAllSessionL2s` +- `StelloAgent.listMessages(id, opts?)` +- `StelloAgent.putMemory(id, content)` / `putInsight(id, content)` / `clearInsight(id)` + +### Out of Scope + +- demo / devtools / visualizer 暂不修,CHANGELOG 标注 breaking +- 旧 `'main'` 目录持久化数据不提供迁移工具 +- 批量原子写(`applyMetadataBatch`)与未来 context 槽位扩展留待下轮 + ## 0.9.0 ### Changed diff --git a/packages/core/package.json b/packages/core/package.json index a0ae835..a515ee0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@stello-ai/core", - "version": "0.9.0", + "version": "0.10.0", "description": "The first open-source conversation topology engine", "license": "Apache-2.0", "author": "Stello Contributors", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9ce86ba..51f2f0d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ /** Stello SDK 版本号 */ -export const VERSION = '0.5.2'; +export const VERSION = '0.10.0'; // 导出所有类型定义 export type { diff --git a/packages/session/CHANGELOG.md b/packages/session/CHANGELOG.md index d0f24f1..877859e 100644 --- a/packages/session/CHANGELOG.md +++ b/packages/session/CHANGELOG.md @@ -1,5 +1,17 @@ # @stello-ai/session +## 0.8.0 + +### Breaking + +- 删除 `MainSession` 接口、`createMainSession` / `loadMainSession` 工厂、`CreateMainSessionOptions` / `LoadMainSessionOptions` 选项 +- 删除 `MainStorage` 接口;其能力或合并入 `SessionStorage`(`listSessions`)或由 core 拓扑层接管(`putNode` 等);批量 L2 收集(`getAllSessionL2s`)转为 `StelloAgent.listSessionDigests` +- 删除 `IntegrateFn` / `IntegrateResult` / `ChildL2Summary` 类型 +- `SessionMeta` 删除 `role` / `tags` / `metadata` 三个字段;`SessionFilter.role` / `SessionFilter.tags` 同步删除 +- `ForkOptions` 删除 `tags` / `metadata` 两个字段 +- `assembleMainSessionContext` 函数删除——所有 Session 同构走 `assembleSessionContext` +- 应用域字段建议通过应用层 wrapper Session 承载;Stello 不再模型化业务字段 + ## 0.7.0 ### Added diff --git a/packages/session/package.json b/packages/session/package.json index 5066a0f..036b561 100644 --- a/packages/session/package.json +++ b/packages/session/package.json @@ -1,6 +1,6 @@ { "name": "@stello-ai/session", - "version": "0.7.2", + "version": "0.8.0", "description": "Session layer for Stello — conversation topology engine", "license": "Apache-2.0", "author": "Stello Contributors", From a1052278bb6479efdd97bb7c9ea1a6c8f8e9d423 Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 17 May 2026 18:53:28 +0800 Subject: [PATCH 17/40] docs: add main-session decouple migration guide --- docs/migration-main-session-decouple.md | 460 ++++++++++++++++++++++++ packages/core/CHANGELOG.md | 2 + packages/session/CHANGELOG.md | 2 + 3 files changed, 464 insertions(+) create mode 100644 docs/migration-main-session-decouple.md diff --git a/docs/migration-main-session-decouple.md b/docs/migration-main-session-decouple.md new file mode 100644 index 0000000..af744c8 --- /dev/null +++ b/docs/migration-main-session-decouple.md @@ -0,0 +1,460 @@ +# 迁移指南:Main Session 解耦 + +> 对应 spec:`docs/superpowers/specs/2026-05-16-decouple-main-session-design.md` +> 对应 plan:`docs/superpowers/plans/2026-05-17-decouple-main-session-plan.md` +> 合入版本:`@stello-ai/session@0.8.0` + `@stello-ai/core@0.10.0`(`refactor/decouple-main-session` 分支) + +--- + +## TL;DR + +**Stello 现在只有一种 Session。** 原来的 `MainSession` 概念(类型、工厂、独占的 `integrate()` 方法、`MainStorage` 接口、`MAIN_SESSION_ID` 常量、`mainSessionConfig` / `mainSessionLoader` 配置项)**全部删除**。 + +对话的起点 = "root session" = 普通 Session(`parentId === null` 的拓扑节点),与子 Session 同构。原 Main 承担的"跨 Session 综合 + 定向 insight 推送"职责完全外包给**外部 orchestrator client**(Claude Code / Codex / 用户自写脚本)。框架只暴露纯数据 IO SDK。 + +--- + +## 1. 心智模型的转变 + +| 维度 | 旧模型 | 新模型 | +|---|---|---| +| Session 类型数 | 2(`Session` + `MainSession`) | **1**(`Session`) | +| 上下文组装规则 | 两套(一套带 synthesis,一套带 insight) | **一套**(systemPrompt + identity + insight + memory + L3 + msg) | +| 跨 Session 综合 | 框架内 `MainSession.integrate()` 自动调度 IntegrateFn → 写 synthesis + 推 insights | **外包给 orchestrator**:调用方读 `listSessionDigests` → 自己做 reflection → 调 `putMemory` / `putInsight` 写回 | +| Root 唯一性 | `MAIN_SESSION_ID = 'main'`,每个拓扑有且仅有一个 root | **多 root 合法**:`parentId === null` 即 root,UUID 命名,可有多个 | +| Memory 槽位语义 | `Session.memory()` = L2(技能描述);`MainSession.synthesis()` = 全局综合(两者实现走不同存储方法) | **统一**:所有 Session 共用 `memory()`(持久;每次 send 注入)。"L2" / "synthesis" 是应用层标签,框架对内容无感知 | +| 存储接口数 | `SessionStorage` 子集 + `MainStorage` 超集(含 `getAllSessionL2s` / `putNode` / `getGlobal` 等) | **1 个 `SessionStorage`**。拓扑节点 CRUD 由 core `SessionTree` 持有。 | +| `SessionMeta` 字段 | `id / label / role / status / tags / metadata / createdAt / updatedAt` | `id / label / status / createdAt / updatedAt` | + +> **关键认知**:Stello 退化为"会话拓扑 + 单 Session 对话 + L2/L3 数据层"。它只负责承载内容;语义判断(L2 是什么格式、synthesis 怎么算、insight 推给谁)全部交给应用 orchestrator 自定义。 + +--- + +## 2. 决策要点(rationale) + +新模型背后的几个核心权衡,迁移代码时遇到边界情况可以回到这些原则上判断: + +1. **职责单一** — Stello 框架只做"装数据 + 跑 tool loop + 调度 consolidate"。任何"理解多个 Session 之间的关系并做出判断"的工作都属于 orchestrator 层。 +2. **零隐式 LLM 调用** — orchestrator-facing SDK 全是数据 IO。调用方知道每一次 LLM 调用都是自己显式发起的。 +3. **接口收敛优于双重派生** — `MainSession` 与 `Session` 当初就是 95% 重复代码 + 5% 不同的两个 slot。合并后 1124 行代码消失,行为无差异。 +4. **composition 优于 data extension** — Stello 不再为应用域字段(tags / metadata / conflicts / relations / priority)开口子。应用层应通过 wrapper Session(持有 Stello Session + 自己的 side-table)扩展业务字段。 +5. **多 root 自然支持** — 这既是删除 `MAIN_SESSION_ID` 的副产品,也让"一个 agent 同时进行多条独立对话"成为可能。 +6. **persistence 不强求迁移** — 旧 FileSystem 存储下 `'main'` 目录里的数据在新版本里"读不到 root",应用自行处理(详见 §5)。框架不提供自动迁移工具。 + +--- + +## 3. 删除清单(速查表) + +### `@stello-ai/session` 0.8.0 + +| 删除项 | 替代 | +|---|---| +| `MainSession` interface | 用 `Session` | +| `createMainSession` / `loadMainSession` | 用 `createSession` / `loadSession` | +| `CreateMainSessionOptions` / `LoadMainSessionOptions` | 用 `CreateSessionOptions` / `LoadSessionOptions` | +| `MainStorage` interface | 用 `SessionStorage`(已合并 `listSessions`) | +| `IntegrateFn` / `IntegrateResult` / `ChildL2Summary` | orchestrator 自定义 | +| `SessionMeta.role` / `.tags` / `.metadata` | 删除 — 用 wrapper Session 承载业务字段 | +| `SessionFilter.role` / `.tags` | 删除 | +| `ForkOptions.tags` / `.metadata` | 删除 | +| `assembleMainSessionContext` | 用 `assembleSessionContext`(唯一上下文组装函数) | +| `MainSession.synthesis()` | 用 `session.memory()`(已统一) | +| `MainStorage.getAllSessionL2s` | 用 `StelloAgent.listSessionDigests()` | +| `MainStorage.putNode` / `getChildren` / `removeNode` | 用 `SessionTree.createSession` / `getTree` | +| `MainStorage.getGlobal` / `putGlobal` | 删除(未被任何路径使用) | + +### `@stello-ai/core` 0.10.0 + +| 删除项 | 替代 | +|---|---| +| `MAIN_SESSION_ID = 'main'` | 删除 — root 由拓扑决定(`parentId === null`) | +| `SessionTree.createRoot(label?)` | 用 `createSession({ label? })` | +| `SessionTree.createChild(options)` | 用 `createSession({ parentId, label? })` | +| `SessionTree.getRoot()` | 用 `listRoots()`(多 root 合法) | +| `MainSessionConfig` / `SerializableMainSessionConfig` | 用 `SessionConfig` / `SerializableSessionConfig` | +| `MainSessionCompatible` / `SessionCompatibleIntegrateFn` | 删除 | +| `DEFAULT_INTEGRATE_PROMPT` / `createDefaultIntegrateFn` | orchestrator 自定义 | +| `StelloAgentConfig.mainSessionConfig` | 删除 | +| `StelloAgentSessionConfig.mainSessionLoader` | 删除 | +| `StelloAgent.createMainSession(opts?)` | 用 `agent.createSession({ parentId?, label? })` | +| `StelloAgent.integrate()` | 自己在 orchestrator 里实现(见 §6) | +| Engine 中 `sourceSessionId === MAIN_SESSION_ID` 跳过分支 | 删除 — root 配置正常被子 fork 继承 | + +### 新增(orchestrator-facing SDK) + +| 新增项 | 用途 | +|---|---| +| `StelloAgentConfig.storage?: SessionStorage` | 顶层注入数据存储;data-IO SDK 依赖此字段 | +| `agent.createSession({ parentId?, label? })` | 创建拓扑节点(root or child) | +| `agent.listSessions(filter?)` | 列出所有 Session(按状态过滤) | +| `agent.listRoots()` | 列出所有 `parentId === null` 节点 | +| `agent.getTopology()` | 完整森林(`SessionTreeNode[]`) | +| `agent.getTopologyNode(id)` | 单个拓扑节点 | +| `agent.getSessionMetadata(id)` | `{ memory, insight }` 视图 | +| `agent.listSessionDigests(filter?)` | 批量 `{ id, label, status, memory, insight }` — 取代 `getAllSessionL2s` | +| `agent.listMessages(id, opts?)` | 读取 L3 | +| `agent.putMemory(id, content)` | 写 memory | +| `agent.putInsight(id, content)` | 写 insight(一次性,被下次 `send` 消费) | +| `agent.clearInsight(id)` | 清 insight | + +--- + +## 4. 迁移配方 + +### 4.1 创建 root session(原 `createMainSession` / `agent.createMainSession()`) + +**旧写法 A**(Session 包直接调用): + +```ts +import { createMainSession } from '@stello-ai/session' + +const main = await createMainSession({ + storage, + llm, + label: 'Main Session', + systemPrompt: '...', + integrateFn: myIntegrate, +}) +``` + +**新写法 A**: + +```ts +import { createSession } from '@stello-ai/session' + +const root = await createSession({ + storage, + llm, + label: 'Main Session', + systemPrompt: '...', + // integrateFn 已删除:integration 由 orchestrator 在外部实现 +}) +``` + +**旧写法 B**(StelloAgent 工厂): + +```ts +const node = await agent.createMainSession({ label: 'Main' }) +``` + +**新写法 B**: + +```ts +const node = await agent.createSession({ label: 'Main' }) +// parentId 省略 ⇒ 新 root;非空 ⇒ 挂在该父节点下 +``` + +> 注意:新版 `createSession` 默认**不**继承父 Session 上下文。需要继承走 `agent.forkSession(parentId, opts)`。 + +### 4.2 读取所有 Session 的 L2 / synthesis 数据(原 `getAllSessionL2s`) + +**旧写法**: + +```ts +const summaries = await mainStorage.getAllSessionL2s() +// → ChildL2Summary[]: { sessionId, label, l2 } +``` + +**新写法**(要求 `agent.storage` 已注入): + +```ts +const digests = await agent.listSessionDigests({ status: 'active' }) +// → SessionDigest[]: { id, label, status, memory, insight } +``` + +差异: +- `id` 替代 `sessionId` +- `memory` 替代 `l2`(语义一致,仅命名收敛) +- 额外提供 `status` 与 `insight`,方便 orchestrator 复用同一份 digest + +### 4.3 自己实现 integrate 循环(原 `MainSession.integrate()`) + +旧框架内部逻辑大致是: + +``` +getAllSessionL2s() → IntegrateFn(children, currentSynthesis) → { synthesis, insights[] } +→ transaction { putMemory(rootId, synthesis); insights.forEach(putInsight) } +``` + +迁到 orchestrator 后,用 SDK 拼一个等价循环: + +```ts +async function runIntegrate( + agent: StelloAgent, + rootId: string, + llm: LLMAdapter, +) { + // 1. 收集所有 Session 摘要 + const digests = await agent.listSessionDigests({ status: 'active' }) + const children = digests.filter((d) => d.id !== rootId) + + // 2. 读取当前 root 的 memory(== 旧 synthesis) + const { memory: currentSynthesis } = await agent.getSessionMetadata(rootId) + + // 3. 调用你自己的 IntegrateFn —— 现在是普通函数,框架不感知 + const prompt = buildIntegratePrompt(children, currentSynthesis) + const raw = await llm.complete([ + { role: 'system', content: prompt }, + { role: 'user', content: serialize(children) }, + ]) + const result = JSON.parse(raw.content ?? '{}') as { + synthesis: string + insights: Array<{ sessionId: string; content: string }> + } + + // 4. 写回 — 不再走 storage.transaction,多个写操作可并发也可串行 + await agent.putMemory(rootId, result.synthesis) + await Promise.all( + result.insights + .filter((i) => children.some((c) => c.id === i.sessionId)) + .map((i) => agent.putInsight(i.sessionId, i.content)), + ) +} +``` + +**与旧实现的语义差异**: +- 旧版自动过滤无效 `sessionId`(不存在 / 已归档的子 session);新版需要你自己做(上例的 `.filter`)。 +- 旧版 `transaction` 保证 synthesis + insights 原子写;新版默认非原子。若需要原子性,自己用 `agent.storage.transaction(...)` 包裹。 +- 旧版会强制把 `currentSynthesis` 与 `children` 传给 IntegrateFn;新版完全由调用方决定输入。 + +> 参考实现可以从 `@stello-ai/core@0.9` 的 `createDefaultIntegrateFn` 拷出(已删除,但 git 历史里仍能找到,commit `f89a6a4` 之前的 `packages/core/src/llm/defaults.ts`)。 + +### 4.4 读取拓扑树(原 `MainStorage.getChildren` / `SessionTree.getRoot` / `getTree`) + +**旧写法**: + +```ts +const rootMeta = await sessionTree.getRoot() +const treeNode = await sessionTree.getTree() // SessionTreeNode(单 root) +const children = await mainStorage.getChildren(parentId) +``` + +**新写法**: + +```ts +// 多 root 合法 — 用 listRoots +const roots = await agent.listRoots() // TopologyNode[] +const forest = await agent.getTopology() // SessionTreeNode[] +const node = await agent.getTopologyNode(id) // TopologyNode | null +// 子节点:直接读 TopologyNode.children(id 列表),再 getTopologyNode 逐个解析 +``` + +注意:`getTree()` 返回值从 `Promise` 变成 `Promise`。所有调用点都需要适配数组。 + +### 4.5 写 memory / insight(原 `MainSession.integrate` 内部 / `setInsight`) + +orchestrator 现在直接调 SDK 方法,无需经过 Session 实例: + +```ts +await agent.putMemory(sessionId, '综合认知文本') +await agent.putInsight(childId, '给这个子 session 的建议') +// send 消费 insight 后,框架内部自动 clearInsight;调用方一般不需要主动清 +await agent.clearInsight(childId) // 仅在需要"撤回"未消费 insight 时用 +``` + +### 4.6 配置 StelloAgent(删除 mainSessionConfig / mainSessionLoader) + +**旧写法**: + +```ts +createStelloAgent({ + sessions, + memory, + sessionDefaults: { ... }, + mainSessionConfig: { + systemPrompt: 'main system prompt', + integrateFn: createDefaultIntegrateFn(...), + llm: mainLLM, + }, + session: { + sessionLoader: ..., + mainSessionLoader: async () => ({ session: mainSession, config: null }), + }, + capabilities: { ... }, +}) +``` + +**新写法**: + +```ts +createStelloAgent({ + sessions, + memory, + storage, // 新:data-IO SDK 依赖 + sessionDefaults: { ... }, // root 也吃这套默认配置 + session: { + sessionLoader: ..., // 仅保留 sessionLoader;mainSessionLoader 已删 + }, + capabilities: { ... }, +}) +``` + +整个 `mainSessionConfig` 和 `mainSessionLoader` 字段在新版本里**不存在**,传了会触发 TS 报错。 + +如果你之前在 `mainSessionConfig` 里放了 root 专属的 `systemPrompt` 或 `skills`,现在改在 root 节点的 `SerializableSessionConfig` 里写(通过 `agent.sessions.putConfig(rootId, ...)`),或者作为 `sessionDefaults` 的一部分(所有新 Session 都吃)。 + +### 4.7 持久化文件迁移(旧 `'main'` 目录) + +旧 `FileSystem` 存储 layout: + +``` +sessions/ + main/ + meta.json (role: 'main') + memory.md + scope.md + index.md + /... + /... +``` + +新版本 `SessionTreeImpl.createSession()` 总是生成 `randomUUID()` 作为 id,所以"main"目录里的 root 数据**读不到**(`listRoots()` 不会发现它,因为 `listAllStored` 走 `listDirs('sessions')` 但 `getRoot` 已删除)。 + +**应用层迁移方案**(如果有线上数据需要保留): + +```ts +// 启动脚本里跑一次 +import { promises as fs } from 'node:fs' +import { randomUUID } from 'node:crypto' + +async function migrateLegacyMainDir(rootDir: string) { + const legacyPath = `${rootDir}/sessions/main` + if (!(await pathExists(legacyPath))) return + + const metaJson = JSON.parse(await fs.readFile(`${legacyPath}/meta.json`, 'utf8')) + const newId = randomUUID() + // 新 meta:去掉 role,保留 label/status/timestamps + const newMeta = { + id: newId, + parentId: null, + children: metaJson.children ?? [], + refs: metaJson.refs ?? [], + label: metaJson.label, + index: 0, + status: metaJson.status, + depth: 0, + turnCount: metaJson.turnCount, + createdAt: metaJson.createdAt, + updatedAt: metaJson.updatedAt, + lastActiveAt: metaJson.lastActiveAt, + } + await fs.mkdir(`${rootDir}/sessions/${newId}`, { recursive: true }) + await fs.writeFile( + `${rootDir}/sessions/${newId}/meta.json`, + JSON.stringify(newMeta, null, 2), + ) + // 复制 .md 内容文件 + for (const file of ['memory.md', 'scope.md', 'index.md']) { + await fs.copyFile(`${legacyPath}/${file}`, `${rootDir}/sessions/${newId}/${file}`) + } + // 更新所有以 'main' 为 parentId 的子节点 + const dirs = await fs.readdir(`${rootDir}/sessions`) + for (const dir of dirs) { + if (dir === 'main' || dir === newId) continue + const childMetaPath = `${rootDir}/sessions/${dir}/meta.json` + const childMeta = JSON.parse(await fs.readFile(childMetaPath, 'utf8')) + if (childMeta.parentId === 'main') { + childMeta.parentId = newId + await fs.writeFile(childMetaPath, JSON.stringify(childMeta, null, 2)) + } + } + // 删除老的 main 目录 + await fs.rm(legacyPath, { recursive: true }) +} +``` + +框架不提供这个工具——它是应用层一次性操作,不该污染 SDK API。 + +--- + +## 5. 兼容性 / 升级路径 + +- 推荐**同时升级**两个包到对齐版本(session@0.8 + core@0.10)。任何混搭组合都不工作。 +- 不提供 deprecated alias。所有 break 是硬 break,编辑器立即报错;不会出现"运行时悄悄失败"。 +- demo / devtools / visualizer **本次未同步更新**。如果你在用它们,需要按本指南手动迁移(或暂停升级)。 +- 上线步骤建议: + 1. `pnpm up` 升级依赖 + 2. 跑 `pnpm typecheck`——所有 break 都是编译错误,对照本文档逐个改 + 3. 跑测试套件 + 4. 如有持久化数据,按 §4.7 跑一次性迁移脚本 + 5. 重新部署 + +--- + +## 6. 完整 orchestrator 重建示例 + +把"反思 + 推送 insight"的最小循环写在你自己的 orchestrator 层里: + +```ts +import { createStelloAgent, type StelloAgent } from '@stello-ai/core' +import { createSession, createClaude, InMemoryStorageAdapter } from '@stello-ai/session' + +// 1. 装配 +const storage = new InMemoryStorageAdapter() +const llm = createClaude({ model: 'claude-sonnet-4-6', apiKey: process.env.ANTHROPIC_API_KEY! }) +const agent = createStelloAgent({ + sessions: mySessionTreeImpl(storage), // 你自己的 SessionTree 实现,或 SessionTreeImpl(fs) + memory: myMemoryEngine(), + storage, // 新增 — data-IO SDK 依赖 + session: { + sessionLoader: async (id) => { + const session = await loadSession(id, { storage, llm }) + if (!session) throw new Error(`Session not found: ${id}`) + return { session, config: null } + }, + }, + capabilities: { lifecycle, tools, skills, confirm }, +}) + +// 2. 创建 root +const root = await agent.createSession({ label: 'Mission Control' }) + +// 3. 跑几轮对话 / fork 几个子 session +await agent.enterSession(root.id) +await agent.turn(root.id, '帮我拆个子任务出来') +// ... 用户和 agent 来回几轮 ... + +// 4. orchestrator 自己跑 reflection +async function reflect() { + const digests = await agent.listSessionDigests({ status: 'active' }) + const { memory: synthesis } = await agent.getSessionMetadata(root.id) + const children = digests.filter((d) => d.id !== root.id) + + // 你的 IntegrateFn —— 普通 LLM 调用,框架不感知 + const prompt = `当前综合: ${synthesis ?? '(无)'}\n\n子会话:\n${children + .map((c) => `[${c.id}] ${c.label}: ${c.memory ?? '(空)'}`) + .join('\n')}\n\n请输出 JSON: { "synthesis": "...", "insights": [{ "sessionId": "...", "content": "..." }] }` + + const result = JSON.parse( + (await llm.complete([{ role: 'user', content: prompt }])).content ?? '{}', + ) + + // 写回 + await agent.putMemory(root.id, result.synthesis) + for (const { sessionId, content } of result.insights) { + if (children.some((c) => c.id === sessionId)) { + await agent.putInsight(sessionId, content) + } + } +} + +// 5. 在你认为合适的时机触发 +setInterval(reflect, 60_000) // 例:每分钟 +// 或挂在 agent hooks 上,例如 onRoundEnd +``` + +这就是原来框架替你做的事情。**唯一的变化是它现在在你的代码里,你能完全控制 prompt、调度时机、原子性、错误处理、模型 tier 选择**。 + +--- + +## 7. 下轮(暂未实施) + +以下条目在本次重构中**有意未做**,留待下一轮专门处理(spec §7.2 / §7.5): + +- **批量原子写 API**(`applyMetadataBatch` 类):当前需要多次调用 `putMemory` / `putInsight`,非原子。 +- **未来 context 字段扩展**:除 memory / insight 外的新 prompt 槽位(例如 agent-shared memory index)。 +- **StelloAgent 级共享 memory**(Claude Code auto-memory 路线)。 +- **持久化数据自动迁移工具**:见 §4.7,自行处理。 +- **demo / devtools / visualizer** 跟进升级。 + +如果你的迁移卡在这些条目上,开 issue 单独讨论。 diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 37e9b54..d915adc 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.10.0 +> **迁移指南**:[`docs/migration-main-session-decouple.md`](../../docs/migration-main-session-decouple.md) 含心智模型转变、删除清单、迁移配方与 orchestrator 重建示例。 + ### Breaking - 删除 `MAIN_SESSION_ID` 常量 diff --git a/packages/session/CHANGELOG.md b/packages/session/CHANGELOG.md index 877859e..4dfc9c6 100644 --- a/packages/session/CHANGELOG.md +++ b/packages/session/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.8.0 +> **迁移指南**:[`docs/migration-main-session-decouple.md`](../../docs/migration-main-session-decouple.md) 含心智模型转变、删除清单、迁移配方与 orchestrator 重建示例。 + ### Breaking - 删除 `MainSession` 接口、`createMainSession` / `loadMainSession` 工厂、`CreateMainSessionOptions` / `LoadMainSessionOptions` 选项 From 3337f2086f56e5167ac71cd31e73b6da2c57afdc Mon Sep 17 00:00:00 2001 From: uchouT Date: Mon, 18 May 2026 17:43:46 +0800 Subject: [PATCH 18/40] docs: update docs about this refactor --- .agents/skills/engine-design/SKILL.md | 4 +- .agents/skills/fork-design/SKILL.md | 6 +- .agents/skills/llm-call-sites/SKILL.md | 81 ++-- .agents/skills/scheduler-design/SKILL.md | 38 +- .agents/skills/server-design/SKILL.md | 6 +- .agents/skills/server-storage/SKILL.md | 13 +- .agents/skills/session-usage/SKILL.md | 84 +++-- .agents/skills/stello-agent-creation/SKILL.md | 329 +++++++---------- .agents/skills/stello-agent-usage/SKILL.md | 349 +++++++----------- .agents/skills/stello-usage/SKILL.md | 24 +- .agents/skills/storage-design/SKILL.md | 110 +++--- CLAUDE.md | 178 +++++---- README.md | 88 +++-- README_EN.md | 90 +++-- docs/migration-unified-session-config.md | 331 ----------------- 15 files changed, 663 insertions(+), 1068 deletions(-) delete mode 100644 docs/migration-unified-session-config.md diff --git a/.agents/skills/engine-design/SKILL.md b/.agents/skills/engine-design/SKILL.md index d77cdff..fe8a538 100644 --- a/.agents/skills/engine-design/SKILL.md +++ b/.agents/skills/engine-design/SKILL.md @@ -21,7 +21,7 @@ Engine 不感知树结构,不知道其他 Session 的存在,**也不感知 **做**:tool call 循环、consolidate() 执行、hooks fire-and-forget、生命周期边界(enter/leave/archive/fork)、fork 编排(拓扑 + session 创建)、内置 tool 注册与执行(通过 CompositeToolRuntime 统一调度)、error 事件 emit -**不做**:调度时机判断(由 Factory hook 内联)、持有 MainSession、Session 切换检测(Orchestrator)、多 Session 管理 +**不做**:调度时机判断(由 Factory hook 内联)、持有跨 Session 状态(全局反思层由应用层在 StelloAgent 之外实现)、Session 切换检测(Orchestrator)、多 Session 管理 --- @@ -41,7 +41,7 @@ fork 选项中的 `consolidateFn` 和 `compressFn` 遵循继承链:fork 时指 ### Consolidation 触发内联到 Factory hook -Engine 不持有 Scheduler 或 MainSession。Consolidation 触发逻辑(如 `consolidateEveryNTurns`)由 Factory 构建闭包注入 EngineHooks。Engine 在事件点 fire-and-forget 调用 hooks,不知道背后有调度。 +Engine 不持有 Scheduler,也不感知"全局反思"概念。Consolidation 触发逻辑(如 `consolidateEveryNTurns`)由 Factory 构建闭包注入 EngineHooks。Engine 在事件点 fire-and-forget 调用 hooks,不知道背后有调度。 ### turn() 返回值 diff --git a/.agents/skills/fork-design/SKILL.md b/.agents/skills/fork-design/SKILL.md index 1f23487..1cefba3 100644 --- a/.agents/skills/fork-design/SKILL.md +++ b/.agents/skills/fork-design/SKILL.md @@ -145,8 +145,8 @@ sessionDefaults → parent(固化 config) → profile → forkOptions | 场景 | 合成链贡献 | |------|----------| -| 从 main session fork | parent 层 = undefined(main 不参与合成链) | -| 从 regular session fork | parent 层 = 该 session 的 `SerializableSessionConfig`(只有 systemPrompt/skills) | +| 从 root session fork | parent 层 = root 的 `SerializableSessionConfig`(root 是普通 session,正常参与合成链) | +| 从非 root session fork | parent 层 = 该 session 的 `SerializableSessionConfig`(只有 systemPrompt/skills) | | 无 profile 的普通 fork | profile 层 = undefined | | Profile + options 都提供 llm | 结果取 options 的 llm | | Profile 提供 llm,options 不提供 | 结果取 profile 的 llm | @@ -267,5 +267,5 @@ LLM 在 prepend 模式下可通过 `systemPrompt` 参数补充具体任务约束 3. **undefined 不覆盖** — 保证"某层不传"等价于"使用下层值"的直觉 4. **skills 显式 `[]` 能生效** — 与 undefined 区分,让"禁用"成为可表达的意图 5. **只固化 systemPrompt + skills** — 可序列化字段有限,其余字段每次 fork 现场合成 -6. **从 main session fork 不继承 main 的配置** — main 不参与 regular session 的合成链 +6. **root 是普通 session** — root 的固化 systemPrompt/skills 通过 parent 层正常进入子 session 的合成链,没有任何特殊豁免 7. **topologyParentId 与 sourceSessionId 分离** — 编排层的拓扑策略和上下文继承是两个独立维度 diff --git a/.agents/skills/llm-call-sites/SKILL.md b/.agents/skills/llm-call-sites/SKILL.md index 7a4d7ad..1c1883b 100644 --- a/.agents/skills/llm-call-sites/SKILL.md +++ b/.agents/skills/llm-call-sites/SKILL.md @@ -1,48 +1,30 @@ --- name: llm-call-sites -description: Stello 框架内所有 LLM 调用位置的消息结构速查。覆盖 MainSession 对话、普通 Session 对话、compress、consolidate、integrate。 +description: Stello 框架内所有 LLM 调用位置的消息结构速查。覆盖 Session 对话、compress、consolidate;应用层 reflection 调用由 orchestrator 自行决定。 --- # LLM 调用消息结构 -Stello 里所有 LLM 调用的 `messages` 参数构成。 +Stello 里所有 LLM 调用的 `messages` 参数构成。Session 同构——root 与 child 走同一套上下文组装规则,差异只在 `meta.label`。 --- -## 1. MainSession 对话 +## 1. Session 对话 ``` [ - { role: 'system', content: systemPrompt }, // 若非空 - { role: 'system', content: synthesis }, // 若非空(始终注入) - { role: 'system', content: compressSummary }, // 仅当触发自动压缩 - ...recentL3History, // user / assistant / tool + { role: 'system', content: systemPrompt }, // 可能含 块;若非空 + { role: 'system', content: }, // 若 meta.label 非空 + { role: 'system', content: insight }, // 若非空,消费后清除 + { role: 'system', content: compressSummary }, // 仅当触发自动压缩 + ...recentL3History, // user / assistant / tool { role: 'user', content: userInput }, ] ``` `tools` 经 `llm.complete(messages, { tools })` 第二参数传入,不进 messages。 -MainSession **不**注入 ``(只对普通 session 注入身份标签)。 - ---- - -## 2. 普通 Session 对话 - -``` -[ - { role: 'system', content: systemPrompt }, // 可能含 块 - { role: 'system', content: },// 若 meta.label 非空(子 session 总是非空) - { role: 'system', content: insight }, // 若非空,消费后清除 - { role: 'system', content: compressSummary }, // 仅当触发自动压缩 - ...recentL3History, - { role: 'user', content: userInput }, -] -``` - -与 MainSession 的差异:第二槽位是 `insight`(一次性)而非 `synthesis`;此外多一条 `` 注入在 systemPrompt 之后。 - -`` 形态: +`` 形态(label 缺省则该消息不注入): ``` @@ -50,7 +32,7 @@ MainSession **不**注入 ``(只对普通 session 注入身 ``` -label 改名(`updateMeta({ label })`)后下次 send 自动同步,无需重写持久化的 systemPrompt。 +label 改名后下次 send 自动同步,无需重写持久化的 systemPrompt。 `systemPrompt` 在 fork-compress 场景形态: @@ -62,9 +44,11 @@ label 改名(`updateMeta({ label })`)后下次 send 自动同步,无需重 ``` +所有 Session 同构走这套规则。Root 也是普通 Session,差异只在 `meta.label`。如需在 root 上注入"全局综合",应用层把综合结果通过 `putInsight(rootId, content)` 一次性写入即可。`memory` 槽位**不进入** send() 上下文。 + --- -## 3. Compress +## 2. Compress ``` [ @@ -82,7 +66,7 @@ label 改名(`updateMeta({ label })`)后下次 send 自动同步,无需重 --- -## 4. Consolidate(L3→L2) +## 3. Consolidate(L3 → memory) ``` [ @@ -95,32 +79,21 @@ label 改名(`updateMeta({ label })`)后下次 send 自动同步,无需重 ] ``` -- `currentMemory` = 本 session 当前 L2 +- `currentMemory` = 本 session 当前 memory 槽位 - `messages` = 本 session 全量 L3 输出:100-150 字摘要,写回本 session memory 槽位。 --- -## 5. Integrate(所有子 L2 → synthesis + insights) +## 4. Reflection(应用层自行实现) -``` -[ - { role: 'system', content: INTEGRATE_PROMPT }, - { role: 'system', content: }, // 若传入非空 roleContext - { role: 'user', content: [ - currentSynthesis ? `当前综合:\n${currentSynthesis}` : null, - `子 Session 摘要:\n` + children.map(c => - `- [sessionId=${c.sessionId}] ${c.label}: ${c.l2}` - ).join('\n'), - ].filter(Boolean).join('\n\n') }, -] -``` +跨 Session 的"全 memory → 综合 → 定向 insight"循环由应用层自行调用任意 LLM 完成: -- `currentSynthesis` = main 当前 synthesis -- `children` = 扁平收集所有子 session 的 `{ sessionId, label, l2 }` +- 输入:`agent.listSessionDigests({ status: 'active' })` —— `{ id, label, memory, insight }[]` +- 输出:派生 per-target `insight`,通过 `agent.putInsight(targetId, content)` 写回 -输出:JSON `{ synthesis, insights: [{ sessionId, content }] }`。 +应用层完全掌控 prompt 形态、调用频率、LLM tier。详见 stello-agent-creation §7 与 stello-agent-usage §6.4。 --- @@ -128,16 +101,18 @@ label 改名(`updateMeta({ label })`)后下次 send 自动同步,无需重 | Tag | 调用路径 | 数据来源 | 注入位置 | |-----|---------|---------|---------| -| `` | 普通 Session 对话(仅 fork-compress 场景) | 父 session 压缩摘要 | 合成进 systemPrompt 字段 | -| `` | 普通 Session 对话 | `SessionMeta.label`(stello 一等字段) | systemPrompt 之后 | -| `` | Compress / Consolidate / Integrate | `DefaultFnOptions.roleContext`(应用层传入) | 任务 prompt 之后、user content 之前 | +| `` | Session 对话(仅 fork-compress 场景) | 父 session 压缩摘要 | 合成进 systemPrompt 字段 | +| `` | Session 对话 | `SessionMeta.label` | systemPrompt 之后 | +| `` | Compress / Consolidate | `DefaultFnOptions.roleContext`(应用层传入) | 任务 prompt 之后、user content 之前 | + +--- ## 共性 -| 维度 | 对话类(1、2) | 提炼类(3、4、5) | -|------|--------------|------------------| +| 维度 | 对话类(1) | 提炼类(2、3) | +|------|------------|--------------| | 接口 | `llm.complete(msgs, { tools })` | `LLMCallFn(msgs)` → `string` | | tools | 有 | 无 | | L3 形态 | 原始 message 数组 | `${role}: ${content}` 字符串拼接进 user content | -| 返回 | 结构化(含 tool calls) | 纯文本 / JSON | +| 返回 | 结构化(含 tool calls) | 纯文本 | | `` 清洗 | 否 | 是 | diff --git a/.agents/skills/scheduler-design/SKILL.md b/.agents/skills/scheduler-design/SKILL.md index c4f5c7c..4dcc868 100644 --- a/.agents/skills/scheduler-design/SKILL.md +++ b/.agents/skills/scheduler-design/SKILL.md @@ -1,26 +1,28 @@ --- name: scheduler-design -description: Consolidation 触发机制:自动触发通过 Factory 配置内联,手动触发通过 StelloAgent API。 +description: Consolidation 触发机制:自动触发通过 Factory 配置内联,手动触发通过 StelloAgent API;全局 reflection 由应用层在 SDK 之上自行实现。 --- # Consolidation 触发机制 ## 概述 -Scheduler 类已删除。Consolidation 和 integration 的触发机制简化为两条路径: +Consolidation 的触发机制有两条路径: 1. **自动触发**:`consolidateEveryNTurns` 配置项,由 Factory 内联处理 -2. **手动触发**:`agent.consolidateSession(sessionId)` 和 `agent.integrate()` +2. **手动触发**:`agent.consolidateSession(sessionId)` + +跨 Session 的 reflection 由应用层在 `agent.listSessionDigests` / `agent.putInsight` 之上自行实现,详见 skill `session-usage`。 --- ## 自动 Consolidation -在 `orchestration` 配置中设置 `consolidateEveryNTurns`,每 N 轮对话后自动触发 consolidation: +在 `orchestration` 配置中设置 `consolidateEveryNTurns`,每 N 轮对话后自动触发 consolidation(fire-and-forget): ```typescript orchestration: { - consolidateEveryNTurns: 5, // 每 5 轮自动 consolidate + consolidateEveryNTurns: 5, } ``` @@ -30,21 +32,33 @@ orchestration: { ## 手动触发 -`StelloAgent` 提供两个第一方 API: - -- `agent.consolidateSession(sessionId)`:对指定 session 执行 consolidation(L3 → L2) -- `agent.integrate()`:对 Main Session 执行 integration(所有 L2 → synthesis + insights) +```typescript +await agent.consolidateSession(sessionId) +``` 应用层可在任意时机调用,例如 session 结束时、定时任务、用户操作后。 --- -## Integration 无框架级自动触发 +## 应用层 Reflection 循环 + +```typescript +async function reflect() { + const digests = await agent.listSessionDigests({ status: 'active' }) + // 调任意 LLM、用任意 schema 解析 ... + for (const [id, content] of Object.entries(insightsByTarget)) { + await agent.putInsight(id, content) + } +} +``` -Integration 没有框架级自动触发策略。应用层负责决定何时调用 `agent.integrate()`——可在 hooks 中响应 consolidation 完成事件,也可完全独立调用。 +可在 `hooks.onRoundEnd` / `hooks.onSessionFork` 内 fire-and-forget 触发,或绑定到外部 cron / 用户操作。框架不假设调用频率与策略——这部分被有意外推到应用层。 --- ## 设计决策 -去掉 Scheduler 类是有意简化:调度策略的多样性带来了复杂性,但实际上大多数应用只需要"每 N 轮"和"手动"两种模式。将复杂调度逻辑交还给应用层,框架只保留最常用的内联策略。 +调度被有意压到最小: +- 自动调度只保留"每 N 轮 consolidate"——这是高频被用的唯一策略,其他策略都被应用层覆盖 +- 全局 reflection 的频率 / prompt / schema / LLM tier 选择强烈与应用业务耦合,框架不假设 +- reflection 由应用层在 `listSessionDigests` / `putInsight` 之上自行实现,框架不持有跨 Session 状态 diff --git a/.agents/skills/server-design/SKILL.md b/.agents/skills/server-design/SKILL.md index 144661a..540ed47 100644 --- a/.agents/skills/server-design/SKILL.md +++ b/.agents/skills/server-design/SKILL.md @@ -51,13 +51,15 @@ connectionId → { userId, spaceId, sessionId | null } ## AgentPool 默认 fn 注入 -AgentPool 支持内置默认 consolidateFn / integrateFn: +AgentPool 支持内置默认 consolidateFn: - `AgentPoolOptions.llm` 提供最小 LLM 调用接口 -- Space 表存 `consolidatePrompt` / `integratePrompt` +- Space 表存 `consolidatePrompt` - 如果 buildConfig 未提供显式 fn,且 Space 有 prompt 且 llm 可用 → 自动注入默认实现 - buildConfig 提供的显式 fn 始终优先 +跨 Session 的 reflection / 全局综合不由 Server 框架承担——服务端只暴露 `StelloAgent` 的 orchestrator-facing 数据 SDK(`listSessionDigests` / `putInsight` 等),应用层基于这些原语自行实现。 + --- ## createStelloServer 入口 diff --git a/.agents/skills/server-storage/SKILL.md b/.agents/skills/server-storage/SKILL.md index 82638a2..fd3d77e 100644 --- a/.agents/skills/server-storage/SKILL.md +++ b/.agents/skills/server-storage/SKILL.md @@ -21,15 +21,16 @@ description: "@stello-ai/server 的 PG 持久化层设计决策和实现模式 --- -## 4 个 Storage Adapter +## 3 个 Storage Adapter | Adapter | 实现接口 | 职责 | |---------|---------|------| -| PgSessionStorage | SessionStorage(@stello-ai/session) | 单个 Session 数据操作 | -| PgMainStorage | MainStorage(extends SessionStorage) | 额外:批量 L2 收集、拓扑树、全局键值 | -| PgSessionTree | SessionTree(@stello-ai/core) | 树操作:创建节点、递归树、祖先/兄弟查询 | +| PgSessionStorage | SessionStorage(@stello-ai/session) | 单个 Session 数据操作(含 root,所有 Session 同构) | +| PgSessionTree | SessionTree(@stello-ai/core) | 拓扑:createSession / listRoots / 树操作 / 固化配置 | | PgMemoryEngine | MemoryEngine(@stello-ai/core) | 核心数据读写、递归上下文组装 | +批量 digest 收集由 `StelloAgent.listSessionDigests()` 在应用层组合 `SessionTree.listAll()` + `SessionStorage.getMemory/getInsight` 完成,存储层不需要提供专用方法。 + --- ## 关键实现模式 @@ -42,8 +43,8 @@ description: "@stello-ai/server 的 PG 持久化层设计决策和实现模式 ### 两种 SessionMeta 投影 PG 存 sessions 表超集。不同 adapter 投影为不同类型: -- PgSessionStorage → @stello-ai/session 的 SessionMeta(有 role,无 scope/turnCount) -- PgSessionTree → @stello-ai/core 的 SessionMeta(有 scope/turnCount,无树字段)+ TopologyNode(纯树结构) +- PgSessionStorage → @stello-ai/session 的 SessionMeta(`id / label / status / createdAt / updatedAt`) +- PgSessionTree → @stello-ai/core 的 SessionMeta(含 scope / turnCount / lastActiveAt)+ TopologyNode(纯树结构) ### 递归 CTE `getAncestors`、`getAllDescendantIds`、`assembleContext` 都用 `WITH RECURSIVE` 遍历树结构。 diff --git a/.agents/skills/session-usage/SKILL.md b/.agents/skills/session-usage/SKILL.md index f764d2e..93c071c 100644 --- a/.agents/skills/session-usage/SKILL.md +++ b/.agents/skills/session-usage/SKILL.md @@ -1,21 +1,17 @@ --- name: session-usage -description: Session / MainSession 对话单元的设计理念、上下文组装规则、insights 交流模型。 +description: Session 对话单元的设计理念、上下文组装规则、memory / insight 槽位语义、单一 Session 模型与跨 Session 通信模型。 --- -## 两种 Session +# Session 使用 -`@stello-ai/session` 提供两个独立接口: +## 单一 Session 模型 -| | Session(子 Session) | MainSession(全局意识层) | -|--|----------------------|-------------------------| -| 上下文 | system prompt + insight + L3 + msg | system prompt + synthesis + L3 + msg | -| 记忆 | `memory()` = L2(技能描述,给 Main 看) | `synthesis()` = integration 产出 | -| 提炼 | `consolidate()` L3→L2 | `integrate()` 所有 L2→synthesis+insights | -| insights | 被动接收(消费后清除) | 通过 integrate 主动推送 | -| fork | `fork()` 创建子 Session | 无 — 子 Session 由编排层创建 | +`@stello-ai/session` 只对外暴露**一种** Session。对话起点是一个 `parentId === null` 的 root session(由 `agent.createSession()` 创建),其余通过 `agent.forkSession()` 挂在父节点下。Root 与 child 在运行时行为完全一致——差异仅在 `TopologyNode.parentId`。 -两者都是**单次 LLM 调用原语**,tool call 循环由上层驱动。 +一棵树可以有任意多 root,互相独立(森林)。 + +Session 始终是**单次 LLM 调用原语**:`send()` 单次调用 + 持久化,tool call 循环由 `@stello-ai/core` 的 `Engine` 驱动。 --- @@ -23,35 +19,61 @@ description: Session / MainSession 对话单元的设计理念、上下文组装 这是固定规则,不暴露扩展点(设计决策 #7)。 -**子 Session**:system prompt → insight(如有,消费后清除)→ L3 历史 → 当前消息 +``` +system prompt → session_identity(label) → insight(若有,消费后清除) → L3 历史 → 当前用户消息 +``` -**Main Session**:system prompt → synthesis(如有)→ L3 历史 → 当前消息 +- **system prompt** 来自 `getSystemPrompt(sessionId)`,全局每 Session 一份。 +- **session_identity** 由 `label` 自动生成的 `` 系统消息,告知 LLM 当前所在子会话身份。 +- **insight** 来自 `getInsight(sessionId)`,**一次性**:被消费后 send() 内置触发 `clearInsight`。 +- **L3 历史** 来自 `listRecords(sessionId)`,会先经 `removeIncompleteToolCallGroups` 净化掉因中断/崩溃残留的不完整 tool call 组。 +- **memory 槽位不进入 send() 上下文**——它是对外暴露的描述,由 orchestrator-facing 视角消费,详见下文。 -每个上下文元素对应 SessionStorage 中的一个专用槽位。 +当估算 token 数超过 `maxContextTokens * 0.8` 时,会调用闭包注入的 `compressFn`,将历史压缩为一段 system 摘要,与近期消息拼接。Session 内部缓存压缩结果,避免每次 send() 都调用 compressFn。 --- -## Insights 交流模型 +## 三个上下文槽位的语义 -这是 Session 间唯一的信息通道: +每个 Session 在 `SessionStorage` 中有三个独立内容槽位: -1. **Integration 生成 insights**:MainSession.integrate() 收集所有子 L2 → IntegrateFn(创建时绑定)生成 synthesis + per-child insights -2. **定向推送**:每个 insight 通过 `putInsight(sessionId, content)` 写入目标子 Session -3. **消费即清除**:子 Session 下次 send() 时读取 insight,注入上下文,然后清除 -4. **替换策略**:每次 integration 覆盖上一次的 insight(不追加) +| 槽位 | 写入者 | 消费者 | 生命周期 | +|------|--------|--------|---------| +| `systemPrompt` | fork 合成链固化 / 应用层 | Session.send() 组装上下文 | 持久(每次 send 读取) | +| `insight` | Orchestrator(应用层通过 `putInsight`) | Session.send() 消费一次后清除 | 一次性 inbox | +| `memory` | 应用层(通过 consolidate 输出 / 直接 `putMemory`) | Orchestrator-facing 反思层(`listSessionDigests`) | 持久(被外部读,不进 send) | -子 Session 之间完全不感知。唯一的跨 Session 信息来源是 Main Session 推送的 insights。 +**关键不变量**:`memory` 不进入 Session 自身的 LLM 上下文。它是面向外部视角的描述——上层可以批量收集所有 Session 的 memory 做反思、规划、调度,再通过 `putInsight` 把派生的洞察定向回写给目标 Session。Session 自身不感知这个回路。 --- -## L2 的语义 +## 跨 Session 通信模型 + +子 Session 之间完全不感知。唯一的跨 Session 信息通道: -L2 是子 Session 的**外部描述**(技能描述),不是自用记忆。 +``` +所有 Session 的 memory ──┐ + ├─→ 应用层反思层(任意 LLM)──→ putInsight(targetId, content) + ┘ +(StelloAgent.listSessionDigests 一次性取齐) +``` -- L2 对子 Session 自身 LLM **不可见**(设计决策 #1) -- Main Session 只读 L2,不读子 Session 的 L3(设计决策 #2) -- L2 在 consolidation 时批量生成,不在每轮对话中更新 -- 正在进行中的 Session 没有 L2,对 Main Session 暂时不可见——有意为之 +- **反思层由应用层实现**:应用层可以用任意频率、任意 LLM、任意策略对 `listSessionDigests` 的结果做综合,再把派生 insight 定向回写。 +- **insight 是一次性的**:每次 reflection 写入新 insight;target session 下一次 send() 注入后自动 clear。重复 reflect 不会累积。 +- **memory 是持久的**:`putMemory` 的语义是替换不是追加。 + +--- + +## fork() 的语义 + +Session 层 `fork(options)` 完成上下文继承: + +- 创建一个新 Session 实例(id 由调用方传入,topology-first) +- 按 `context: 'none' | 'inherit' | ForkContextFn` 决定是否拷贝父 session 的 L3 记录 +- 不复制 memory / insight 槽位 +- 一次性继承后两个 Session 互相独立 + +Engine 在编排层会先创建 TopologyNode(拿到 ID),再调用 `session.fork({ id })`。调用方通过 `agent.forkSession()` 一次完成两步,详见 skill `fork-design`。 --- @@ -61,8 +83,8 @@ L2 是子 Session 的**外部描述**(技能描述),不是自用记忆。 --- -## ConsolidateFn / IntegrateFn 配对 - -这两个函数是**配对的**——ConsolidateFn 输出某种格式的 L2,IntegrateFn 读取该格式。框架对 L2 内容格式完全无感知。 +## ConsolidateFn / CompressFn -两个函数都不注入 LLM——应用层通过闭包自行选择 LLM tier(设计决策 #12)。 +- **ConsolidateFn**:L3 → memory 的提炼函数,由应用层定义输出格式,框架对 memory 内容格式完全无感知。 +- **CompressFn**:超 token 阈值时把对话历史压缩为一段摘要,注入到上下文里。 +- 两者都不注入 LLM——应用层通过闭包自行选择 LLM tier(设计决策 #12)。 diff --git a/.agents/skills/stello-agent-creation/SKILL.md b/.agents/skills/stello-agent-creation/SKILL.md index 18fe594..e8bfac0 100644 --- a/.agents/skills/stello-agent-creation/SKILL.md +++ b/.agents/skills/stello-agent-creation/SKILL.md @@ -1,6 +1,6 @@ --- name: stello-agent-creation -description: StelloAgent 创建配置教程。完整说明 createStelloAgent 的每个配置项,包含 sessionDefaults、mainSessionConfig、tools、skills、forkProfiles、session 层接入等。 +description: StelloAgent 创建配置教程。完整说明 createStelloAgent 的每个配置项,包含 sessionDefaults、storage、tools、skills、forkProfiles、session 层接入、orchestration 等。 --- # StelloAgent 创建配置教程 @@ -17,15 +17,17 @@ import { type SessionTree, type MemoryEngine, } from '@stello-ai/core' +import type { SessionStorage } from '@stello-ai/session' const agent = createStelloAgent({ - sessions, // SessionTree 实例 - memory, // MemoryEngine 实例 + sessions, // SessionTree 实例(拓扑) + memory, // MemoryEngine 实例 + storage: sessionStorage, // SessionStorage 实例(内容;orchestrator-facing SDK 依赖) capabilities: { - lifecycle, // EngineLifecycleAdapter - tools: new ToolRegistryImpl(), // 空 registry = 无自定义 tool - skills: new SkillRouterImpl(), // 空 router = 无 skill(activate_skill 不注入) - confirm: { ... }, // ConfirmProtocol + lifecycle, // EngineLifecycleAdapter + tools: new ToolRegistryImpl(), + skills: new SkillRouterImpl(), + confirm: { ... }, }, session: { sessionLoader: async (id) => ({ session: loadedSession, config: null }), @@ -39,100 +41,80 @@ const agent = createStelloAgent({ ```typescript interface StelloAgentConfig { - sessions: SessionTree // 拓扑树(必填) - memory: MemoryEngine // 记忆引擎(必填) - sessionDefaults?: SessionConfig // Regular session 的 agent 级默认配置 - mainSessionConfig?: MainSessionConfig // Main session 的独立配置 - capabilities: { // 能力注入(必填) + sessions: SessionTree // 拓扑树(必填) + memory: MemoryEngine // 记忆引擎(必填) + storage?: SessionStorage // 内容存储(orchestrator-facing 数据 SDK 依赖) + sessionDefaults?: SessionConfig // 所有 session 的 agent 级默认(fork 合成链最低优先级) + capabilities: { // 能力注入(必填) lifecycle: EngineLifecycleAdapter - tools: EngineToolRuntime // 用户自定义工具 - skills: SkillRouter // Skill 注册表 + tools: EngineToolRuntime // 用户自定义工具 + skills: SkillRouter // Skill 注册表 confirm: ConfirmProtocol - profiles?: ForkProfileRegistry // Fork 模板(可选) + profiles?: ForkProfileRegistry // Fork 模板(可选) } - session?: StelloAgentSessionConfig // Session 层接入(可选) - runtime?: StelloAgentRuntimeConfig // Runtime 策略(可选) - orchestration?: StelloAgentOrchestrationConfig // 编排策略(可选) + session?: StelloAgentSessionConfig // Session 层接入(可选) + runtime?: StelloAgentRuntimeConfig // Runtime 策略(可选) + orchestration?: StelloAgentOrchestrationConfig // 编排策略(可选) } ``` +> 所有 Session 共用 `sessionDefaults`。Root 没有专属配置,与子 session 走同一套 fork 合成链(详见 fork-design)。 +> "全 memory → 反思 → 定向 insight" 的循环由应用层基于 orchestrator-facing SDK 自行实现(不在配置注入点里)。 + --- -## 1. `sessionDefaults` 与 `mainSessionConfig` +## 1. `storage` —— Orchestrator-facing 数据 SDK -### `sessionDefaults` — Regular Session 的 Agent 级默认 +`storage: SessionStorage` 注入后,StelloAgent 暴露以下 data-IO 方法(详见 stello-agent-usage): -`sessionDefaults` 是所有 regular session 的配置基线,是 fork 合成链的最低优先级层。 +- `listSessionDigests(filter?)` —— 批量收集所有 Session 的 `{ id, label, status, memory, insight }` +- `getSessionMetadata(id)` —— 单个 Session 的 `{ memory, insight }` +- `listMessages(id, options?)` —— 读取指定 Session 的 L3 消息 +- `putMemory(id, content)` / `putInsight(id, content)` / `clearInsight(id)` -```typescript -createStelloAgent({ - sessionDefaults: { - llm: defaultLlm, // 所有子 session 使用的默认 LLM - consolidateFn: defaultConsolidateFn, // 默认 L3→L2 提炼函数 - compressFn: defaultCompressFn, // 默认上下文压缩函数 - systemPrompt: '你是一个助手。', // 所有子 session 的基础 prompt(可被 fork 覆盖) - skills: undefined, // undefined = 继承全局 SkillRouter(默认) - }, - // ... -}) -``` +**重要**:应用层需保证 `sessions`(拓扑)与 `storage`(内容)指向**同一份后端**——`SessionTree.listAll()` 返回的 id 必须能在 `SessionStorage` 上 `getMemory`。 -**`SessionConfig` 完整字段**: +未注入 `storage` 时,上述方法会抛错;其余编排能力(turn / stream / fork / archive)不受影响。 -```typescript -interface SessionConfig { - systemPrompt?: string - llm?: LLMAdapter - tools?: LLMCompleteOptions['tools'] - skills?: string[] // undefined=继承全局;[]=禁用所有 skill;['a','b']=白名单 - consolidateFn?: SessionCompatibleConsolidateFn - compressFn?: SessionCompatibleCompressFn -} -``` +--- -### `mainSessionConfig` — Main Session 的独立配置 +## 2. `sessionDefaults` —— Agent 级默认配置 -`mainSessionConfig` 是 main session 的专属配置,不参与 regular session 的 fork 合成链。 +所有 Session 的配置基线,fork 合成链的最低优先级层。 ```typescript createStelloAgent({ - mainSessionConfig: { - systemPrompt: '你是全局协调者,负责统筹所有子任务。', - llm: mainLlm, // main session 可用更强的模型 - integrateFn: myIntegrateFn, // all L2s → synthesis + insights - compressFn: mainCompressFn, + sessionDefaults: { + llm: defaultLlm, // 默认 LLM + consolidateFn: defaultConsolidateFn, // 默认 L3→memory 提炼函数 + compressFn: defaultCompressFn, // 默认上下文压缩函数 + systemPrompt: '你是一个助手。', // 默认 system prompt(可被 fork 覆盖) + skills: undefined, // undefined = 继承全局 SkillRouter(默认) }, // ... }) ``` -**`MainSessionConfig` 字段**(与 `SessionConfig` 平行,但用 `integrateFn` 替代 `consolidateFn`): +**`SessionConfig` 完整字段**: ```typescript -interface MainSessionConfig { +interface SessionConfig { systemPrompt?: string llm?: LLMAdapter tools?: LLMCompleteOptions['tools'] - skills?: string[] - integrateFn?: SessionCompatibleIntegrateFn // main session 专属 + skills?: string[] // undefined=继承全局;[]=禁用所有 skill;['a','b']=白名单 + consolidateFn?: SessionCompatibleConsolidateFn compressFn?: SessionCompatibleCompressFn } ``` -**与 `sessionDefaults` 的区别**: - -| | `sessionDefaults` | `mainSessionConfig` | -|--|------------------|---------------------| -| 作用对象 | 所有 regular session | 仅 main session | -| 提炼函数 | `consolidateFn` | `integrateFn` | -| 参与 fork 合成链 | 是(最低优先级) | 否 | -| 固化时机 | 每次 fork | `createMainSession()` 调用时 | +> Root session 的固化配置由你创建 root 时通过 `agent.createSession({ label })` + 后续 `sessions.putConfig(rootId, ...)` 设置——或在 `sessionDefaults` 给出全局默认即可。Root 没有特殊待遇。 --- -## 2. `capabilities` — 能力注入 +## 3. `capabilities` — 能力注入 -### 2.1 `tools` — 用户自定义工具 +### 3.1 `tools` — 用户自定义工具 ```typescript import { ToolRegistryImpl } from '@stello-ai/core' @@ -149,7 +131,7 @@ toolRegistry.register({ }, required: ['note'], }, - execute: async (args) => { + execute: async (args, _ctx) => { await db.saveNote(String(args.note)) return { success: true, data: { saved: true } } }, @@ -158,11 +140,11 @@ toolRegistry.register({ **要点**: - `parameters` 是 JSON Schema 格式,LLM 据此生成参数 -- `execute` 返回 `{ success: true, data: ... }` 或 `{ success: false, error: '...' }` +- `execute(args, ctx)` 返回 `{ success: true, data: ... }` 或 `{ success: false, error: '...' }` - tool 执行失败时 Engine 自动将 error 作为 tool result 返回给 LLM,不中断对话 -- **不需要注册 `stello_create_session` 和 `activate_skill`**——框架自动注入 +- 内置 tool(`stello_create_session` / `activate_skill`)需要在 `ToolRegistryImpl([...])` 构造时显式 opt-in(参考 `createSessionTool()` / `activateSkillTool(skills)` factory) -### 2.2 `skills` — Skill 注册表 +### 3.2 `skills` — Skill 注册表 Skill 是两级渐进式加载的 prompt 片段:LLM 始终看到 name + description,主动调用 `activate_skill` 后注入完整 content。 @@ -185,16 +167,9 @@ for (const skill of fileSkills) { } ``` -**行为**: -- 有 skills 注册时,Engine 自动注入 `activate_skill` 内置 tool -- 无 skills 时,`activate_skill` 不出现在 LLM 的可用工具列表中 - -**per-session skill 白名单**通过 `SessionConfig.skills` 控制(见 fork 配置合成链一节): -- `undefined`(默认):该 session 继承全局 SkillRouter 的全部 skill -- `[]`:禁用该 session 的 `activate_skill`,LLM 看不到任何 skill -- `['search', 'summarize']`:只允许这两个 skill 对该 session 可见 +`activate_skill` 是否对 LLM 可见取决于 `skills.getAll().length > 0`,以及该 session 的 `SessionConfig.skills` 白名单(详见 fork-design 的 skills 三态语义)。 -### 2.3 `profiles` — Fork Profile 注册表(可选) +### 3.3 `profiles` — Fork Profile 注册表(可选) ForkProfile 是预定义的 fork 配置模板,extends `SessionConfig`。LLM 调用 `stello_create_session` 时可通过 `profile` 参数引用。 @@ -203,52 +178,29 @@ import { ForkProfileRegistryImpl } from '@stello-ai/core' const forkProfiles = new ForkProfileRegistryImpl() -// 基础 profile:固定角色 forkProfiles.register('poet', { systemPrompt: '你是一位诗人。所有回复必须用诗歌形式。', - systemPromptMode: 'preset', // 忽略 fork options 的 systemPrompt + systemPromptMode: 'preset', }) -// 动态 systemPrompt 模板 forkProfiles.register('region-expert', { - systemPromptFn: (vars) => `你是${vars.region}地区的留学专家。`, // 优先于 systemPrompt + systemPromptFn: (vars) => `你是${vars.region}地区的留学专家。`, systemPromptMode: 'preset', llm: cheaperLlmAdapter, - skills: ['search', 'summarize'], // 白名单:只允许这两个 skill + skills: ['search', 'summarize'], consolidateFn: researchConsolidateFn, }) -// prepend 合成 + 继承上下文 forkProfiles.register('researcher', { systemPrompt: '你是研究助手,善于深入分析。', - systemPromptMode: 'prepend', // profile prompt 在前,fork options 的 prompt 在后 - context: 'inherit', // 继承父会话的对话历史 + systemPromptMode: 'prepend', + context: 'inherit', }) ``` -**`ForkProfile` 完整字段**(extends `SessionConfig`): +完整字段、合成规则、`systemPromptMode` 三种模式见 skill `fork-design`。 -```typescript -interface ForkProfile extends SessionConfig { - // SessionConfig 字段(systemPrompt / llm / tools / skills / consolidateFn / compressFn) - - systemPromptFn?: (vars: Record) => string // 动态模板,优先于 systemPrompt - systemPromptMode?: 'preset' | 'prepend' | 'append' // 默认 'prepend' - context?: 'none' | 'inherit' | ForkContextFn // 上下文继承策略 - prompt?: string // fork 后的开场消息(默认值) -} -``` - -**`systemPromptMode` 三种模式**: -- `'preset'`:只用 profile 的 systemPrompt,完全忽略 fork options 的 systemPrompt -- `'prepend'`(默认):`[profile prompt]\n[fork options prompt]` -- `'append'`:`[fork options prompt]\n[profile prompt]` - -**行为**: -- 有 profiles 注册时,`stello_create_session` 自动增加 `profile` 枚举参数和 `vars` 对象参数 -- 无 profiles 时,`stello_create_session` 只有 `label / systemPrompt / prompt / context` 参数 - -### 2.4 `lifecycle` — 生命周期适配器 +### 3.4 `lifecycle` — 生命周期适配器 ```typescript const lifecycle: EngineLifecycleAdapter = { @@ -265,16 +217,13 @@ const lifecycle: EngineLifecycleAdapter = { } ``` -### 2.5 `confirm` — 确认协议 +### 3.5 `confirm` — 确认协议 ```typescript const confirm: ConfirmProtocol = { async confirmSplit(proposal) { - // LLM 建议拆分时,创建子 session - // proposal 含 parentId、suggestedLabel return agent.forkSession(proposal.parentId, { label: proposal.suggestedLabel, - // 如需基于 LLM 建议的 prompt 约束子 session 行为,用 systemPrompt }) }, async dismissSplit() {}, @@ -285,13 +234,12 @@ const confirm: ConfirmProtocol = { --- -## 3. `session` — Session 层接入 +## 4. `session` — Session 层接入 -`StelloAgentSessionConfig` 的职责是**纯 I/O 数据加载**,不在 resolver 闭包里构造 session 行为配置(`consolidateFn` 等移到 `sessionDefaults`)。 +`StelloAgentSessionConfig` 是**纯 I/O 数据加载**,按 ID 加载 Session 实例与其固化配置。 ```typescript session: { - // 按 ID 加载 Session 实例与其固化配置(必填) sessionLoader: async (sessionId) => { const session = await loadSession(sessionId, { storage: sessionStorage, @@ -299,17 +247,8 @@ session: { }) if (!session) throw new Error(`Session not found: ${sessionId}`) return { - session, // SessionCompatible 实例 - config: null, // SerializableSessionConfig | null(目前传 null 即可) - } - }, - - // 加载 MainSession(可选,需要 integration 时提供) - mainSessionLoader: async () => { - if (!mainSession) return null - return { - session: mainSession, // MainSessionCompatible 实例 - config: null, + session, // SessionCompatible 实例 + config: null, // SerializableSessionConfig | null } }, @@ -321,6 +260,8 @@ session: { } ``` +所有 Session(含 root)由同一个 `sessionLoader` 按 id 加载。Root 与子 session 的差异只在拓扑 (`TopologyNode.parentId === null`),loader 无需区分。 + **两种 session 接入方式**: | 方式 | 配置 | 适用场景 | @@ -330,44 +271,42 @@ session: { --- -## 4. `orchestration` — 编排策略(可选) +## 5. `orchestration` — 编排策略(可选) -### `consolidateEveryNTurns` — 自动 consolidation +### `consolidateEveryNTurns` ```typescript orchestration: { - consolidateEveryNTurns: 5, // 每 5 轮自动 consolidate(fire-and-forget) + consolidateEveryNTurns: 5, } ``` -### `splitGuard` — 拆分保护 +每 5 轮自动 consolidate(fire-and-forget)。 + +### `splitGuard` ```typescript import { SplitGuard } from '@stello-ai/core' orchestration: { splitGuard: new SplitGuard(sessions, { - minTurns: 3, // 至少对话 3 轮才允许 fork - cooldownTurns: 5, // 上次 fork 后至少再对话 5 轮 + minTurns: 3, + cooldownTurns: 5, }), } ``` -### `hooks` — Engine 事件钩子 +### `hooks` ```typescript orchestration: { hooks: { onRoundStart({ sessionId, input }) {}, onRoundEnd({ sessionId, turn }) {}, - onSessionFork({ parentId, child }) { - console.log(`Fork: ${parentId} → ${child.id}`) - }, + onSessionFork({ parentId, child }) {}, onToolCall({ sessionId, toolCall }) {}, onError({ source, error }) {}, }, - // 也支持按 sessionId 动态生成 - // hooks: (sessionId) => ({ onRoundEnd({ turn }) { ... } }), } ``` @@ -375,17 +314,6 @@ orchestration: { --- -## 5. 内置 Tool 的自动注册 - -用户**不需要**手动注册: - -| 内置 Tool | 注入条件 | -|-----------|---------| -| `stello_create_session` | 始终注入 | -| `activate_skill` | `skills.getAll().length > 0` 时注入 | - ---- - ## 6. 完整配置示例 ```typescript @@ -397,33 +325,27 @@ import { SplitGuard, SessionTreeImpl, NodeFileSystemAdapter, - createDefaultConsolidateFn, - createDefaultIntegrateFn, - loadSession, - loadMainSession, - type StelloAgentConfig, + FileSystemMemoryEngine, + createSessionTool, + activateSkillTool, } from '@stello-ai/core' -import { InMemoryStorageAdapter } from '@stello-ai/session' +import { + loadSession, + InMemoryStorageAdapter, + createOpenAICompatibleAdapter, +} from '@stello-ai/session' // ─── 基础设施 ─── const fs = new NodeFileSystemAdapter('./data') const sessions = new SessionTreeImpl(fs) const memory = new FileSystemMemoryEngine(fs, sessions) const sessionStorage = new InMemoryStorageAdapter() - -const llm = createOpenAICompatibleAdapter({ apiKey: process.env.OPENAI_API_KEY!, model: 'gpt-4o' }) -const llmCall: LLMCallFn = async (messages) => (await llm.complete(messages)).content ?? '' - -// ─── 自定义 Tools ─── -const toolRegistry = new ToolRegistryImpl() -toolRegistry.register({ - name: 'search_knowledge', - description: '搜索知识库', - parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] }, - execute: async (args) => ({ success: true, data: await knowledgeBase.search(String(args.query)) }), +const llm = createOpenAICompatibleAdapter({ + apiKey: process.env.OPENAI_API_KEY!, + model: 'gpt-4o', }) -// ─── Skills ─── +// ─── 自定义 Tools(含 opt-in 内置 tool) ─── const skills = new SkillRouterImpl() skills.register({ name: 'data-analysis', @@ -431,6 +353,24 @@ skills.register({ content: '你是数据分析专家...', }) +const toolRegistry = new ToolRegistryImpl([ + createSessionTool(), // 内置 fork tool(opt-in) + activateSkillTool(skills), // 内置 skill 激活 tool(opt-in) +]) +toolRegistry.register({ + name: 'search_knowledge', + description: '搜索知识库', + parameters: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + execute: async (args, _ctx) => ({ + success: true, + data: await knowledgeBase.search(String(args.query)), + }), +}) + // ─── Fork Profiles ─── const profiles = new ForkProfileRegistryImpl() profiles.register('researcher', { @@ -438,7 +378,6 @@ profiles.register('researcher', { systemPromptMode: 'prepend', context: 'inherit', skills: ['search', 'data-analysis'], - consolidateFn: createDefaultConsolidateFn('请提炼研究要点', llmCall), }) // ─── 创建 Agent ─── @@ -446,19 +385,12 @@ let agent: ReturnType agent = createStelloAgent({ sessions, memory, + storage: sessionStorage, // 注入内容存储,启用 orchestrator-facing SDK - // Regular session 的 agent 级默认配置 sessionDefaults: { llm, - consolidateFn: createDefaultConsolidateFn('请提炼对话要点', llmCall), - compressFn: createDefaultCompressFn(llmCall), - }, - - // Main session 的独立配置 - mainSessionConfig: { - systemPrompt: '你是全局协调者,负责统筹所有子任务。', - llm, - integrateFn: createDefaultIntegrateFn('请综合所有子任务的要点', llmCall), + systemPrompt: '你是一个助手。', + // consolidateFn / compressFn 由应用层闭包注入 }, session: { @@ -467,11 +399,6 @@ agent = createStelloAgent({ if (!session) throw new Error(`Session not found: ${sessionId}`) return { session, config: null } }, - mainSessionLoader: async () => { - const mainSession = await loadMainSession({ storage: sessionStorage, llm }) - if (!mainSession) return null - return { session: mainSession, config: null } - }, }, capabilities: { @@ -508,17 +435,41 @@ agent = createStelloAgent({ }, }) -// ─── 创建 Main Session(推荐入口)─── -const mainNode = await agent.createMainSession({ label: 'Main' }) +// ─── 创建 root session ─── +const root = await agent.createSession({ label: 'Main' }) // ─── 开始对话 ─── -await agent.enterSession(mainNode.id) -const result = await agent.turn(mainNode.id, '帮我分析一下市场趋势') +await agent.enterSession(root.id) +const result = await agent.turn(root.id, '帮我分析一下市场趋势') console.log(result.turn.finalContent) ``` --- -## 7. 运行时使用 +## 7. 自行实现 reflection 循环 + +"全 memory → 反思 → 定向 insight" 的循环由应用层实现: + +```typescript +async function reflect(agent: StelloAgent, llm: LLMAdapter): Promise { + const digests = await agent.listSessionDigests({ status: 'active' }) + const reflection = await llm.complete([ + { role: 'system', content: '你是 orchestrator,请综合各 session 的 memory,对需要纠偏/补充信息的 session 写出 insight。' }, + { role: 'user', content: JSON.stringify(digests) }, + ]) + + // 解析 reflection 输出(自定义 schema),调用 putInsight 定向回写 + const { insights } = JSON.parse(reflection.content ?? '{}') as { insights: Record } + await Promise.all( + Object.entries(insights).map(([sessionId, content]) => + agent.putInsight(sessionId, content), + ), + ) +} +``` + +--- + +## 8. 运行时使用 -Agent 创建后的运行时操作(turn / stream / fork / attach / detach 等)见 skill `stello-agent-usage`。 +Agent 创建后的运行时操作(createSession / turn / stream / fork / attach / detach / 数据 SDK 等)见 skill `stello-agent-usage`。 diff --git a/.agents/skills/stello-agent-usage/SKILL.md b/.agents/skills/stello-agent-usage/SKILL.md index 801c638..62c213c 100644 --- a/.agents/skills/stello-agent-usage/SKILL.md +++ b/.agents/skills/stello-agent-usage/SKILL.md @@ -1,6 +1,6 @@ --- name: stello-agent-usage -description: StelloAgent 运行时使用教程。覆盖 Session 生命周期、turn/stream 对话、fork 配置合成链、createMainSession、runtime 管理、热更新等运行时 API。 +description: StelloAgent 运行时使用教程。覆盖 Session 生命周期、createSession、turn/stream 对话、fork 配置合成链、orchestrator-facing 数据 SDK、runtime 管理、热更新等运行时 API。 --- # StelloAgent 运行时使用教程 @@ -10,36 +10,34 @@ description: StelloAgent 运行时使用教程。覆盖 Session 生命周期、t --- -## 1. Main Session 初始化 +## 1. Root Session 创建 -Main session 是 Agent 的起始节点,必须显式创建,不通过 `forkSession` 创建。 +对话起点是一个普通 Session(`parentId === null`),由 `agent.createSession()` 创建——**不传** `parentId` 即为新 root。 ```typescript -// 推荐:用 createMainSession(),框架会用 mainSessionConfig 固化配置 -const mainNode = await agent.createMainSession({ label: 'Main' }) - -// 之后进入 session 开始对话 -await agent.enterSession(mainNode.id) +const root = await agent.createSession({ label: 'Main' }) +await agent.enterSession(root.id) ``` -**`createMainSession` 做了什么**: -1. 调用 `sessions.createRoot(label)` 创建根拓扑节点 -2. 将 `mainSessionConfig` 的可序列化字段(`systemPrompt / skills`)固化写入存储 -3. 返回 `TopologyNode`(含 `id / parentId / depth / label` 等) +`agent.createSession({ parentId?, label? })` 做了什么: +1. 调用 `sessions.createSession({ parentId, label })` 创建拓扑节点(`parentId` 缺省即 `parentId === null`) +2. 返回 `TopologyNode`(含 `id / parentId / children / refs / depth / label` 等) + +Root 没有特殊待遇——它就是一个普通 Session。多个 root 合法(森林)。全局默认 systemPrompt / skills 等配置在 `sessionDefaults` 即可。 --- ## 2. Session 生命周期 ``` -createMainSession → enterSession → turn / stream (× N) → leaveSession → archiveSession +createSession → enterSession → turn / stream (× N) → leaveSession → archiveSession ``` ### 2.1 进入 Session ```typescript const bootstrap = await agent.enterSession(sessionId) -// bootstrap.context — 组装好的上下文(L1 core + L2 memories + insight/synthesis) +// bootstrap.context — 组装好的上下文(MemoryEngine 视角) // bootstrap.session — SessionMeta(id, label, status, turnCount 等) ``` @@ -73,15 +71,10 @@ console.log(result.turn.finalContent) ```typescript await agent.turn(sessionId, input, { - maxToolRounds: 5, // 限制 tool call 循环轮数(默认无限) - - onToolCall: (toolCall) => { - console.log(`调用工具: ${toolCall.name}`, toolCall.arguments) - }, - - onToolResult: (result) => { - console.log(`工具结果: ${result.name}`, result.content) - }, + maxToolRounds: 5, // 限制 tool call 循环轮数(默认无限) + signal: abortController.signal, // 支持取消(中断当前轮 LLM/tool 调用) + onToolCall: (toolCall) => { /* ... */ }, + onToolResult: (result) => { /* ... */ }, }) ``` @@ -100,7 +93,7 @@ await agent.archiveSession(sessionId) // 标记归档,之后不应再 turn() | 方式 | 触发者 | 入口 | |------|--------|------| -| LLM 发起 | LLM 调用 `stello_create_session` 内置 tool | 自动,无需代码 | +| LLM 发起 | LLM 调用 `stello_create_session` 内置 tool | 需在 `capabilities.tools` opt-in 注册 | | 代码发起 | 应用层调用 `agent.forkSession()` | 手动编排 | ### 3.2 `forkSession` 参数 @@ -114,142 +107,114 @@ const child = await agent.forkSession(sessionId, { systemPrompt: '你是市场分析专家...', llm: specializedLlm, tools: customTools, - skills: ['search', 'summarize'], // 该子 session 的 skill 白名单 + skills: ['search', 'summarize'], consolidateFn: customConsolidateFn, compressFn: customCompressFn, // ── Fork 专属字段(可选)── - prompt: '请深入分析半导体行业', // fork 后立即发送的首条消息 - context: 'inherit', // 'none'(默认)| 'inherit' | ForkContextFn - topologyParentId: otherNodeId, // 显式指定拓扑父节点(不传 = 当前 sessionId) - profile: 'researcher', // 引用预注册的 ForkProfile 名称 - profileVars: { region: '北美' }, // ForkProfile.systemPromptFn 的模板变量 + prompt: '请深入分析半导体行业', // fork 后立即发送的首条消息 + context: 'inherit', // 'none'(默认)| 'inherit' | ForkContextFn + topologyParentId: otherNodeId, // 显式指定拓扑父节点(不传 = 当前 sessionId) + profile: 'researcher', // 引用预注册的 ForkProfile 名称 + profileVars: { region: '北美' }, // ForkProfile.systemPromptFn 的模板变量 }) // child: TopologyNode -// child.id — 新 session 的 ID -// child.parentId — 拓扑父节点 ID +// child.id — 新 session 的 ID +// child.parentId — 拓扑父节点 ID // child.sourceSessionId — fork 时的上下文来源 session ID -// child.depth — 拓扑深度(根 = 0) -// child.label — 显示名称 +// child.depth — 拓扑深度(root = 0) +// child.label — 显示名称 ``` Fork 后需要单独 `enterSession(child.id)` 才能在子 session 上 turn()。 ### 3.3 上下文继承策略(`context`) -`context` 控制子 session 是否继承父 session 的 L3 对话历史: - ```typescript -// 空白开始(默认) -await agent.forkSession(sessionId, { label: '子任务', context: 'none' }) - -// 完整继承父 session 的所有 L3 记录 -await agent.forkSession(sessionId, { label: '深度研究', context: 'inherit' }) - -// 自定义:只继承最近 10 条消息 +await agent.forkSession(sessionId, { label: '子任务', context: 'none' }) // 空白开始(默认) +await agent.forkSession(sessionId, { label: '深度研究', context: 'inherit' }) // 完整继承 L3 await agent.forkSession(sessionId, { label: '摘要子任务', - context: async (parentMessages) => parentMessages.slice(-10), + context: async (parentMessages) => parentMessages.slice(-10), // 自定义裁剪 }) ``` ### 3.4 Fork 配置合成链 -fork 时各字段按以下优先级合成,**后者覆盖前者**: +fork 时按 `sessionDefaults → 父 session 固化 config → ForkProfile → EngineForkOptions` 顺序合成,后者覆盖前者。root 也是普通 session,从 root fork 会正常继承 root 的固化 config。 -``` -sessionDefaults → 父 session 固化 config → ForkProfile → EngineForkOptions -``` +详见 skill `fork-design`。 -- **从 main session fork**:不继承 main session 的配置,直接从 `sessionDefaults` 开始 -- **从 regular session fork**:在 `sessionDefaults` 基础上叠加父 session 的固化配置 +--- -**`systemPrompt` 的特殊合成规则**(当使用 profile 时): +## 4. Orchestrator-facing 数据 SDK -| `systemPromptMode` | 结果 | -|-------------------|------| -| `'preset'` | 只用 profile 的 prompt,忽略 fork options 的 systemPrompt | -| `'prepend'`(默认) | `[profile prompt]\n[fork options prompt]` | -| `'append'` | `[fork options prompt]\n[profile prompt]` | +需要在创建 agent 时注入 `storage: SessionStorage`(顶层)。这套 API 让外部 orchestrator(应用层 / Claude Code / Codex / Kitkit 等)能够在对话之外直接读取和回写每个 Session 的数据。 -**合成后结果固化入存储**:session 创建时结算一次,不随 `sessionDefaults` 的后续变化而改变。 +### 4.1 拓扑与 Session 列表 -### 3.5 `skills` 白名单的合成 +```typescript +const roots = await agent.listRoots() // TopologyNode[] +const forest = await agent.getTopology() // SessionTreeNode[](嵌套森林) +const node = await agent.getTopologyNode(sessionId) // 单个 TopologyNode +const sessions = await agent.listSessions({ status: 'active' }) // SessionMeta[] +``` -`skills` 字段遵循字段级覆盖,不做合并: +### 4.2 单个 Session 视图 ```typescript -// sessionDefaults.skills = undefined(继承全局) -// ForkProfile.skills = ['search', 'summarize'] -// → 子 session skills = ['search', 'summarize'] - -// EngineForkOptions.skills = [] -// → 子 session skills = [](禁用所有 skill,优先级最高) +const view = await agent.getSessionMetadata(sessionId) +// view.memory — string | null(持久;不进 send 上下文) +// view.insight — string | null(一次性 inbox;下次 send 注入并 clear) ``` -### 3.6 `topologyParentId` 与 `sourceSessionId` 的区别 +### 4.3 批量 digest ```typescript -await agent.forkSession(currentSessionId, { - label: '子任务', - topologyParentId: rootId, // 拓扑树上挂在 root 下(星空图展示位置) - // context 来源仍是 currentSessionId(sourceSessionId = currentSessionId) -}) - -// child.parentId = rootId (拓扑父节点) -// child.sourceSessionId = currentSessionId (上下文来源) +const digests = await agent.listSessionDigests({ status: 'active' }) +// digests[i] = { id, label, status, memory, insight } ``` -当不传 `topologyParentId` 时,`parentId` 和 `sourceSessionId` 都等于 `sessionId`。 +应用层把这份数据喂给反思层 LLM,由它产出 per-session insight,再调用 `agent.putInsight` 定向回写。完整模式见 skill `session-usage`。 -### 3.7 使用 ForkProfile +### 4.4 L3 消息读取 ```typescript -// 代码发起(指定 profile 名称) -await agent.forkSession(sessionId, { - label: '北美市场专家', - profile: 'region-expert', - profileVars: { region: '北美' }, - // 可叠加 EngineForkOptions 字段覆盖 profile 的部分配置 - systemPrompt: '请特别关注科技行业', // 在 profile 的 systemPromptMode 下合成 -}) +const messages = await agent.listMessages(sessionId, { limit: 100 }) +``` + +### 4.5 写入 -// LLM 发起:LLM 调用 stello_create_session tool 时传 profile 参数(自动) -// 需要 capabilities.profiles 中注册了对应 profile +```typescript +await agent.putMemory(sessionId, '当前进展摘要...') // 持久 memory(替换语义) +await agent.putInsight(sessionId, '需要重新评估方向...') // 一次性 insight(send 消费后 clear) +await agent.clearInsight(sessionId) // 主动清除 ``` +未在 agent 创建时注入 `storage` 时,这些方法会抛错。 + --- -## 4. Runtime 管理(多连接场景) +## 5. Runtime 管理(多连接场景) 适用于 WebSocket 等多客户端连接场景,通过引用计数管理 Engine 生命周期。 -### 4.1 Attach / Detach - ```typescript await agent.attachSession(sessionId, connectionId) // WS 连接建立 await agent.detachSession(sessionId, connectionId) // WS 连接断开 -``` - -**语义**: -- 第一个 holder attach 时创建 Engine -- 最后一个 holder detach 后,按 `recyclePolicy.idleTtlMs` 决定回收时机 - -### 4.2 查询状态 -```typescript agent.hasActiveEngine(sessionId) // 是否有活跃 Engine agent.getEngineRefCount(sessionId) // 当前引用计数 ``` -### 4.3 回收策略 +**回收策略**: ```typescript createStelloAgent({ runtime: { resolver: myResolver, - recyclePolicy: { idleTtlMs: 30_000 }, // 最后 holder detach 后 30s 回收(默认 0 = 立即) + recyclePolicy: { idleTtlMs: 30_000 }, }, }) @@ -259,83 +224,71 @@ agent.updateConfig({ runtime: { idleTtlMs: 60_000 } }) --- -## 5. 典型使用模式 +## 6. 典型使用模式 -### 5.1 最简单的单 Session 对话 +### 6.1 单 root 对话 ```typescript -const mainNode = await agent.createMainSession({ label: 'Main' }) -await agent.enterSession(mainNode.id) - -const r1 = await agent.turn(mainNode.id, '你好') -const r2 = await agent.turn(mainNode.id, '继续上个话题') - -await agent.leaveSession(mainNode.id) +const root = await agent.createSession({ label: 'Main' }) +await agent.enterSession(root.id) +await agent.turn(root.id, '你好') +await agent.turn(root.id, '继续上个话题') +await agent.leaveSession(root.id) ``` -### 5.2 代码驱动的并行 Fork +### 6.2 代码驱动的并行 Fork ```typescript -await agent.enterSession(mainNode.id) -await agent.turn(mainNode.id, '我需要研究三个市场') - -// 并行创建子 session -const [child1, child2, child3] = await Promise.all([ - agent.forkSession(mainNode.id, { - label: '美国市场', - systemPrompt: '你是美国市场专家', - skills: ['search'], - }), - agent.forkSession(mainNode.id, { - label: '欧洲市场', - systemPrompt: '你是欧洲市场专家', - skills: ['search'], - }), - agent.forkSession(mainNode.id, { - label: '亚洲市场', - systemPrompt: '你是亚洲市场专家', - skills: ['search'], - }), +const root = await agent.createSession({ label: 'Main' }) +await agent.enterSession(root.id) +await agent.turn(root.id, '我需要研究三个市场') + +const children = await Promise.all([ + agent.forkSession(root.id, { label: '美国市场', systemPrompt: '你是美国市场专家' }), + agent.forkSession(root.id, { label: '欧洲市场', systemPrompt: '你是欧洲市场专家' }), + agent.forkSession(root.id, { label: '亚洲市场', systemPrompt: '你是亚洲市场专家' }), ]) -// 并行对话(不同 sessionId 之间天然并行安全) await Promise.all( - [child1, child2, child3].map(async (child) => { + children.map(async (child) => { await agent.enterSession(child.id) await agent.turn(child.id, '分析半导体供应链') await agent.leaveSession(child.id) // 触发 consolidation - }) + }), ) ``` -### 5.3 使用 ForkProfile 的分角色 Fork +### 6.3 多 root 并存(森林) ```typescript -// 创建时注册 profile -profiles.register('regional-expert', { - systemPromptFn: (vars) => `你是${vars.region}地区的留学顾问,只负责${vars.region}选校。`, - systemPromptMode: 'preset', - consolidateFn: createDefaultConsolidateFn('提炼该地区的选校建议', llmCall), - skills: ['search', 'school-data'], -}) +// 独立的研究/写作两条线,互不影响 +const research = await agent.createSession({ label: 'Research' }) +const writing = await agent.createSession({ label: 'Writing' }) -// fork 时引用 profile + 传模板变量 -const usaExpert = await agent.forkSession(mainNode.id, { - label: '美国选校专家', - profile: 'regional-expert', - profileVars: { region: '美国' }, -}) +await agent.enterSession(research.id) +await agent.turn(research.id, '调研材料 ...') -const ukExpert = await agent.forkSession(mainNode.id, { - label: '英国选校专家', - profile: 'regional-expert', - profileVars: { region: '英国' }, - // 在 profile 基础上叠加额外约束 - llm: ukSpecializedLlm, -}) +await agent.enterSession(writing.id) +await agent.turn(writing.id, '基于已有材料写一份 ...') + +const all = await agent.listRoots() // 两个 root 都会出现 ``` -### 5.4 WebSocket 连接管理 +### 6.4 外部 reflection 循环(自行实现 integrate) + +```typescript +async function reflect(agent: StelloAgent, llm: LLMAdapter): Promise { + const digests = await agent.listSessionDigests({ status: 'active' }) + // ... 应用层 prompt 把 digests 喂给 llm,解析出 per-target insight ... + for (const [id, content] of Object.entries(insightsByTarget)) { + await agent.putInsight(id, content) + } +} +``` + +详见 stello-agent-creation §7。 + +### 6.5 WebSocket 连接管理 ```typescript ws.on('connection', async (socket) => { @@ -363,94 +316,58 @@ ws.on('connection', async (socket) => { }) ``` -### 5.5 监听 Tool 调用(审计/UI) - -```typescript -const result = await agent.turn(sessionId, input, { - onToolCall: (tc) => { - ws.send(JSON.stringify({ type: 'tool_start', name: tc.name })) - }, - onToolResult: (tr) => { - ws.send(JSON.stringify({ type: 'tool_end', name: tr.name })) - }, -}) -``` - ---- - -## 6. 读写底层数据 - -### 6.1 拓扑树查询 - -```typescript -const root = await agent.sessions.getRoot() // 根节点(TopologyNode) -const node = await agent.sessions.getNode(nodeId) // 单个节点 -const children = await agent.sessions.getChildren(nodeId) // 子节点列表 - -// TopologyNode 结构 -// node.id — Session ID -// node.parentId — 拓扑父节点 ID(null = 根) -// node.sourceSessionId — fork 时的上下文来源(可能 ≠ parentId) -// node.depth — 层级深度(根 = 0) -// node.children — 子节点 ID 列表 -// node.label — 显示名称 -``` - -### 6.2 记忆读写 - -```typescript -const mem = agent.memory - -// L1 核心档案(全局键值) -await mem.writeCore('user.name', '张三') -const name = await mem.readCore('user.name') - -// L2 记忆(Session 级) -const l2 = await mem.readMemory(sessionId) - -// L3 对话记录 -const records = await mem.readRecords(sessionId) - -// 组装上下文(给 LLM 的完整上下文) -const ctx = await mem.assembleContext(sessionId) -// ctx.core — L1 全局键值 -// ctx.memories — 继承链上的 L2 列表 -// ctx.currentMemory — 当前 session 的 L2 -// ctx.scope — 当前 session 的 scope(来自 memory engine) -``` - --- ## 7. 并发语义 - **同 sessionId 内串行**:同一 session 上的 turn() 不会并发执行 - **不同 sessionId 之间并行**:可同时在多个 session 上 turn() -- **所有异步副作用 fire-and-forget**:consolidation / integration / hooks 不阻塞 turn() 返回 +- **所有异步副作用 fire-and-forget**:consolidation / hooks 不阻塞 turn() 返回 - **错误不中断对话**:副作用抛错时 emit error 事件,对话循环继续 --- ## 8. 公开方法速查 +### 编排 + | 方法 | 返回值 | 说明 | |------|--------|------| -| `createMainSession(opts?)` | `Promise` | 创建根节点,固化 mainSessionConfig | +| `createSession({ parentId?, label? })` | `Promise` | 创建拓扑节点(不传 parentId 即新 root;多 root 合法) | | `enterSession(id)` | `Promise` | 进入 session,触发 bootstrap | | `turn(id, input, opts?)` | `Promise` | 同步对话轮次(含 tool call 循环) | | `stream(id, input, opts?)` | `Promise` | 流式对话轮次 | | `leaveSession(id)` | `Promise<{ sessionId }>` | 离开 session,触发 consolidation 调度 | | `forkSession(id, opts)` | `Promise` | 创建子 session,执行配置合成链 | | `archiveSession(id)` | `Promise<{ sessionId }>` | 归档 session | +| `consolidateSession(id)` | `Promise` | 手动触发该 session 的 consolidation | | `attachSession(id, holderId)` | `Promise` | 附着 runtime 持有者 | | `detachSession(id, holderId)` | `Promise` | 释放 runtime 持有者 | | `hasActiveEngine(id)` | `boolean` | 是否有活跃 Engine | | `getEngineRefCount(id)` | `number` | 当前引用计数 | -| `updateConfig(patch)` | `void` | 热更新运行时配置(仅值类型字段) | +| `updateConfig(patch)` | `void` | 热更新运行时配置 | + +### Orchestrator-facing 数据 SDK(需注入 `storage`) -只读属性: +| 方法 | 返回值 | 说明 | +|------|--------|------| +| `listSessions(filter?)` | `Promise` | 列出所有 session | +| `listRoots()` | `Promise` | 列出所有 root | +| `getTopology()` | `Promise` | 完整森林(嵌套树) | +| `getTopologyNode(id)` | `Promise` | 单个节点 | +| `getSessionMetadata(id)` | `Promise<{ memory, insight }>` | 单 session 的 memory + insight | +| `listSessionDigests(filter?)` | `Promise` | 批量收集所有 Session 的 digest | +| `listMessages(id, options?)` | `Promise` | 读取 L3 消息 | +| `putMemory(id, content)` | `Promise` | 写入 memory | +| `putInsight(id, content)` | `Promise` | 写入 insight(一次性) | +| `clearInsight(id)` | `Promise` | 清除 insight | + +### 只读属性 | 属性 | 类型 | 说明 | |------|------|------| | `config` | `StelloAgentConfig` | 归一化后的完整配置 | -| `sessions` | `SessionTree` | 拓扑树,可做查询 | -| `memory` | `MemoryEngine` | 记忆引擎,可读写数据 | +| `sessions` | `SessionTree` | 拓扑树 | +| `memory` | `MemoryEngine` | 记忆引擎 | +| `storage` | `SessionStorage \| undefined` | 数据存储(未注入时 data-IO 方法不可用) | +| `profiles` | `ForkProfileRegistry \| undefined` | Fork 模板注册表 | diff --git a/.agents/skills/stello-usage/SKILL.md b/.agents/skills/stello-usage/SKILL.md index f34dabc..0e19600 100644 --- a/.agents/skills/stello-usage/SKILL.md +++ b/.agents/skills/stello-usage/SKILL.md @@ -1,6 +1,6 @@ --- name: stello-usage -description: Stello 仓库总览入口。快速理解各包的关系、推荐入口、编排模型。 +description: Stello 仓库总览入口。快速理解各包的关系、推荐入口、编排模型、单一 Session 模型。 --- # Stello 使用总览 @@ -9,8 +9,8 @@ description: Stello 仓库总览入口。快速理解各包的关系、推荐入 ## 包结构 -- `@stello-ai/session` — 单个 Session 原语层(send / stream / consolidate / integrate) -- `@stello-ai/core` — 编排层(StelloAgent / SessionOrchestrator / Engine) +- `@stello-ai/session` — 单个 Session 原语层(send / stream / consolidate) +- `@stello-ai/core` — 编排层(StelloAgent / SessionOrchestrator / Engine / SessionTree 拓扑) - `@stello-ai/server` — 服务化适配层(PG 持久化 + REST/WS + 多租户 Space) - `@stello-ai/visualizer` — 可视化层(星空图) @@ -26,10 +26,20 @@ Server 层承接 StelloAgent,不重写编排逻辑。 --- +## 单一 Session 模型 + +Stello 内部只有**一种 Session**。对话的起点是一个 `parentId === null` 的 root session,通过 `agent.createSession()` 创建(不传 `parentId`);后续分支用 `agent.forkSession()` 创建子 session(`parentId` 指向父节点)。 + +拓扑允许多 root —— 同一个 agent 下可以并存多棵互相独立的对话树(森林)。所有 Session 共用同一套上下文组装规则、同一套 `SessionStorage` 接口;root 不具备任何特殊运行时行为,差异只体现在 `TopologyNode.parentId` 上。 + +跨 Session 的"全局意识层"由**应用层**承载——读取所有 Session 的 digest(memory + insight),用任意 LLM 反思后通过 `agent.putInsight(targetId, content)` 定向回写。详见 skill `session-usage`。 + +--- + ## 编排模型 ``` -StelloAgent(门面) +StelloAgent(门面 + orchestrator-facing 数据 SDK) → SessionOrchestrator(多 Session 协调) → EngineRuntimeManager(runtime 生命周期) → DefaultEngineFactory(内联 consolidation 触发逻辑,闭包注入 hooks) @@ -47,10 +57,10 @@ StelloAgent(门面) `@stello-ai/core` 通过 `StelloAgentSessionConfig` 接入 `@stello-ai/session`: -- `sessionResolver` / `mainSessionResolver` — 按 ID 解析真实 Session(fn 在 Session 创建时绑定,不在 config 中传入) +- `sessionLoader(sessionId)` — 按 ID 解析真实 Session 实例(所有 Session 共用同一个 loader) - `serializeSendResult` / `toolCallParser` — 序列化与工具解析 -Session 团队负责单 Session 实现,Core 负责把它装成 Agent 应用。 +外加顶层 `StelloAgentConfig.storage` 用于 data-IO SDK(`listSessionDigests` / `putMemory` / `putInsight` 等)。应用层需保证 `sessions`(拓扑)与 `storage`(内容)指向同一份持久化后端。 --- @@ -64,4 +74,4 @@ Session 团队负责单 Session 实现,Core 负责把它装成 Agent 应用。 ## 推荐继续阅读 -Skills:stello-agent-creation / stello-agent-usage / orchestrator-usage / engine-design / scheduler-design / session-usage / server-design +Skills:stello-agent-creation / stello-agent-usage / orchestrator-usage / engine-design / scheduler-design / session-usage / fork-design / storage-design / server-design diff --git a/.agents/skills/storage-design/SKILL.md b/.agents/skills/storage-design/SKILL.md index 2f7a735..f604f0c 100644 --- a/.agents/skills/storage-design/SKILL.md +++ b/.agents/skills/storage-design/SKILL.md @@ -1,84 +1,102 @@ --- name: storage-design -description: 存储接口的分层设计原则、SessionMeta 与 TopologyNode 解耦、数据流向。触发条件:理解或实现 StorageAdapter。 +description: 存储接口的设计原则、SessionMeta 与 TopologyNode 解耦、上下文槽位、单一 SessionStorage 接口。触发条件:理解或实现 SessionStorage / SessionTree。 --- ## 核心原则 -**Session 是纯对话单元,不感知树结构。** 存储接口按消费者职责细分。 +**Session 是纯对话单元,不感知树结构。** 数据职责切成两条独立的线: ---- - -## 两层存储接口 +- `SessionStorage` — 单个 Session 的内容数据(消息、上下文槽位等),由 `@stello-ai/session` 定义 +- `SessionTree` — 拓扑结构(节点关系、固化配置),由 `@stello-ai/core` 定义 -### SessionStorage — 单个 Session 的数据操作 +两者由应用层各自实现并注入;常见做法是共享同一份持久化后端,但接口分离让 Session 层(运行单条对话)和 Orchestrator 层(管理整棵森林)的职责互不耦合。 -普通 Session 注入此接口。只能操作自身的数据,无法感知其他 Session 的存在。 - -职责:Session 元数据 CRUD、L3 对话记录追加/查询、上下文槽位(system prompt / insight / memory)读写、事务支持。 +--- -### MainStorage extends SessionStorage — Main Session 额外能力 +## 单一 `SessionStorage` 接口 -Main Session 注入此接口。除了自身数据操作外,还能: -1. 扁平收集所有子 Session 的 L2(用于 integration,不走树) -2. 操作拓扑树节点(用于前端渲染) -3. 读写全局键值(L1-structured) -4. 列举 Session(管理用) +所有 Session(含 root)共用同一个接口。Root 没有特权方法——它就是一个 `parentId === null` 的普通 Session。 -### 为什么按消费者分接口而不是按数据结构分 +职责: +- Session 元数据 CRUD(`getSession / putSession / listSessions`) +- L3 对话记录追加/查询/裁剪(`appendRecord / listRecords / trimRecords`) +- 三个上下文槽位的读写(`getSystemPrompt/putSystemPrompt` / `getInsight/putInsight/clearInsight` / `getMemory/putMemory`) +- 事务(`transaction(fn)`) -同一个实现类可以同时实现两个接口(共享数据库),接口分离只是约束注入范围。普通 Session 拿到 SessionStorage 后无法调用 `getAllSessionL2s()`,从类型层面保证子 Session 不感知其他 Session。 +批量收集由 `StelloAgent.listSessionDigests()` 提供——它在 orchestrator-facing SDK 上聚合 `SessionTree.listAll()` 与 `SessionStorage.getMemory/getInsight`,由应用层在 agent 顶层注入 `storage: SessionStorage` 来启用。 --- ## SessionMeta 与 TopologyNode 解耦 -树状关系完全由 TopologyNode 维护,SessionMeta 不关心自己在树中的位置。 +树状关系完全由 `TopologyNode` 维护,`SessionMeta` 不关心自己在树中的位置。 -- **SessionMeta**:对话运行时数据(id、label、status、turnCount 等),无 parentId/children/depth -- **TopologyNode**:纯树结构(id、parentId、children、refs、depth、index、label) +- **SessionMeta**(在 `SessionStorage`):对话运行时数据(id、label、status、turnCount 等),无 parentId/children/depth +- **TopologyNode**(在 `SessionTree`):纯树结构(id、parentId、children、refs、depth、index、label、sourceSessionId) -两种类型从同一底层存储投影而来,但消费者不同:SessionMeta 面向 Session 层,TopologyNode 面向编排层和前端渲染。 +两种类型由两条独立接口提供。底层存储实现可以共享一张表/同一份 JSON,但消费侧(Session 层 vs 拓扑 / 前端)拿到的是经过职责裁剪的视图。 ### 两个包的 SessionMeta -`@stello-ai/session` 和 `@stello-ai/core` 各有自己的 SessionMeta,字段不完全相同: -- session 包的有 `role`,无 `scope`/`turnCount`/`lastActiveAt` -- core 包的有 `scope`/`turnCount`/`lastActiveAt`,无 `role` +`@stello-ai/session` 和 `@stello-ai/core` 各有自己的 SessionMeta,字段集合不完全一致;持久化层(PG / FS)通常存超集,由各 adapter 按需投影。 + +--- + +## 上下文槽位 + +每个出现在 Session.send() 上下文中的元素都有对应的专用槽位(一对 get/put 方法),不复用通用键值: -PG 存储层存超集,各 adapter 按需投影。 +| 槽位 | 写入者 | send() 消费 | 生命周期 | +|------|--------|------|---------| +| `systemPrompt` | fork 合成链固化 / 应用层 | 每次 send 注入 | 持久 | +| `insight` | 应用层(`agent.putInsight`) | 注入一次即 clear | 一次性 inbox | +| `memory` | 应用层(consolidate 写入 / `agent.putMemory`) | **不进入 send 上下文** | 持久(供 orchestrator 反思) | + +`memory` 是**外部视角的槽位**——它是 orchestrator 层(应用层)用来对 Session 做综合反思的输入,但不会注入 Session 自身的 LLM 上下文。Session 的 LLM 看不到自己的 memory。 --- ## 数据流向 ``` -普通 Session(注入 SessionStorage) - send() → 读 system prompt + insight + L3 历史 → 调 LLM → 写 L3 - consolidate() → 读 L3 + 当前 L2 → 调 ConsolidateFn → 写新 L2 +Session.send() + storage.getSystemPrompt → storage.getInsight → storage.listRecords → LLM → storage.appendRecord + ↓ + storage.clearInsight(若 insight 被消费) + +Session.consolidate(fn) + storage.listRecords + storage.getMemory → fn → storage.putMemory + +Engine.forkSession(options) + 1. sessions.createSession({ parentId, label, sourceSessionId }) ← 拿到 ID + 2. sessions.putConfig(childId, serializable) ← 固化 systemPrompt + skills + 3. session.fork({ id: childId, context, prompt }) ← 创建 Session 实例 + +应用层反思层(自行实现,每 N 分钟 / on demand) + agent.listSessionDigests() → 收集所有 Session 的 {id, label, memory, insight} + → 任意 LLM → 派生 per-target insight + → agent.putInsight(targetId, content) +``` -Main Session(注入 MainStorage) - send() → 读 system prompt + synthesis + L3 历史 → 调 LLM → 写 L3 - integrate() → 扁平收集所有子 L2 → 调 IntegrateFn → 写 synthesis + 推送 insights +--- -编排层 fork 流程: - 1. Session 层创建 Session(putSession) - 2. 拷贝上下文 - 3. 存储层写入 TopologyNode(putNode)—— 两个独立操作 +## SessionTree 接口要点 -前端渲染: - 懒加载树节点 → 点击加载 Session 详情 -``` +- `createSession({ parentId?, label?, sourceSessionId? })` —— 唯一节点创建入口;`parentId` 缺省即建 root(`parentId === null`),多 root 合法 +- `listRoots()` —— 列出所有 root,应用层据此显示森林 +- `getTree()` —— 返回 `SessionTreeNode[]` 森林视图 +- `addRef(from, to)` —— 跨树引用(非父子) +- `getConfig / putConfig` —— 持久化 `SerializableSessionConfig`(只含 `systemPrompt` / `skills`) --- -## 上下文槽位 +## 应用层实现策略 -每个出现在 LLM 上下文中的元素都有对应的专用存储方法(get/put 对),不复用通用键值: +| 后端 | 推荐拆分 | +|------|---------| +| 文件系统(NodeFS) | `SessionTreeImpl` + `InMemoryStorageAdapter`(demo 用法) | +| PostgreSQL | 一个 PG schema,两个 wrapper 类(一个实现 `SessionStorage`,一个实现 `SessionTree`) | +| 多租户 server | 同上,再加 space_id 维度 | -| 上下文元素 | 消费场景 | -|-----------|---------| -| system prompt | 所有 Session | -| insight | 子 Session(Main → 子,消费后清除) | -| L3 历史 | 所有 Session | -| memory(L2 / synthesis) | 子 Session 存 L2,Main Session 存 synthesis | +要点:两个接口的 `id` 必须语义一致(同一个 Session 的 `SessionMeta.id` === `TopologyNode.id`)。应用层在创建/删除 Session 时需保证两条线同步。 diff --git a/CLAUDE.md b/CLAUDE.md index 650b090..7345f94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,46 +6,47 @@ ## 项目定位 -Stello 是开源对话拓扑引擎(TypeScript SDK)。让 AI Agent 将线性对话分裂为树状 Session,跨分支通过 Main Session 传递洞察,整个拓扑可渲染为可交互的星空图。 +Stello 是开源对话拓扑引擎(TypeScript SDK)。让 AI Agent 把线性对话拆分为树状 Session 森林,对外暴露 orchestrator-facing 数据 SDK,跨分支的反思与洞察传递由应用层在 SDK 之上自行实现。整个拓扑可渲染为可交互的星空图。 **仓库**:`github.com/stello-agent/stello` **协议**:Apache-2.0 --- -## 核心理念 — 技能隐喻 +## 核心模型 — 单一 Session -每个子 Session 是一个**技能(Skill)**,Main Session 是**技能调用方(Orchestrator)**。 +Stello 内部只有**一种** Session。对话起点是一个 `parentId === null` 的 root session,通过 `agent.createSession()` 创建;后续分支通过 `agent.forkSession()` 挂在父节点下。多 root 合法——同一 agent 下可以并存互相独立的森林。 -``` -子 Session = Skill - L3 = 技能的详细知识体(内部消费) - L2 = 技能的 description(外部接口,Main Session 消费) - -Main Session = Orchestrator - 读所有子 Session 的 L2 = 知道自己有哪些技能 - synthesis = 对所有 L2 的综合认知 - insights = 定向推送给各子 Session 的建议 -``` +Root 与 child 在运行时完全同构——唯一差异是 `TopologyNode.parentId`。所有 Session 共用: -由此推导出三条核心约束: +- 同一份 `SessionStorage` 接口 +- 同一套上下文组装规则 +- 同一条 fork 合成链 +- 同一个 `sessionLoader` -1. **L2 对子 Session 自身不可见** — L2 是外部描述,不是自用记忆 -2. **Main Session 只读 L2,不读子 Session 的 L3** — Orchestrator 看接口,不看实现 -3. **子 Session 对其他 Session 完全不感知** — 唯一的跨 Session 信息来源是 Main Session 推送的 insights +> 跨 Session 的"全局综合"不是框架职责,由应用层用 `agent.listSessionDigests` / `agent.putInsight` 自行实现。 --- -## 三层记忆模型 +## 三个上下文槽位 + +每个 Session 在 `SessionStorage` 中有三个独立内容槽位: + +| 槽位 | 写入者 | send() 行为 | 生命周期 | +|------|--------|------------|---------| +| `systemPrompt` | fork 合成链固化 / 应用层 | 每次注入 | 持久 | +| `insight` | 应用层(`putInsight`) | 注入一次即 `clearInsight` | 一次性 inbox | +| `memory` | 应用层 / `consolidateFn` 产出 | **不进入 send 上下文** | 持久(供外部反思层消费) | -| 层 | 语义 | 消费者 | -|----|------|--------| -| L3 | 原始对话记录 | 该 Session 自身的 LLM | -| L2 | 技能描述(外部视角) | Main Session(通过 integration cycle) | -| L1-structured | 全局键值 | 应用层直接读写 | -| L1-emergent(synthesis) | Main Session 对所有 L2 的综合提炼 | Main Session 自身 | +**关键不变量**:`memory` 不进入 Session 自身的 LLM 上下文。它是面向外部视角的描述——上层批量收集所有 Session 的 memory 做反思、规划、调度,再通过 `putInsight` 把派生的洞察定向回写给目标 Session。Session 自身不感知这个回路。 + +加上 L3 对话历史(`appendRecord / listRecords`),上下文按以下顺序组装(固定规则,不暴露扩展点): + +``` +system prompt → session_identity(label) → insight(若有,消费后清除) → L3 历史 → 当前用户消息 +``` -**零对话中 LLM 开销**:L2 在 consolidation 时批量生成,不在每轮对话中更新。正在进行中的 Session 没有 L2,对 Main Session 暂时不可见——这是有意为之的取舍。 +当估算 token 数超过 `maxContextTokens * 0.8` 时,闭包注入的 `compressFn` 把历史段压缩为一段 system 摘要,与近期消息拼接。 --- @@ -57,19 +58,20 @@ Main Session = Orchestrator │ REST / WebSocket 服务,多租户,跨语言客户端 │ ├─────────────────────────────────────────────────────────┤ │ 应用层(Application Layer) │ -│ 开发者提供:StorageAdapter · LLMAdapter · system prompt │ -│ · ConsolidateFn · IntegrateFn · 触发时机配置 · 工具定义 │ +│ 开发者提供:SessionStorage · SessionTree · LLMAdapter │ +│ · ConsolidateFn · CompressFn · 工具定义 · reflection 循环│ ├─────────────────────────────────────────────────────────┤ │ 编排层(Orchestration Layer) │ -│ Engine:tool call 循环 · consolidation/integration 调度 │ -│ · Session 切换追踪 · fire-and-forget 异步调度 · 事件 │ +│ StelloAgent:orchestrator-facing 数据 SDK + Engine 调度 │ +│ Engine:tool call 循环 · consolidation 调度 · fork 编排 │ +│ · fire-and-forget 异步副作用 · 事件 │ ├─────────────────────────────────────────────────────────┤ │ Session 层 │ │ 独立对话单元:send() 单次 LLM 调用 · consolidate() │ │ · fork() · 不感知树结构 · 不做 tool call 循环 │ └─────────────────────────────────────────────────────────┘ ↑ 依赖注入 - SessionStorage / MainStorage LLMAdapter + SessionStorage SessionTree LLMAdapter ``` ### Session 层 @@ -77,29 +79,18 @@ Main Session = Orchestrator Session 是**有记忆的对话单元**,与树结构完全解耦。 - **send()**:组装上下文 → 单次 LLM 调用 → 存 L3 → 返回响应 -- **consolidate(fn)**:暴露给上层调度 L3→L2 提炼 -- **fork()**:一次性继承源 Session 上下文,创建独立新 Session +- **consolidate(fn)**:暴露给上层调度 L3 → memory 提炼 +- **fork(options)**:按 `context: 'none' | 'inherit' | ForkContextFn` 一次性继承上下文,创建独立新 Session(id 由调用方传入,topology-first) - **SessionMeta 不含 parentId / depth** — Session 不知道自己在树中的位置 -两种 Session 接口,不同的上下文组装规则: - -| | Session(子) | MainSession(全局意识层) | -|--|--------------|-------------------------| -| 上下文 | system prompt + insights + L3 + msg | system prompt + synthesis + L3 + msg | -| 记忆 | `memory()` = L2 | `synthesis()` = integration 产出 | -| 提炼 | `consolidate(fn)` L3→L2 | `integrate(fn)` 所有 L2→synthesis+insights | -| insights | 被动接收 | 主动推送 | - ### 编排层 -Engine 是 Session 原语之上的**执行周期管理器**,不创造新能力: +`StelloAgent` 是面向使用者的最高层对象,提供两类能力: -- **turn()**:Session.send() × N(tool call 循环)+ 调度判断 -- **Consolidation 调度**:onSwitch / everyNTurns / onArchive / manual -- **Integration 调度**:afterConsolidate / onSwitch / everyNTurns / manual -- **fork 编排**:创建拓扑节点(topology-first ID)+ 调用 `session.fork({ id })` 创建 Session -- **内置 tool**:通过 CompositeToolRuntime 统一调度内置 tool(stello_create_session、activate_skill)与用户 tool -- **所有异步副作用 fire-and-forget**,不阻塞 turn() 返回 +1. **运行时编排**:`createSession / enterSession / turn / stream / forkSession / leaveSession / archiveSession / consolidateSession` 以及 runtime 引用计数管理(`attachSession / detachSession`) +2. **Orchestrator-facing 数据 SDK**:`listRoots / getTopology / listSessions / listSessionDigests / getSessionMetadata / listMessages / putMemory / putInsight / clearInsight` + +Engine 在 `StelloAgent` 内部驱动 turn / tool call 循环 / consolidation 调度 / fork 编排,所有异步副作用 fire-and-forget,不阻塞 turn() 返回。 ### HTTP / SDK 层 @@ -109,31 +100,33 @@ Engine 是 Session 原语之上的**执行周期管理器**,不创造新能力 ## 存储设计 -存储接口按消费者职责分层,不是按数据结构分: +存储职责切成两条独立的线,由应用层各自实现并注入: + +| 接口 | 包 | 职责 | +|------|----|----| +| **SessionStorage** | `@stello-ai/session` | 单 Session 的内容数据:L3、systemPrompt、insight、memory;事务 | +| **SessionTree** | `@stello-ai/core` | 拓扑结构:节点关系(含 sourceSessionId)、固化 `SerializableSessionConfig`(仅 `systemPrompt` / `skills`)、跨树引用 | -| 接口 | 注入对象 | 职责 | -|------|---------|------| -| **SessionStorage** | 普通 Session | 单个 Session 的数据:L3、system prompt、insight、L2 | -| **MainStorage** (extends SessionStorage) | Main Session + 编排层 | 额外:`getAllSessionL2s()` 批量收集、拓扑树、Session 列举、全局键值 | +两者通常共享同一份持久化后端,但接口分离让 Session 层(运行单条对话)与编排层(管理整棵森林)的职责互不耦合。两个接口的 `id` 必须语义一致——同一 Session 的 `SessionMeta.id === TopologyNode.id`。 -### Session 与拓扑树解耦 +`StelloAgent.listSessionDigests` 等批量 API 在 SDK 上组合两条线(`SessionTree.listAll()` × `SessionStorage.getMemory/getInsight`),存储层不需要专用方法。 -Session 是独立对话单元,不知道树的存在。树状拓扑由 **TopologyNode**(`{ id, parentId, label }`)独立维护在 MainStorage 中。 +--- + +## Fork 合成链 -- **前端渲染**:通过 `getChildren(parentId)` 懒加载树节点 -- **Integration**:通过 `getAllSessionL2s()` 扁平收集所有 L2,不走树 -- **fork**:编排层创建 Session(Session 层)+ 写入 TopologyNode(存储层),两个独立操作 +fork 时按顺序合成 `SessionConfig`,后者覆盖前者: -### 上下文组装依赖的存储槽位 +``` +sessionDefaults → 父 session 固化 config → ForkProfile → EngineForkOptions +``` -每个出现在 LLM 上下文中的元素都有对应的专用存储接口: +- **持久化边界**:合成结果只把 `systemPrompt` + `skills` 写入 `sessions.putConfig`。其余字段(llm / tools / consolidateFn / compressFn)是运行时引用,每次 fork 现场合成 +- **`systemPrompt` 三种合成模式**:`preset` / `prepend`(默认)/ `append`,由 `ForkProfile.systemPromptMode` 控制 +- **`skills` 三态语义**:`undefined`(继承下层)/ `[]`(显式禁用,可覆盖下层非空值)/ `['a','b']`(白名单) +- **`topologyParentId` 与 `sourceSessionId` 分离**:拓扑挂靠位置和上下文继承来源可独立指定 -| 上下文元素 | 存储方法 | 说明 | -|-----------|---------|------| -| system prompt | `getSystemPrompt / putSystemPrompt` | 全局共享 | -| insights | `getInsight / putInsight` | Main → 子 Session 定向推送 | -| L3 历史 | `appendRecord / listRecords` | 原始对话记录 | -| L2 / synthesis | `getMemory / putMemory` | 子 Session 存 L2,Main Session 存 synthesis | +详见 skill `fork-design`。 --- @@ -141,37 +134,39 @@ Session 是独立对话单元,不知道树的存在。树状拓扑由 **Topolo | 注入 | 说明 | |------|------| -| SessionStorage / MainStorage | 持久化抽象(按消费者职责分层) | +| SessionStorage | 单 Session 数据持久化 | +| SessionTree | 拓扑与固化配置持久化 | | LLMAdapter | LLM 接口(消息数组、tool use、可选 stream) | -| ConsolidateFn | L3→L2 转换逻辑,应用层定义 L2 格式,fn 自行选择 LLM tier | -| IntegrateFn | all L2s → synthesis + insights,与 ConsolidateFn 配对,fn 自行选择 LLM tier | -| system prompt | 全局共享 | -| ToolRegistry | 应用层工具注册(`register(tool)`),Engine 通过 CompositeToolRuntime 自动合并内置 + 用户 tool | -| SessionRuntimeResolver | Session 加载(`resolve`),Engine 通过 `session.fork()` 创建子 Session | -| ForkProfile | 预注册的 fork 配置模板(systemPrompt 合成策略 + LLM/tools/context/skills 预设) | +| ConsolidateFn | L3 → memory 的转换逻辑;应用层定义 memory 格式,fn 自行选择 LLM tier | +| CompressFn | 超上下文阈值时的摘要压缩逻辑;fn 自行选择 LLM tier | +| sessionDefaults | 所有 Session 的 agent 级默认 SessionConfig,fork 合成链最低优先级 | +| ToolRegistry | 应用层工具注册;内置 tool(`createSessionTool()` / `activateSkillTool(skills)`)由应用层显式 opt-in 加入 | +| SkillRouter | Skill 注册表 | +| ForkProfileRegistry | 预注册的 fork 配置模板(systemPrompt 合成策略 + LLM/tools/context/skills 预设) | +| SessionRuntimeResolver / sessionLoader | Session 加载入口;所有 Session(含 root)走同一条路径 | -ConsolidateFn 和 IntegrateFn 是**配对函数**——ConsolidateFn 输出某种格式的 L2,IntegrateFn 读取该格式。框架对 L2 内容格式完全无感知。 +框架对 memory 内容格式完全无感知——`ConsolidateFn` 输出什么格式,应用层的 reflection 循环就消费什么格式。 --- ## 设计决策(已确认,不再讨论) -1. L2 对子 Session 自身不可见 — L2 是外部描述 -2. Main Session 只读 L2,不读子 Session 的 L3 -3. insights 替换策略(不追加)— 每次 integration 给出最新完整判断 +1. memory 不进入 Session 自身上下文 — 它是外部视角的描述 +2. 跨 Session 信息传播走 `insight` 一次性 inbox — 应用层通过 `putInsight` 定向回写 +3. insights 替换策略(不追加)— 写入即覆盖上一次 4. 回调一次性注入(immutable config) -5. consolidate/integrate 均 fire-and-forget — 不阻塞对话 +5. consolidate fire-and-forget — 不阻塞对话 6. 错误处理:emit error,不中断对话周期 7. Session 上下文组装为固定规则,不暴露 assembler 扩展点 -8. fork 一次性继承后独立 — 跨 Session 通信靠 insights +8. fork 一次性继承后独立 — 跨 Session 通信靠 insight 9. Session 做单次 LLM 调用 — tool call 循环由编排层驱动 10. Session 与树结构解耦 — SessionMeta 无 parentId/depth,拓扑由 TopologyNode 独立维护 -11. 存储接口按职责分层 — SessionStorage / MainStorage -12. ConsolidateFn / IntegrateFn 不注入 LLM — 应用层通过闭包自行选择 tier -13. 内置 tool 与用户 tool 统一走 ToolRegistryEntry + CompositeToolRuntime — Engine 构造时自动注册,应用层无需手动参与 -14. Fork = 创建独立 Session + 添加拓扑节点 — 不需要"从父 Session fork",parentId 只是拓扑关系元数据 -15. Engine 编排 fork 的两步 — 创建拓扑节点(`sessions.createChild` 生成 ID)+ 调用 `session.fork({ id })` 创建 Session(topology-first ID) -16. 应用层工具注册仿照 SkillRouter 模式 — `ToolRegistry.register(tool)`,Engine 自动管理定义和执行 dispatch +11. 单一 Session 模型 — root 与 child 同构,差异仅在 `parentId` +12. ConsolidateFn / CompressFn 不注入 LLM — 应用层通过闭包自行选择 tier +13. 内置 tool 由应用层显式 opt-in — `createSessionTool()` / `activateSkillTool(skills)` 作为 ToolRegistry 构造参数 +14. Fork = 创建独立 Session + 添加拓扑节点 — `parentId` 只是拓扑关系元数据 +15. Engine 编排 fork 两步 — `sessions.createSession({ parentId })` 拿到 ID + `session.fork({ id })` 创建 Session 实例 +16. 全局 reflection 由应用层在 `listSessionDigests` / `putInsight` 之上实现 — 框架不持有跨 Session 状态 17. 层级依赖单向向下 — Engine 不 import Orchestrator,共享类型定义在 `types/` 层 --- @@ -197,7 +192,7 @@ ConsolidateFn 和 IntegrateFn 是**配对函数**——ConsolidateFn 输出某 ## Skills 持久化规则 -项目级别的架构认知、设计决策等持久化知识,统一通过 `.claude/skills/` 目录管理。 +项目级别的架构认知、设计决策等持久化知识,统一通过 `.claude/skills/` 目录管理(实际位置为 `.agents/skills/`,前者是符号链接)。 ### 组织方式 @@ -206,18 +201,19 @@ ConsolidateFn 和 IntegrateFn 是**配对函数**——ConsolidateFn 输出某 - 遇到与当前 skill 认知不匹配的理解 → **先与用户澄清**,确认后再更新对应 skill - 不要在对话中默认自己的理解是正确的,skill 是唯一的认知基线 -### 内容规范 — 只写不变的,不写易变的 +### 内容规范 — 只描述当前状态 -Skills 是**方向指导**,不是代码文档。核心原则:**如果内容会随实现变化而过时,就不该写进 skill。** +Skills 是**方向指导**,不是代码文档,也不是迁移日志。核心原则: -**应该写(持久):** +**只写当前生效的设计**: - 设计决策和背后的理由(why) - 架构约束和不变量(invariants) - 使用模式和推荐做法(how to use) - 职责边界(做什么 / 不做什么) - 与其他层的关系 -**不应该写(易变):** +**不写**: +- "X 已删除"、"不再有 Y"、"取代旧 Z"、"历史上 W" 等迁移叙述——删了的东西就不在 skill 里出现 - 具体方法签名、字段列表(读代码即可) - 文件路径和目录结构(用 glob/grep 查找) - Phase 进度状态、emoji 标记(用 git log) @@ -225,7 +221,7 @@ Skills 是**方向指导**,不是代码文档。核心原则:**如果内容 - 代码块(除非是展示用法的最小示例,且不会频繁变化) - 依赖版本号 -**判断标准:** 如果这条信息 3 个月后可能过时,不写。如果这条信息帮助理解"为什么这样设计",写。 +**判断标准:** 如果这条信息 3 个月后可能过时,不写。如果这条信息帮助理解"为什么这样设计",写。迁移与变更历史属于 CHANGELOG / migration guide,不属于 skill。 ## 降级项(不实现) diff --git a/README.md b/README.md index 69caad7..bd43cad 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,9 @@ ## 🌟 Stello 是什么? -**首个 Agent 认知拓扑引擎。** +**Agent 认知拓扑引擎。** -Stello 是一个开源的认知拓扑引擎,面向 AI Agent 和 AI 应用开发者。它提供对话自动分裂、三层分级记忆、全局意识整合和拓扑可视化四大核心能力。 - -对话按语义自动分裂为独立 Session,形成树状拓扑结构。三层记忆系统在 Session 之间分级继承。全局意识层(Main Session)跨所有分支感知冲突与依赖,并定向推送洞察。整棵认知拓扑渲染为可生长可对话的星空节点图。 +Stello 是一个开源的对话拓扑引擎,面向 AI Agent 和 AI 应用开发者。它把对话切成可分裂的 Session 森林,每个 Session 既有自己的对话历史,又对外暴露可被反思的描述;跨分支的综合洞察由你的应用层用任意 LLM 完成,再通过 SDK 定向回写给目标 Session。整棵拓扑可渲染为可生长可对话的星空节点图。 线性聊天不适合会分叉、递归或需要上下文隔离的工作流。常见问题包括: @@ -49,53 +47,56 @@ Stello 是一个开源的认知拓扑引擎,面向 AI Agent 和 AI 应用开 Stello 的做法是明确拆分三件事: -- **分支执行:** 子 Session 持有自己的 L3 历史 -- **外部描述:** 子 Session 可以把 L3 提炼成供外部消费的 L2 -- **全局整合:** 主 Session 读取所有 L2,产出 synthesis 和 insights +- **分支执行:** 每个 Session 持有自己的 L3 历史 +- **外部描述:** 每个 Session 把对话提炼成 `memory`,供外部 orchestrator 消费 +- **全局综合:** Orchestrator-facing 数据 SDK 让应用层批量收集所有 Session 的 memory,做综合反思后通过 `insight` 定向回写 --- ## 核心能力 - **对话自动分裂** — AI 识别话题分叉时通过工具调用创建子 Session,每个分支有明确 scope -- **三层分级记忆** — L3 原始对话 / L2 技能描述 / L1 全局认知,记忆在层级间流动 -- **全局意识整合** — Main Session 收集所有子 Session 的 L2,生成 synthesis 并推送 insights +- **单一 Session 模型** — root 与 child 同构,差异仅在拓扑位置;多 root 合法(森林) +- **三个内容槽位** — `systemPrompt`(持久注入)/ `insight`(一次性 inbox)/ `memory`(对外描述,不进自身上下文) +- **Orchestrator-facing 数据 SDK** — `listSessionDigests` / `putInsight` 等暴露给外部反思层(你的应用 / Claude Code / Codex 等),跨分支综合由你的应用层用任意 LLM 实现 - **对话中零开销** — 所有记忆提炼异步执行(fire-and-forget),不阻塞对话流程 - **星空图可视化** — 每颗星是一个思考方向,连线是关联,大小映射深度,亮度映射活跃度 -- **完全解耦架构** — 不绑定 LLM / 存储 / UI,Session 与 Topology 分离 +- **完全解耦架构** — 不绑定 LLM / 存储 / UI;Session 内容与 Topology 结构分离注入 --- ## 核心概念 -### 技能隐喻 +### 单一 Session + 应用层 Orchestrator -每个子 Session 可以看作一个拥有私有实现和公开描述的技能。 +每个 Session 可以看作一个拥有私有实现和公开描述的对话单元。 ```text -子 Session - L3 = 该 Session 的原始对话历史 - L2 = 供 Main Session 消费的外部摘要 - -主 Session - synthesis = 对所有子 Session L2 的整合视图 - insights = 定向推送给特定子 Session 的建议 +Session(root 或 child,运行时同构) + L3 = 该 Session 的原始对话历史(自己消费) + memory = 对外描述(应用层 / orchestrator 消费) + insight = 一次性 inbox(被 send 注入并 clear) + +应用层 Orchestrator(不在框架内) + batch read = listSessionDigests({ status: 'active' }) + reflection = 任意 LLM 综合所有 Session 的 memory + targeted push = putInsight(targetSessionId, content) ``` -### 三层记忆 +### 三个内容槽位 -| 层级 | 含义 | 消费者 | -| --- | --- | --- | -| L3 | 原始对话历史 | Session 自身的 LLM | -| L2 | Session 的外部摘要 | Main Session | -| L1 | 全局结构化状态和 synthesis | 应用层 / Main Session | +| 槽位 | 写入者 | 消费者 | 生命周期 | +|------|--------|--------|---------| +| `systemPrompt` | fork 合成链 / 应用层 | Session.send() 注入 | 持久 | +| `insight` | 应用层(`putInsight`) | Session.send() 消费后 `clearInsight` | 一次性 inbox | +| `memory` | 应用层 / `consolidateFn` | 外部反思层(`listSessionDigests`) | 持久(不进 send 上下文) | ### 架构约束 -- 子 Session 不读取自己的 L2。 -- Main Session 读取 L2,不读取子 Session 的 L3。 -- 子 Session 之间不直接通信。 -- 跨 Session 信息通过 Main Session 的 insight 传播。 +- Session 不读取自己的 `memory`(memory 是外部视角的描述)。 +- Session 之间互相不感知。 +- 跨 Session 信息传播走 `insight` 一次性 inbox。 +- 全局反思由应用层在 SDK 之上自行实现——框架不持有跨 Session 状态。 ## 包说明 @@ -109,7 +110,7 @@ Stello 的做法是明确拆分三件事: - 组装 prompt 上下文 - 存储与回放 L3 记录 -- 将 L3 consolidate 为 L2 +- 把对话提炼为 `memory`(consolidate) - 处理支持 streaming 和 tool call 的 LLM 适配器 如果你只需要一个具备记忆能力的单 Session 抽象,优先看这个包。 @@ -119,14 +120,15 @@ Stello 的做法是明确拆分三件事: ### `@stello-ai/core` -负责核心编排: +负责核心编排和 orchestrator-facing 数据 SDK: +- StelloAgent 顶层入口(创建 / 进入 / turn / stream / fork / 数据 SDK) - 带 tool-call loop 的 turn 执行 -- fork 编排 -- consolidation / integration 调度 -- runtime 管理与生命周期 +- fork 编排(topology + Session 两步) +- consolidation 调度 +- runtime 引用计数管理与生命周期 -如果你需要一棵 Session 拓扑,并由 Main Session 统一调度,优先看这个包。 +如果你需要一棵 Session 拓扑加 orchestrator-facing 数据 SDK,优先看这个包。 @@ -181,15 +183,23 @@ import { createStelloAgent } from '@stello-ai/core' const agent = createStelloAgent({ sessions: /* SessionTree 实现 */, + storage: /* SessionStorage 实现(启用 orchestrator-facing 数据 SDK) */, + memory: /* MemoryEngine 实现 */, + capabilities: { + lifecycle, tools, skills, confirm, + }, session: { - llm: /* LLM adapter */, - sessionResolver: async (id) => { - /* 返回 session-compatible runtime */ + sessionLoader: async (id) => { + /* 按 id 返回 Session 实例与固化配置 */ }, }, }) -const result = await agent.turn('main-session-id', '帮我规划一个产品策略') +// 创建对话起点(不传 parentId 即新 root) +const root = await agent.createSession({ label: 'Main' }) + +await agent.enterSession(root.id) +const result = await agent.turn(root.id, '帮我规划一个产品策略') ``` ### 启动 devtools diff --git a/README_EN.md b/README_EN.md index 3192ce9..20ceac6 100644 --- a/README_EN.md +++ b/README_EN.md @@ -34,11 +34,9 @@ Ever feel your AI conversations trapped in a single thread? Your thinking diverg ## 🌟 What is Stello? -**The first Agent Cognitive Topology Engine.** +**An Agent Cognitive Topology Engine.** -Stello is an open-source cognitive topology engine for AI Agent and AI application developers. It provides four core capabilities: auto-splitting conversations, three-layer hierarchical memory, global consciousness integration, and topology visualization. - -Conversations auto-split into independent Sessions by semantics, forming tree-structured topologies. The three-layer memory system inherits hierarchically across Sessions. The global consciousness layer (Main Session) perceives conflicts and dependencies across all branches, pushing targeted insights. The entire cognitive topology renders as a growable, conversable star-node graph. +Stello is an open-source conversation topology engine for AI Agent and AI application developers. It splits conversations into a forest of branchable Sessions—each Session has its own L3 history and also exposes an external description (`memory`) for reflection; cross-branch synthesis runs in *your* application layer with any LLM you choose, then writes targeted `insight` back to specific Sessions through the SDK. The whole topology renders as a growable, conversable star-node graph. Linear chat doesn't fit workflows that branch, recurse, or need context isolation. Common problems include: @@ -49,53 +47,56 @@ Linear chat doesn't fit workflows that branch, recurse, or need context isolatio Stello's approach explicitly separates three things: -- **Branch Execution:** Child Sessions hold their own L3 history -- **External Description:** Child Sessions distill L3 into L2 for external consumption -- **Global Integration:** Main Session reads all L2s, producing synthesis and insights +- **Branch Execution:** Each Session holds its own L3 history +- **External Description:** Each Session distills its conversation into `memory` for external consumption +- **Global Synthesis:** An orchestrator-facing data SDK lets your app batch-collect every Session's memory, run any reflection logic, and write targeted `insight` back --- ## Core Capabilities - **Auto-splitting Conversations** — AI detects topic branches and creates child Sessions via tool calling, each with clear scope -- **Three-layer Memory** — L3 raw records / L2 skill descriptions / L1 global cognition, memory flows between layers -- **Global Synthesis** — Main Session collects all child Session L2s, generates synthesis and pushes insights +- **Single Session Model** — root and child are runtime-isomorphic; the only difference is topology position. Multi-root (forest) is a first-class case +- **Three content slots** — `systemPrompt` (persistent), `insight` (one-shot inbox), `memory` (external description; never injected into Session's own context) +- **Orchestrator-facing Data SDK** — `listSessionDigests` / `putInsight` etc. exposed to external reflection layers (your app / Claude Code / Codex / ...). Cross-branch synthesis is your application's choice of LLM and prompt - **Zero Overhead in Dialogue** — All memory consolidation executes async (fire-and-forget), never blocks conversation flow - **Star Map Visualization** — Each star is a thought direction, connections show relationships, size maps depth, brightness maps activity -- **Fully Decoupled Architecture** — No LLM / storage / UI lock-in, Session and Topology are separate +- **Fully Decoupled Architecture** — No LLM / storage / UI lock-in; Session content and Topology structure are injected independently --- ## Core Concepts -### The Skill Metaphor +### Single Session + Application-Level Orchestrator -Each child Session can be seen as a skill with a private implementation and a public description. +Every Session is a conversation unit with a private implementation and a public description. ```text -Child Session - L3 = The session's raw conversation history - L2 = External summary consumed by Main Session - -Main Session - synthesis = Integrated view of all child Session L2s - insights = Targeted suggestions pushed to specific child Sessions +Session (root or child — runtime-isomorphic) + L3 = raw conversation history (consumed by itself) + memory = external description (consumed by your app / orchestrator) + insight = one-shot inbox (injected then cleared at next send) + +Application-Level Orchestrator (lives outside the framework) + batch read = listSessionDigests({ status: 'active' }) + reflection = any LLM synthesizes all Sessions' memory + targeted push = putInsight(targetSessionId, content) ``` -### Three-layer Memory +### Three content slots -| Layer | Meaning | Consumer | -| --- | --- | --- | -| L3 | Raw conversation history | The session's own LLM | -| L2 | Session's external summary | Main Session | -| L1 | Global structured state and synthesis | Application layer / Main Session | +| Slot | Writer | Reader | Lifecycle | +|------|--------|--------|-----------| +| `systemPrompt` | fork chain / app | injected into every Session.send() | persistent | +| `insight` | app (`putInsight`) | consumed once, then `clearInsight` | one-shot inbox | +| `memory` | app / `consolidateFn` output | external reflection layer (`listSessionDigests`) | persistent (NOT injected into send) | ### Architectural Constraints -- Child Sessions do not read their own L2. -- Main Session reads L2, not child Sessions' L3. -- Child Sessions do not communicate directly. -- Cross-Session information propagates through Main Session insights. +- A Session never reads its own `memory` (memory is the *external* view). +- Sessions don't see each other. +- Cross-Session signal travels through the `insight` one-shot inbox. +- Global reflection is implemented by the application on top of the SDK — the framework holds no cross-Session state. ## Packages @@ -109,8 +110,8 @@ Handles Session-level capabilities: - Assemble prompt context - Store and replay L3 records -- Consolidate L3 into L2 -- Handle LLM adapters with streaming and tool call support +- Consolidate the conversation into `memory` +- LLM adapters with streaming and tool call support If you only need a single Session abstraction with memory, start here. @@ -119,14 +120,15 @@ If you only need a single Session abstraction with memory, start here. ### `@stello-ai/core` -Handles core orchestration: +Handles core orchestration and the orchestrator-facing data SDK: +- StelloAgent top-level entry (create / enter / turn / stream / fork / data SDK) - Turn execution with tool-call loops -- Fork orchestration -- Consolidation / integration scheduling -- Runtime management and lifecycle +- Fork orchestration (topology + Session, two-step) +- Consolidation scheduling +- Runtime ref-counting and lifecycle -If you need a Session topology with Main Session coordinating everything, start here. +If you need a Session topology plus an orchestrator-facing data SDK, start here. @@ -181,15 +183,23 @@ import { createStelloAgent } from '@stello-ai/core' const agent = createStelloAgent({ sessions: /* SessionTree implementation */, + storage: /* SessionStorage implementation (enables orchestrator-facing data SDK) */, + memory: /* MemoryEngine implementation */, + capabilities: { + lifecycle, tools, skills, confirm, + }, session: { - llm: /* LLM adapter */, - sessionResolver: async (id) => { - /* return session-compatible runtime */ + sessionLoader: async (id) => { + /* return a Session instance and its serializable config for the given id */ }, }, }) -const result = await agent.turn('main-session-id', 'Help me plan a product strategy') +// Create the conversation entry point (no parentId == new root) +const root = await agent.createSession({ label: 'Main' }) + +await agent.enterSession(root.id) +const result = await agent.turn(root.id, 'Help me plan a product strategy') ``` ### Launch Devtools diff --git a/docs/migration-unified-session-config.md b/docs/migration-unified-session-config.md deleted file mode 100644 index 21ce4ca..0000000 --- a/docs/migration-unified-session-config.md +++ /dev/null @@ -1,331 +0,0 @@ -# 迁移指南:Unified SessionConfig - -> 对应 RFC:`docs/rfcs/unified-session-config.md` -> 合入版本:2026-04-18(`feat/unified-session-config` 分支) - ---- - -## 概览 - -本次变更统一了 session 配置的三处分散入口,清理了 `SessionMeta` 冗余字段,并重命名了 `StelloAgentSessionConfig` 的核心 API。**对已有代码的影响集中在以下五个点**: - -| 变更点 | 类型 | 影响范围 | -|--------|------|---------| -| `sessionResolver` → `sessionLoader` | 重命名 + 返回值变化 | `createStelloAgent` 配置 | -| `mainSessionResolver` → `mainSessionLoader` | 重命名 + 返回值变化 | `createStelloAgent` 配置 | -| `SessionMeta` 删除 `scope / tags / metadata` | 破坏性删除 | 所有引用 `SessionMeta` 的地方 | -| `EngineForkOptions` 删除 `scope / tags / metadata` | 破坏性删除 | `forkSession()` 调用 | -| `compressFn` 从 `session` 迁移到 `sessionDefaults` | 字段移动 | 使用 compressFn 的配置 | - ---- - -## 1. `sessionResolver` → `sessionLoader` - -### 旧写法 - -```typescript -createStelloAgent({ - session: { - sessionResolver: async (sessionId) => { - const session = await loadSession(sessionId, { storage, llm }) - if (!session) throw new Error(`Session not found: ${sessionId}`) - return session // 直接返回 session 实例 - }, - }, -}) -``` - -### 新写法 - -```typescript -createStelloAgent({ - session: { - sessionLoader: async (sessionId) => { - const session = await loadSession(sessionId, { storage, llm }) - if (!session) throw new Error(`Session not found: ${sessionId}`) - return { session, config: null } // 包装为 {session, config} tuple - }, - }, -}) -``` - -**变化说明**: -- 函数名从 `sessionResolver` 改为 `sessionLoader` -- 返回值从 `SessionCompatible` 改为 `{ session: SessionCompatible; config: SerializableSessionConfig | null }` -- `config` 目前传 `null` 即可;将来框架会用它覆盖 `sessionDefaults` 中的可序列化字段 - ---- - -## 2. `mainSessionResolver` → `mainSessionLoader` - -### 旧写法 - -```typescript -createStelloAgent({ - session: { - mainSessionResolver: async () => ({ - async integrate() { /* ... */ } - }), - }, -}) -``` - -### 新写法 - -```typescript -createStelloAgent({ - session: { - mainSessionLoader: async () => ({ - session: { - async integrate() { /* ... */ } - }, - config: null, // 包装为 {session, config} tuple - }), - }, -}) -``` - -**变化说明**: -- 函数名从 `mainSessionResolver` 改为 `mainSessionLoader` -- 返回值从 `MainSessionCompatible | null` 改为 `{ session: MainSessionCompatible; config: SerializableMainSessionConfig | null } | null` - ---- - -## 3. `SessionMeta` 删除了 `scope / tags / metadata` - -### 受影响的代码 - -所有通过 `SessionMeta` 对象读取或写入这三个字段的地方: - -```typescript -// ❌ 不再存在 -meta.scope -meta.tags -meta.metadata -meta.metadata._stello -meta.metadata.sourceSessionId -``` - -### 迁移策略 - -| 原用途 | 新方法 | -|--------|--------| -| `scope` — 约束 LLM 行为 | 改用 `systemPrompt` 或 `ForkProfile.systemPrompt` | -| `tags` — 分类标记 | 应用层自行维护(框架不提供) | -| `metadata` — 自定义键值 | 应用层自行维护(框架不提供) | -| `metadata._stello.allowedSkills` | 改用固化 `SessionConfig.skills`(fork 时通过 `EngineForkOptions.skills` 或 `ForkProfile.skills` 传入) | -| `metadata.sourceSessionId` | 改读 `TopologyNode.sourceSessionId`(现在是一等字段) | - -**新 `SessionMeta` 只剩**: - -```typescript -interface SessionMeta { - readonly id: string - label: string - status: 'active' | 'archived' - turnCount: number - createdAt: string - updatedAt: string - lastActiveAt: string -} -``` - ---- - -## 4. `EngineForkOptions` 删除了 `scope / tags / metadata` - -### 旧写法 - -```typescript -await agent.forkSession(sessionId, { - label: '市场分析', - scope: 'market', // ❌ 已删除 - tags: ['research'], // ❌ 已删除 - metadata: { key: 'val' }, // ❌ 已删除 -}) -``` - -### 新写法 - -```typescript -await agent.forkSession(sessionId, { - label: '市场分析', - systemPrompt: '你专注于市场分析...', // 用 systemPrompt 替代 scope 的行为约束用途 -}) -``` - -同样适用于 `ConfirmProtocol.confirmSplit` 中的 `proposal.suggestedScope`: - -```typescript -// 旧 -confirmSplit: async (proposal) => { - return agentRef.forkSession(proposal.parentId, { - label: proposal.suggestedLabel, - scope: proposal.suggestedScope, // ❌ - }) -} - -// 新 -confirmSplit: async (proposal) => { - return agentRef.forkSession(proposal.parentId, { - label: proposal.suggestedLabel, - // suggestedScope 已不在 proposal 中,如需约束 LLM 行为改传 systemPrompt - }) -} -``` - ---- - -## 5. `compressFn` 从 `session` 迁移到 `sessionDefaults` - -### 旧写法 - -```typescript -createStelloAgent({ - session: { - compressFn: myCompressFn, // ❌ 已不存在 - sessionResolver: async (id) => { /* ... */ }, - }, -}) -``` - -### 新写法 - -```typescript -createStelloAgent({ - sessionDefaults: { - compressFn: myCompressFn, // ✅ 移到 sessionDefaults - }, - session: { - sessionLoader: async (id) => { /* ... */ }, - }, -}) -``` - ---- - -## 新增能力(可选使用) - -### `sessionDefaults` — Regular Session 的 Agent 级默认 - -```typescript -createStelloAgent({ - sessionDefaults: { - llm: defaultLlm, - consolidateFn: defaultConsolidateFn, - compressFn: defaultCompressFn, - }, -}) -``` - -- 是所有 regular session 的配置基线(合成链最低优先级) -- `ForkProfile` 和 `EngineForkOptions` 的同名字段可逐级覆盖 - -### `mainSessionConfig` — Main Session 的独立配置 - -```typescript -createStelloAgent({ - mainSessionConfig: { - systemPrompt: '你是全局协调者...', - llm: mainLlm, - integrateFn: myIntegrateFn, - }, -}) -``` - -- 独立配置,不参与 regular session 的 fork 合成链 -- Main session 使用 `integrateFn` 而非 `consolidateFn` - -### `agent.createMainSession()` — 显式创建根节点 - -```typescript -// 旧:直接调用底层 -const root = await agent.sessions.createRoot('Main') - -// 新:推荐路径,会用 mainSessionConfig 固化配置 -const root = await agent.createMainSession({ label: 'Main' }) -``` - -### Fork 配置合成链 - -`forkSession` 时,`systemPrompt` / `llm` / `tools` / `consolidateFn` / `compressFn` / `skills` 按以下优先级合成(后者覆盖前者): - -``` -sessionDefaults → 父 session 的固化 config → ForkProfile → EngineForkOptions -``` - -从 main session fork 时,不继承 main session 的配置(类型不同),只从 `sessionDefaults` 开始。 - -### `skills` 白名单(替代 `metadata._stello.allowedSkills`) - -```typescript -// fork 时指定该子 session 只能用特定 skill -await agent.forkSession(sessionId, { - label: '研究助手', - skills: ['search', 'summarize'], // 白名单 - // skills: [] // 禁用所有 skill - // skills: undefined // 继承全局 SkillRouter(默认) -}) - -// 或通过 ForkProfile 预设 -forkProfiles.register('researcher', { - skills: ['search', 'summarize'], - systemPrompt: '你是研究助手...', -}) -``` - -### `ForkProfile` 扁平化 - -`ForkProfile` 现在继承 `SessionConfig`,不再重复定义字段: - -```typescript -// 旧:ForkProfile 有自己的 systemPrompt/llm/tools/consolidateFn/compressFn 字段 -// 新:ForkProfile extends SessionConfig,全部字段通过继承获得 - -forkProfiles.register('expert', { - // SessionConfig 字段(直接写,不再有命名空间) - systemPrompt: '你是专家...', - llm: expertLlm, - consolidateFn: expertConsolidateFn, - skills: ['search'], - - // ForkProfile 专属字段 - systemPromptFn: (vars) => `你是${vars.region}地区的专家...`, // 优先于 systemPrompt - systemPromptMode: 'prepend', // 默认值 - context: 'inherit', - prompt: '请先做自我介绍', -}) -``` - ---- - -## `TopologyNode.sourceSessionId` - -`sourceSessionId` 从 `SessionMeta.metadata.sourceSessionId` 升为 `TopologyNode` 的一等字段: - -```typescript -// 旧(从 metadata 读) -const node = await agent.sessions.get(sessionId) -const sourceId = node.metadata?.sourceSessionId // ❌ - -// 新(从 TopologyNode 读) -const node = await agent.sessions.getNode(sessionId) // 返回 TopologyNode -const sourceId = node.sourceSessionId // ✅ -``` - -**语义**:fork 时的上下文来源 session ID。当 `topologyParentId` 被显式覆盖时,拓扑父节点和上下文来源可以不同,两者均被保留。 - ---- - -## 已删除 - -- `packages/server`(整包删除,相关 PG 适配器和 HTTP 层随之移除) -- `SessionMeta.scope` -- `SessionMeta.tags` -- `SessionMeta.metadata` -- `EngineForkOptions.scope` -- `EngineForkOptions.tags` -- `EngineForkOptions.metadata` -- `StelloAgentSessionConfig.compressFn` -- `StelloAgentSessionConfig.sessionResolver`(改名为 `sessionLoader`) -- `StelloAgentSessionConfig.mainSessionResolver`(改名为 `mainSessionLoader`) From 0968e6475c50548fa5a486562ea5ba95311b6d74 Mon Sep 17 00:00:00 2001 From: uchouT Date: Tue, 19 May 2026 17:34:15 +0800 Subject: [PATCH 19/40] feat: expose single digest read API --- packages/core/src/agent/stello-agent.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/core/src/agent/stello-agent.ts b/packages/core/src/agent/stello-agent.ts index 08d2ab2..adf519d 100644 --- a/packages/core/src/agent/stello-agent.ts +++ b/packages/core/src/agent/stello-agent.ts @@ -298,6 +298,18 @@ export class StelloAgent { ); } + /** 读取单个 Session 的 digest(id / label / status / memory / insight)。返回 null 表示不存在。 */ + async getSessionDigest(id: string): Promise { + const meta = await this.sessions.get(id); + if (!meta) return null; + const storage = this.requireStorage('getSessionDigest'); + const [memory, insight] = await Promise.all([ + storage.getMemory(id), + storage.getInsight(id), + ]); + return { id: meta.id, label: meta.label, status: meta.status, memory, insight }; + } + /** 读取指定 Session 的 L3 消息 */ listMessages(id: string, options?: ListRecordsOptions): Promise { const storage = this.requireStorage('listMessages'); From e3ada36fc264e0e1b7e631b3671ff0a4e8f92ec1 Mon Sep 17 00:00:00 2001 From: uchouT Date: Wed, 20 May 2026 15:18:33 +0800 Subject: [PATCH 20/40] feat(core): shared memory system (#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add shared memory design spec * docs: clarify shared memory release plan — bundle into unpublished release * docs: add shared memory implementation plan * feat(core): define SharedMemoryStore interface and SharedMemoryEntry type * feat(core): add InMemorySharedMemoryStore with writeLock serialization * feat(core): add renderSharedMemoryIndex * feat(core): expose sharedMemory config and four SDK methods on StelloAgent * style(core): match requireStorage Chinese error pattern and add semicolons in shared memory SDK * feat(core): add stello_memory_recall builtin tool * feat(core): add stello_memory_remember builtin tool * feat(core): add stello_memory_forget builtin tool * feat(session): add sharedMemoryIndex slot in context assembly * feat(core): inject sharedMemoryIndex on every session.send via adapter * refactor(core): drop legacy MemoryEngine and FileSystemMemoryEngine 删除 MemoryEngine 接口、FileSystemMemoryEngine 实现及其测试目录。 从 StelloAgentConfig、StelloEngineOptions、DefaultEngineFactoryOptions、 StelloEngine 接口、index.ts 公开导出中移除所有 memory 字段和相关类型引用。 保留 TurnRecord / AssembledContext(仍被 EngineLifecycleAdapter 和 BootstrapResult 使用)。 * feat(core): export SharedMemoryStore types, InMemorySharedMemoryStore, and three tool factories * test(core): add end-to-end test for shared memory index injection * docs(core): refresh agent-creation skill for shared memory and document resolver caveat * feat(core): re-export TurnRecord and AssembledContext for downstream lifecycle implementers --- .agents/skills/stello-agent-creation/SKILL.md | 24 +- .../plans/2026-05-17-shared-memory.md | 1656 +++++++++++++++++ .../specs/2026-05-17-shared-memory-design.md | 351 ++++ .../builtin-tools-llm-exposure.test.ts | 2 - .../src/__tests__/shared-memory-e2e.test.ts | 64 + .../__tests__/session-runtime.test.ts | 2 +- packages/core/src/adapters/session-runtime.ts | 32 +- .../agent/__tests__/shared-memory-sdk.test.ts | 83 + .../src/agent/__tests__/stello-agent.test.ts | 4 - packages/core/src/agent/stello-agent.ts | 61 +- .../__tests__/memory-forget-tool.test.ts | 46 + .../__tests__/memory-recall-tool.test.ts | 46 + .../__tests__/memory-remember-tool.test.ts | 78 + packages/core/src/builtin-tools/index.ts | 3 + .../src/builtin-tools/memory-forget-tool.ts | 36 + .../src/builtin-tools/memory-recall-tool.ts | 37 + .../src/builtin-tools/memory-remember-tool.ts | 49 + .../engine/__tests__/fork-compress.test.ts | 3 - .../engine/__tests__/stello-engine.test.ts | 18 +- packages/core/src/engine/stello-engine.ts | 5 +- packages/core/src/index.ts | 24 +- .../file-system-memory-engine.test.ts | 261 --- .../src/memory/file-system-memory-engine.ts | 145 -- .../__tests__/default-engine-factory.test.ts | 2 - .../orchestrator/default-engine-factory.ts | 3 - .../in-memory-shared-memory-store.test.ts | 84 + .../__tests__/render-index.test.ts | 42 + .../in-memory-shared-memory-store.ts | 44 + .../core/src/shared-memory/render-index.ts | 19 + packages/core/src/shared-memory/types.ts | 27 + packages/core/src/types.ts | 10 - packages/core/src/types/engine.ts | 7 +- packages/core/src/types/memory.ts | 60 +- .../src/__tests__/shared-memory-index.test.ts | 70 + packages/session/src/context-utils.ts | 10 +- packages/session/src/create-session.ts | 11 +- packages/session/src/types/session-api.ts | 5 + 37 files changed, 2869 insertions(+), 555 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-17-shared-memory.md create mode 100644 docs/superpowers/specs/2026-05-17-shared-memory-design.md create mode 100644 packages/core/src/__tests__/shared-memory-e2e.test.ts create mode 100644 packages/core/src/agent/__tests__/shared-memory-sdk.test.ts create mode 100644 packages/core/src/builtin-tools/__tests__/memory-forget-tool.test.ts create mode 100644 packages/core/src/builtin-tools/__tests__/memory-recall-tool.test.ts create mode 100644 packages/core/src/builtin-tools/__tests__/memory-remember-tool.test.ts create mode 100644 packages/core/src/builtin-tools/memory-forget-tool.ts create mode 100644 packages/core/src/builtin-tools/memory-recall-tool.ts create mode 100644 packages/core/src/builtin-tools/memory-remember-tool.ts delete mode 100644 packages/core/src/memory/__tests__/file-system-memory-engine.test.ts delete mode 100644 packages/core/src/memory/file-system-memory-engine.ts create mode 100644 packages/core/src/shared-memory/__tests__/in-memory-shared-memory-store.test.ts create mode 100644 packages/core/src/shared-memory/__tests__/render-index.test.ts create mode 100644 packages/core/src/shared-memory/in-memory-shared-memory-store.ts create mode 100644 packages/core/src/shared-memory/render-index.ts create mode 100644 packages/core/src/shared-memory/types.ts create mode 100644 packages/session/src/__tests__/shared-memory-index.test.ts diff --git a/.agents/skills/stello-agent-creation/SKILL.md b/.agents/skills/stello-agent-creation/SKILL.md index e8bfac0..4a48374 100644 --- a/.agents/skills/stello-agent-creation/SKILL.md +++ b/.agents/skills/stello-agent-creation/SKILL.md @@ -15,13 +15,11 @@ import { type EngineLifecycleAdapter, type ConfirmProtocol, type SessionTree, - type MemoryEngine, } from '@stello-ai/core' import type { SessionStorage } from '@stello-ai/session' const agent = createStelloAgent({ sessions, // SessionTree 实例(拓扑) - memory, // MemoryEngine 实例 storage: sessionStorage, // SessionStorage 实例(内容;orchestrator-facing SDK 依赖) capabilities: { lifecycle, // EngineLifecycleAdapter @@ -42,8 +40,8 @@ const agent = createStelloAgent({ ```typescript interface StelloAgentConfig { sessions: SessionTree // 拓扑树(必填) - memory: MemoryEngine // 记忆引擎(必填) storage?: SessionStorage // 内容存储(orchestrator-facing 数据 SDK 依赖) + sharedMemory?: SharedMemoryStore // Agent 级共享 memory;注入后索引每 send 前由 adapter 自动注入 sessionDefaults?: SessionConfig // 所有 session 的 agent 级默认(fork 合成链最低优先级) capabilities: { // 能力注入(必填) lifecycle: EngineLifecycleAdapter @@ -325,9 +323,12 @@ import { SplitGuard, SessionTreeImpl, NodeFileSystemAdapter, - FileSystemMemoryEngine, + InMemorySharedMemoryStore, createSessionTool, activateSkillTool, + memoryRecallTool, + memoryRememberTool, + memoryForgetTool, } from '@stello-ai/core' import { loadSession, @@ -338,8 +339,8 @@ import { // ─── 基础设施 ─── const fs = new NodeFileSystemAdapter('./data') const sessions = new SessionTreeImpl(fs) -const memory = new FileSystemMemoryEngine(fs, sessions) const sessionStorage = new InMemoryStorageAdapter() +const sharedMemory = new InMemorySharedMemoryStore() const llm = createOpenAICompatibleAdapter({ apiKey: process.env.OPENAI_API_KEY!, model: 'gpt-4o', @@ -356,6 +357,9 @@ skills.register({ const toolRegistry = new ToolRegistryImpl([ createSessionTool(), // 内置 fork tool(opt-in) activateSkillTool(skills), // 内置 skill 激活 tool(opt-in) + memoryRecallTool(), // 共享 memory 读取 tool(opt-in) + memoryRememberTool(), // 共享 memory 写入 tool(opt-in) + memoryForgetTool(), // 共享 memory 删除 tool(opt-in) ]) toolRegistry.register({ name: 'search_knowledge', @@ -384,8 +388,8 @@ profiles.register('researcher', { let agent: ReturnType agent = createStelloAgent({ sessions, - memory, storage: sessionStorage, // 注入内容存储,启用 orchestrator-facing SDK + sharedMemory, // 注入共享 memory,启用 4 个 SDK 方法 + 索引自动注入 sessionDefaults: { llm, @@ -404,14 +408,10 @@ agent = createStelloAgent({ capabilities: { lifecycle: { bootstrap: async (sessionId) => ({ - context: await memory.assembleContext(sessionId), + context: { core: {}, memories: [], currentMemory: null, scope: null }, session: await sessions.get(sessionId), }), - afterTurn: async (sessionId, userMsg, assistantMsg) => { - await memory.appendRecord(sessionId, userMsg) - await memory.appendRecord(sessionId, assistantMsg) - return { coreUpdated: false, memoryUpdated: false, recordAppended: true } - }, + afterTurn: async () => ({ coreUpdated: false, memoryUpdated: false, recordAppended: true }), }, tools: toolRegistry, skills, diff --git a/docs/superpowers/plans/2026-05-17-shared-memory.md b/docs/superpowers/plans/2026-05-17-shared-memory.md new file mode 100644 index 0000000..f554bb8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-shared-memory.md @@ -0,0 +1,1656 @@ +# Shared Memory Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a StelloAgent-level shared memory mechanism: a single per-agent store of `{ slug, summary, body }` entries, with an auto-injected index in every Session's context and three built-in tools (`stello_memory_recall` / `stello_memory_remember` / `stello_memory_forget`) for the agent to read/write. Expose four SDK methods on `StelloAgent` for application-level read/write. Delete the dead legacy `MemoryEngine` in the same release. + +**Architecture:** +- New package surface in `@stello-ai/core`: `SharedMemoryStore` interface + `InMemorySharedMemoryStore` default implementation + index renderer + 3 builtin tool factories + 4 SDK methods on `StelloAgent`. +- `@stello-ai/session` extends `SessionSendOptions` with an optional `sharedMemoryIndex: string` and inserts it as a system message slot between `systemPrompt` and `session_identity`. +- `adaptSessionToEngineRuntime` (in `@stello-ai/core`) wraps `session.send` / `session.stream` to fetch the latest index from the agent's store before every call — agent writes via tools are visible to the next round automatically. +- Legacy `MemoryEngine` interface, `FileSystemMemoryEngine`, and all related plumbing in `Engine` / `DefaultEngineFactory` / `StelloAgent.memory` are deleted in the same release. + +**Tech Stack:** TypeScript (strict), pnpm monorepo, Vitest, tsup. Follows existing patterns: factory + ctx tools (cf. `createSessionTool`), `writeLock`-style RMW serialization (cf. `SessionTreeImpl`), flat data-IO SDK methods on `StelloAgent` (cf. `putMemory` / `getSessionMetadata`). + +**Spec:** `docs/superpowers/specs/2026-05-17-shared-memory-design.md` + +--- + +## File Structure + +**New files (13):** + +| Path | Responsibility | +|---|---| +| `packages/core/src/shared-memory/types.ts` | `SharedMemoryEntry` type + `SharedMemoryStore` interface | +| `packages/core/src/shared-memory/in-memory-shared-memory-store.ts` | Default `InMemorySharedMemoryStore` implementation | +| `packages/core/src/shared-memory/render-index.ts` | `renderSharedMemoryIndex(store)` → string \| undefined | +| `packages/core/src/shared-memory/__tests__/in-memory-shared-memory-store.test.ts` | Tests: CRUD, FIFO ordering, writeLock serialization | +| `packages/core/src/shared-memory/__tests__/render-index.test.ts` | Tests: empty → undefined, non-empty → templated string | +| `packages/core/src/builtin-tools/memory-recall-tool.ts` | `memoryRecallTool()` factory | +| `packages/core/src/builtin-tools/memory-remember-tool.ts` | `memoryRememberTool()` factory | +| `packages/core/src/builtin-tools/memory-forget-tool.ts` | `memoryForgetTool()` factory | +| `packages/core/src/builtin-tools/__tests__/memory-recall-tool.test.ts` | Tests: known slug / unknown slug / store not configured | +| `packages/core/src/builtin-tools/__tests__/memory-remember-tool.test.ts` | Tests: upsert path / empty slug / store not configured | +| `packages/core/src/builtin-tools/__tests__/memory-forget-tool.test.ts` | Tests: remove existing / remove missing / store not configured | +| `packages/core/src/agent/__tests__/shared-memory-sdk.test.ts` | Tests: 4 SDK methods normal path + "not configured" errors | +| `packages/session/src/__tests__/shared-memory-index.test.ts` | Tests: slot inserted between system prompt and session_identity; undefined → not injected; replay path identical | +| `packages/core/src/__tests__/shared-memory-e2e.test.ts` | E2E: adapter injects current index on every send | + +**Modified files (12):** + +| Path | Change | +|---|---| +| `packages/session/src/types/session-api.ts` | Add `sharedMemoryIndex?: string` to `SessionSendOptions` | +| `packages/session/src/context-utils.ts` | `assembleSessionContext` accepts `sharedMemoryIndex` and injects as system message after `systemPrompt` | +| `packages/session/src/create-session.ts` | Forward `sharedMemoryIndex` from `SessionSendOptions` into both `assembleSessionContext` (normal path) and `assembleSessionReplayContext` (replay path); replay helper accepts the param and injects | +| `packages/core/src/adapters/session-runtime.ts` | `SessionCompatibleSendOptions` gains `sharedMemoryIndex?: string`; adapter accepts a `sharedMemoryIndexProvider` and merges its result into `sendOptions` before every `session.send` / `session.stream` | +| `packages/core/src/agent/stello-agent.ts` | Add `sharedMemory?: SharedMemoryStore` config; expose `agent.sharedMemory`; add 4 SDK methods; drop `memory: MemoryEngine` field/config; thread `sharedMemoryIndex` provider into `resolveRuntimeResolver` | +| `packages/core/src/engine/stello-engine.ts` | Remove `memory: MemoryEngine` field / option / constructor wiring | +| `packages/core/src/orchestrator/default-engine-factory.ts` | Remove `memory` factory option and engine-construction wiring | +| `packages/core/src/types/engine.ts` | Drop `MemoryEngine` re-export and field | +| `packages/core/src/types.ts` | Drop `MemoryEngine` re-export | +| `packages/core/src/index.ts` | Drop legacy memory exports (`MemoryEngine`, `FileSystemMemoryEngine`, etc.); add `SharedMemoryStore`, `SharedMemoryEntry`, `InMemorySharedMemoryStore`, three tool factories | +| `packages/core/src/builtin-tools/index.ts` | Export three new tool factories | +| `packages/core/src/agent/__tests__/stello-agent.test.ts` | Drop `memory: {} as MemoryEngine` placeholders in fixture configs | +| `packages/core/src/__tests__/builtin-tools-llm-exposure.test.ts` | Drop `memory: {} as MemoryEngine` placeholder | + +**Deleted files (entire directory):** + +- `packages/core/src/types/memory.ts` +- `packages/core/src/memory/file-system-memory-engine.ts` +- `packages/core/src/memory/__tests__/` (all contents) + +--- + +## Test Commands + +- `pnpm --filter @stello-ai/core test` — run core package tests +- `pnpm --filter @stello-ai/session test` — run session package tests +- `pnpm --filter @stello-ai/core test -- ` — run a single test file +- `pnpm --filter @stello-ai/core exec tsc --noEmit` — type-check only + +--- + +## Task 1: Define `SharedMemoryStore` interface and `SharedMemoryEntry` type + +**Files:** +- Create: `packages/core/src/shared-memory/types.ts` + +- [ ] **Step 1: Write the types file** + +```typescript +/** + * 共享 memory 的单条记录。 + * slug: 主键 / summary: 出现在索引行的一句话 / body: recall 时返回的全文。 + */ +export interface SharedMemoryEntry { + slug: string + summary: string + body: string +} + +/** + * StelloAgent 级共享 memory 存储接口。 + * + * - 一个 StelloAgent 实例对应一份 store;所有 Session 共享 + * - list() 按"插入顺序"返回;upsert 已存在 slug 时**不改变其顺序位置** + * - 写操作(upsert / remove)由实现内部串行化(writeLock 范式),读操作允许脏读 + */ +export interface SharedMemoryStore { + /** 列举全部 entries(按插入顺序) */ + list(): Promise + /** 读取单条 entry,不存在返回 null */ + get(slug: string): Promise + /** 写入或覆盖一条 entry(不存在则追加到末尾,存在则覆盖 summary + body 并保持顺序) */ + upsert(slug: string, summary: string, body: string): Promise + /** 删除一条 entry;不存在为 no-op */ + remove(slug: string): Promise +} +``` + +- [ ] **Step 2: Verify the file compiles** + +Run: `pnpm --filter @stello-ai/core exec tsc --noEmit` +Expected: PASS (no errors related to the new file) + +- [ ] **Step 3: Commit** + +```bash +git add packages/core/src/shared-memory/types.ts +git commit -m "feat(core): define SharedMemoryStore interface and SharedMemoryEntry type" +``` + +--- + +## Task 2: Implement `InMemorySharedMemoryStore` (TDD) + +**Files:** +- Create: `packages/core/src/shared-memory/__tests__/in-memory-shared-memory-store.test.ts` +- Create: `packages/core/src/shared-memory/in-memory-shared-memory-store.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/core/src/shared-memory/__tests__/in-memory-shared-memory-store.test.ts +import { describe, it, expect } from 'vitest' +import { InMemorySharedMemoryStore } from '../in-memory-shared-memory-store' + +describe('InMemorySharedMemoryStore', () => { + it('list returns [] when empty', async () => { + const store = new InMemorySharedMemoryStore() + expect(await store.list()).toEqual([]) + }) + + it('upsert adds new entry and list returns it', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sum-a', 'body-a') + expect(await store.list()).toEqual([{ slug: 'a', summary: 'sum-a', body: 'body-a' }]) + }) + + it('get returns the entry by slug, null if missing', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sum-a', 'body-a') + expect(await store.get('a')).toEqual({ slug: 'a', summary: 'sum-a', body: 'body-a' }) + expect(await store.get('missing')).toBeNull() + }) + + it('upsert preserves insertion order across multiple slugs', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'ba') + await store.upsert('b', 'sb', 'bb') + await store.upsert('c', 'sc', 'bc') + expect((await store.list()).map(e => e.slug)).toEqual(['a', 'b', 'c']) + }) + + it('upsert on existing slug overwrites summary + body but keeps position', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'ba') + await store.upsert('b', 'sb', 'bb') + await store.upsert('a', 'sa2', 'ba2') + expect(await store.list()).toEqual([ + { slug: 'a', summary: 'sa2', body: 'ba2' }, + { slug: 'b', summary: 'sb', body: 'bb' }, + ]) + }) + + it('remove deletes the entry; subsequent list omits it', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'ba') + await store.upsert('b', 'sb', 'bb') + await store.remove('a') + expect(await store.list()).toEqual([{ slug: 'b', summary: 'sb', body: 'bb' }]) + }) + + it('remove on missing slug is a no-op', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'ba') + await store.remove('missing') + expect(await store.list()).toEqual([{ slug: 'a', summary: 'sa', body: 'ba' }]) + }) + + it('serializes concurrent upserts to same slug (last value wins, no lost write)', async () => { + const store = new InMemorySharedMemoryStore() + await Promise.all([ + store.upsert('a', 's1', 'b1'), + store.upsert('a', 's2', 'b2'), + store.upsert('a', 's3', 'b3'), + ]) + const entries = await store.list() + expect(entries.length).toBe(1) + expect(entries[0]!.slug).toBe('a') + // 串行化保证最终状态是三个写之一,且结构完整 + expect(['s1', 's2', 's3']).toContain(entries[0]!.summary) + expect(['b1', 'b2', 'b3']).toContain(entries[0]!.body) + }) + + it('serializes mixed concurrent upsert/remove without corruption', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'ba') + await Promise.all([ + store.upsert('b', 'sb', 'bb'), + store.remove('a'), + store.upsert('c', 'sc', 'bc'), + ]) + const entries = await store.list() + // 无 a;包含 b 和 c + expect(entries.find(e => e.slug === 'a')).toBeUndefined() + expect(entries.find(e => e.slug === 'b')).toEqual({ slug: 'b', summary: 'sb', body: 'bb' }) + expect(entries.find(e => e.slug === 'c')).toEqual({ slug: 'c', summary: 'sc', body: 'bc' }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @stello-ai/core test -- in-memory-shared-memory-store` +Expected: FAIL — cannot resolve `'../in-memory-shared-memory-store'` + +- [ ] **Step 3: Write implementation** + +```typescript +// packages/core/src/shared-memory/in-memory-shared-memory-store.ts +import type { SharedMemoryEntry, SharedMemoryStore } from './types' + +/** + * 内置 SharedMemoryStore — 基于 JS Map(天然保留插入顺序)。 + * + * 所有写操作通过 writeLock 串行化(沿用 SessionTreeImpl 的范式), + * 避免并发 upsert / remove 时读到中间状态。读操作不加锁,允许脏读。 + */ +export class InMemorySharedMemoryStore implements SharedMemoryStore { + private readonly entries = new Map() + private writeLock: Promise = Promise.resolve() + + /** 把 fn 排入写队列,串行执行 */ + private withWriteLock(fn: () => Promise): Promise { + const next = this.writeLock.then(fn, fn) + this.writeLock = next.catch(() => undefined) + return next + } + + /** 列举全部 entries,按 Map 插入顺序 */ + async list(): Promise { + return [...this.entries].map(([slug, { summary, body }]) => ({ slug, summary, body })) + } + + /** 读取单条 entry */ + async get(slug: string): Promise { + const v = this.entries.get(slug) + return v ? { slug, summary: v.summary, body: v.body } : null + } + + /** 写入或覆盖;JS Map.set 在已有 key 上不改变插入位置 */ + upsert(slug: string, summary: string, body: string): Promise { + return this.withWriteLock(async () => { + this.entries.set(slug, { summary, body }) + }) + } + + /** 删除一条;不存在为 no-op */ + remove(slug: string): Promise { + return this.withWriteLock(async () => { + this.entries.delete(slug) + }) + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @stello-ai/core test -- in-memory-shared-memory-store` +Expected: PASS (all cases) + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/shared-memory/in-memory-shared-memory-store.ts \ + packages/core/src/shared-memory/__tests__/in-memory-shared-memory-store.test.ts +git commit -m "feat(core): add InMemorySharedMemoryStore with writeLock serialization" +``` + +--- + +## Task 3: Add `renderSharedMemoryIndex` function (TDD) + +**Files:** +- Create: `packages/core/src/shared-memory/__tests__/render-index.test.ts` +- Create: `packages/core/src/shared-memory/render-index.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/core/src/shared-memory/__tests__/render-index.test.ts +import { describe, it, expect } from 'vitest' +import { renderSharedMemoryIndex } from '../render-index' +import { InMemorySharedMemoryStore } from '../in-memory-shared-memory-store' + +describe('renderSharedMemoryIndex', () => { + it('returns undefined when store is undefined', async () => { + expect(await renderSharedMemoryIndex(undefined)).toBeUndefined() + }) + + it('returns undefined when store has no entries', async () => { + const store = new InMemorySharedMemoryStore() + expect(await renderSharedMemoryIndex(store)).toBeUndefined() + }) + + it('renders entries inside with hint footer', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('prefer-concise', '用户偏好简短回答', 'body-1') + await store.upsert('user-profile', '大三本科生 CS 专业', 'body-2') + const out = await renderSharedMemoryIndex(store) + expect(out).toContain('') + expect(out).toContain('- prefer-concise: 用户偏好简短回答') + expect(out).toContain('- user-profile: 大三本科生 CS 专业') + expect(out).toContain('') + expect(out).toMatch(/stello_memory_recall/) + expect(out).toMatch(/stello_memory_remember/) + expect(out).toMatch(/stello_memory_forget/) + }) + + it('preserves entry order in output', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'ba') + await store.upsert('b', 'sb', 'bb') + await store.upsert('c', 'sc', 'bc') + const out = await renderSharedMemoryIndex(store) + const aIdx = out!.indexOf('- a:') + const bIdx = out!.indexOf('- b:') + const cIdx = out!.indexOf('- c:') + expect(aIdx).toBeGreaterThan(-1) + expect(aIdx).toBeLessThan(bIdx) + expect(bIdx).toBeLessThan(cIdx) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @stello-ai/core test -- render-index` +Expected: FAIL — cannot resolve `'../render-index'` + +- [ ] **Step 3: Write implementation** + +```typescript +// packages/core/src/shared-memory/render-index.ts +import type { SharedMemoryStore } from './types' + +const HINT = `调用 stello_memory_recall 工具按 slug 查阅完整内容; +调用 stello_memory_remember / stello_memory_forget 工具维护此处条目。` + +/** + * 渲染共享 memory 索引段。 + * - store 为 undefined 或无 entry:返回 undefined(调用方应跳过注入) + * - 否则返回 + hint 文本 + */ +export async function renderSharedMemoryIndex( + store: SharedMemoryStore | undefined, +): Promise { + if (!store) return undefined + const entries = await store.list() + if (entries.length === 0) return undefined + const lines = entries.map(e => `- ${e.slug}: ${e.summary}`).join('\n') + return `\n${lines}\n\n\n${HINT}` +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @stello-ai/core test -- render-index` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/shared-memory/render-index.ts \ + packages/core/src/shared-memory/__tests__/render-index.test.ts +git commit -m "feat(core): add renderSharedMemoryIndex" +``` + +--- + +## Task 4: Wire `sharedMemory` into `StelloAgent` config + four SDK methods (TDD) + +**Files:** +- Modify: `packages/core/src/agent/stello-agent.ts` +- Create: `packages/core/src/agent/__tests__/shared-memory-sdk.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/core/src/agent/__tests__/shared-memory-sdk.test.ts +import { describe, it, expect } from 'vitest' +import { StelloAgent } from '../stello-agent' +import { InMemorySharedMemoryStore } from '../../shared-memory/in-memory-shared-memory-store' +import { SkillRouterImpl } from '../../skill/skill-router' +import { ToolRegistryImpl } from '../../tool/tool-registry' +import type { StelloAgentConfig } from '../stello-agent' +import type { SessionTree } from '../../types/session' +import type { EngineLifecycleAdapter } from '../../engine/stello-engine' +import type { ConfirmProtocol } from '../../types/lifecycle' + +// Minimal fixture; only SDK-method paths are exercised, runtime is not used. +function makeAgent(sharedMemory?: InMemorySharedMemoryStore): StelloAgent { + const config: StelloAgentConfig = { + sessions: { + createSession: async () => ({ id: 'r', label: 'r', parentId: null, status: 'active' }), + listRoots: async () => [], + getTree: async () => [], + getNode: async () => null, + listAll: async () => [], + get: async () => null, + archive: async () => undefined, + addRef: async () => undefined, + updateMeta: async () => undefined, + getAncestors: async () => [], + getSiblings: async () => [], + getConfig: async () => null, + putConfig: async () => undefined, + } as unknown as SessionTree, + capabilities: { + lifecycle: {} as EngineLifecycleAdapter, + tools: new ToolRegistryImpl(), + skills: new SkillRouterImpl(), + confirm: {} as ConfirmProtocol, + }, + runtime: { resolver: { resolve: async () => ({} as never) } }, + ...(sharedMemory ? { sharedMemory } : {}), + } + return new StelloAgent(config) +} + +describe('StelloAgent shared memory SDK', () => { + it('exposes agent.sharedMemory when configured', () => { + const store = new InMemorySharedMemoryStore() + const agent = makeAgent(store) + expect(agent.sharedMemory).toBe(store) + }) + + it('listSharedMemory returns [] when store is empty', async () => { + const agent = makeAgent(new InMemorySharedMemoryStore()) + expect(await agent.listSharedMemory()).toEqual([]) + }) + + it('upsertSharedMemoryEntry + listSharedMemory round-trip', async () => { + const agent = makeAgent(new InMemorySharedMemoryStore()) + await agent.upsertSharedMemoryEntry('a', 'sa', 'ba') + await agent.upsertSharedMemoryEntry('b', 'sb', 'bb') + expect(await agent.listSharedMemory()).toEqual([ + { slug: 'a', summary: 'sa', body: 'ba' }, + { slug: 'b', summary: 'sb', body: 'bb' }, + ]) + }) + + it('getSharedMemoryEntry returns null when missing, entry when present', async () => { + const agent = makeAgent(new InMemorySharedMemoryStore()) + expect(await agent.getSharedMemoryEntry('a')).toBeNull() + await agent.upsertSharedMemoryEntry('a', 'sa', 'ba') + expect(await agent.getSharedMemoryEntry('a')).toEqual({ slug: 'a', summary: 'sa', body: 'ba' }) + }) + + it('removeSharedMemoryEntry deletes the entry', async () => { + const agent = makeAgent(new InMemorySharedMemoryStore()) + await agent.upsertSharedMemoryEntry('a', 'sa', 'ba') + await agent.removeSharedMemoryEntry('a') + expect(await agent.getSharedMemoryEntry('a')).toBeNull() + }) + + it('throws "sharedMemory not configured" when store is absent', async () => { + const agent = makeAgent(undefined) + await expect(agent.listSharedMemory()).rejects.toThrow(/sharedMemory not configured/) + await expect(agent.getSharedMemoryEntry('a')).rejects.toThrow(/sharedMemory not configured/) + await expect(agent.upsertSharedMemoryEntry('a', 's', 'b')).rejects.toThrow(/sharedMemory not configured/) + await expect(agent.removeSharedMemoryEntry('a')).rejects.toThrow(/sharedMemory not configured/) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @stello-ai/core test -- shared-memory-sdk` +Expected: FAIL — `agent.sharedMemory` is undefined; SDK methods do not exist. + +- [ ] **Step 3: Add the import to `stello-agent.ts`** + +In `packages/core/src/agent/stello-agent.ts`, add this import alongside the existing imports near the top: + +```typescript +import type { SharedMemoryEntry, SharedMemoryStore } from '../shared-memory/types' +``` + +- [ ] **Step 4: Add `sharedMemory` to `StelloAgentConfig`** + +In `packages/core/src/agent/stello-agent.ts`, inside the `StelloAgentConfig` interface, add the field alongside `storage?: SessionStorage`: + +```typescript + /** + * Agent 级共享 memory 存储。 + * + * 注入后:四个 SDK 方法可用,索引段每次 send 前由 adapter 自动渲染并注入。 + * 未注入:四个 SDK 方法和三个内置 tool 抛 "sharedMemory not configured",索引段不进入上下文。 + */ + sharedMemory?: SharedMemoryStore +``` + +- [ ] **Step 5: Add the field on the `StelloAgent` class** + +After the `readonly storage?: SessionStorage` field, add: + +```typescript + /** 暴露 SharedMemoryStore,供 builtin tool / adapter / SDK 使用 */ + readonly sharedMemory?: SharedMemoryStore +``` + +After `this.storage = config.storage` in the constructor, add: + +```typescript + this.sharedMemory = config.sharedMemory +``` + +- [ ] **Step 6: Add a `requireSharedMemory` private helper** + +Below the existing `requireStorage` private helper, add: + +```typescript + private requireSharedMemory(method: string): SharedMemoryStore { + if (!this.sharedMemory) { + throw new Error( + `StelloAgent.${method} 需要 StelloAgentConfig.sharedMemory;请在创建 agent 时注入 SharedMemoryStore`, + ) + } + return this.sharedMemory + } +``` + +- [ ] **Step 7: Add the four SDK methods** + +Near the existing data-IO methods (`getSessionMetadata`, `putMemory`, etc.), add: + +```typescript + /** 列举全部共享 memory entries(按插入顺序) */ + listSharedMemory(): Promise { + return this.requireSharedMemory('listSharedMemory').list() + } + + /** 读取一条共享 memory entry;不存在返回 null */ + getSharedMemoryEntry(slug: string): Promise { + return this.requireSharedMemory('getSharedMemoryEntry').get(slug) + } + + /** 写入或覆盖一条共享 memory entry */ + upsertSharedMemoryEntry(slug: string, summary: string, body: string): Promise { + return this.requireSharedMemory('upsertSharedMemoryEntry').upsert(slug, summary, body) + } + + /** 删除一条共享 memory entry;slug 不存在为 no-op */ + removeSharedMemoryEntry(slug: string): Promise { + return this.requireSharedMemory('removeSharedMemoryEntry').remove(slug) + } +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `pnpm --filter @stello-ai/core test -- shared-memory-sdk` +Expected: PASS + +Also verify no regressions in the existing agent tests: + +Run: `pnpm --filter @stello-ai/core test -- stello-agent` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add packages/core/src/agent/stello-agent.ts \ + packages/core/src/agent/__tests__/shared-memory-sdk.test.ts +git commit -m "feat(core): expose sharedMemory config and four SDK methods on StelloAgent" +``` + +--- + +## Task 5: Implement `memoryRecallTool` (TDD) + +**Files:** +- Create: `packages/core/src/builtin-tools/__tests__/memory-recall-tool.test.ts` +- Create: `packages/core/src/builtin-tools/memory-recall-tool.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/core/src/builtin-tools/__tests__/memory-recall-tool.test.ts +import { describe, it, expect } from 'vitest' +import { memoryRecallTool } from '../memory-recall-tool' +import { InMemorySharedMemoryStore } from '../../shared-memory/in-memory-shared-memory-store' +import type { ToolExecutionContext } from '../../types/tool' +import type { StelloAgent } from '../../agent/stello-agent' + +function fakeAgent(store: InMemorySharedMemoryStore | undefined): StelloAgent { + return { sharedMemory: store } as unknown as StelloAgent +} + +function ctx(agent: StelloAgent): ToolExecutionContext { + return { agent, sessionId: 's1', toolName: 'stello_memory_recall' } +} + +describe('memoryRecallTool', () => { + it('returns ToolRegistryEntry named "stello_memory_recall"', () => { + expect(memoryRecallTool().name).toBe('stello_memory_recall') + }) + + it('returns body for known slug', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'BODY-A') + const r = await memoryRecallTool().execute({ slug: 'a' }, ctx(fakeAgent(store))) + expect(r).toEqual({ success: true, data: { body: 'BODY-A' } }) + }) + + it('returns error for unknown slug', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryRecallTool().execute({ slug: 'nope' }, ctx(fakeAgent(store))) + expect(r.success).toBe(false) + expect(r.error).toMatch(/slug.*nope/i) + }) + + it('returns error when slug is empty', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryRecallTool().execute({ slug: '' }, ctx(fakeAgent(store))) + expect(r.success).toBe(false) + expect(r.error).toMatch(/slug/i) + }) + + it('returns error when sharedMemory is not configured', async () => { + const r = await memoryRecallTool().execute({ slug: 'a' }, ctx(fakeAgent(undefined))) + expect(r.success).toBe(false) + expect(r.error).toMatch(/sharedMemory not configured/i) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @stello-ai/core test -- memory-recall-tool` +Expected: FAIL — cannot resolve `'../memory-recall-tool'` + +- [ ] **Step 3: Write implementation** + +```typescript +// packages/core/src/builtin-tools/memory-recall-tool.ts +import type { ToolRegistryEntry } from '../tool/tool-registry' + +const DESCRIPTION = `按 slug 读取一条共享 memory 的完整内容。 + +参数: +- slug(必填): 索引中列出的某条 entry 的 slug + +何时使用:上下文里 出现了你需要详读的 slug 时调用。` + +const PARAMETERS = { + type: 'object', + properties: { + slug: { type: 'string', description: '索引中的 entry slug' }, + }, + required: ['slug'], +} + +export function memoryRecallTool(): ToolRegistryEntry { + return { + name: 'stello_memory_recall', + description: DESCRIPTION, + parameters: PARAMETERS, + execute: async (args, ctx) => { + const slug = (args.slug as string | undefined)?.trim() + if (!slug) return { success: false, error: 'slug is required and must be non-empty' } + const store = ctx.agent.sharedMemory + if (!store) return { success: false, error: 'sharedMemory not configured' } + try { + const entry = await store.get(slug) + if (!entry) return { success: false, error: `slug "${slug}" not found` } + return { success: true, data: { body: entry.body } } + } catch (e) { + return { success: false, error: `failed: ${e instanceof Error ? e.message : String(e)}` } + } + }, + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @stello-ai/core test -- memory-recall-tool` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/builtin-tools/memory-recall-tool.ts \ + packages/core/src/builtin-tools/__tests__/memory-recall-tool.test.ts +git commit -m "feat(core): add stello_memory_recall builtin tool" +``` + +--- + +## Task 6: Implement `memoryRememberTool` (TDD) + +**Files:** +- Create: `packages/core/src/builtin-tools/__tests__/memory-remember-tool.test.ts` +- Create: `packages/core/src/builtin-tools/memory-remember-tool.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/core/src/builtin-tools/__tests__/memory-remember-tool.test.ts +import { describe, it, expect } from 'vitest' +import { memoryRememberTool } from '../memory-remember-tool' +import { InMemorySharedMemoryStore } from '../../shared-memory/in-memory-shared-memory-store' +import type { ToolExecutionContext } from '../../types/tool' +import type { StelloAgent } from '../../agent/stello-agent' + +function fakeAgent(store: InMemorySharedMemoryStore | undefined): StelloAgent { + return { sharedMemory: store } as unknown as StelloAgent +} + +function ctx(agent: StelloAgent): ToolExecutionContext { + return { agent, sessionId: 's1', toolName: 'stello_memory_remember' } +} + +describe('memoryRememberTool', () => { + it('returns ToolRegistryEntry named "stello_memory_remember"', () => { + expect(memoryRememberTool().name).toBe('stello_memory_remember') + }) + + it('upserts a new entry', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryRememberTool().execute( + { slug: 'a', summary: 'sa', body: 'BODY' }, + ctx(fakeAgent(store)), + ) + expect(r).toEqual({ success: true, data: { slug: 'a' } }) + expect(await store.get('a')).toEqual({ slug: 'a', summary: 'sa', body: 'BODY' }) + }) + + it('overwrites existing entry', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'old') + await memoryRememberTool().execute( + { slug: 'a', summary: 'sa2', body: 'NEW' }, + ctx(fakeAgent(store)), + ) + expect(await store.get('a')).toEqual({ slug: 'a', summary: 'sa2', body: 'NEW' }) + }) + + it('returns error for empty slug', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryRememberTool().execute( + { slug: '', summary: 's', body: 'b' }, + ctx(fakeAgent(store)), + ) + expect(r.success).toBe(false) + expect(r.error).toMatch(/slug/i) + }) + + it('returns error for missing summary', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryRememberTool().execute( + { slug: 'a', body: 'b' }, + ctx(fakeAgent(store)), + ) + expect(r.success).toBe(false) + expect(r.error).toMatch(/summary/i) + }) + + it('returns error for missing body', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryRememberTool().execute( + { slug: 'a', summary: 's' }, + ctx(fakeAgent(store)), + ) + expect(r.success).toBe(false) + expect(r.error).toMatch(/body/i) + }) + + it('returns error when sharedMemory is not configured', async () => { + const r = await memoryRememberTool().execute( + { slug: 'a', summary: 's', body: 'b' }, + ctx(fakeAgent(undefined)), + ) + expect(r.success).toBe(false) + expect(r.error).toMatch(/sharedMemory not configured/i) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @stello-ai/core test -- memory-remember-tool` +Expected: FAIL — cannot resolve `'../memory-remember-tool'` + +- [ ] **Step 3: Write implementation** + +```typescript +// packages/core/src/builtin-tools/memory-remember-tool.ts +import type { ToolRegistryEntry } from '../tool/tool-registry' + +const DESCRIPTION = `写入或覆盖一条共享 memory entry。所有 Session 共享同一份 store。 + +参数: +- slug(必填): kebab-case 主键 +- summary(必填): 索引行展示的一句话 +- body(必填): 详情全文(recall 时返回) + +何时使用:当你判断某个事实 / 偏好 / 背景对整个 agent 都有用,且未来对话需要复用时调用。 +存在则覆盖,不存在则追加;不会改变 entry 的原有插入顺序。` + +const PARAMETERS = { + type: 'object', + properties: { + slug: { type: 'string', description: 'kebab-case 主键' }, + summary: { type: 'string', description: '索引行的一句话' }, + body: { type: 'string', description: '完整内容' }, + }, + required: ['slug', 'summary', 'body'], +} + +export function memoryRememberTool(): ToolRegistryEntry { + return { + name: 'stello_memory_remember', + description: DESCRIPTION, + parameters: PARAMETERS, + execute: async (args, ctx) => { + const slug = (args.slug as string | undefined)?.trim() + if (!slug) return { success: false, error: 'slug is required and must be non-empty' } + const summary = args.summary as string | undefined + if (summary === undefined || summary === null) { + return { success: false, error: 'summary is required' } + } + const body = args.body as string | undefined + if (body === undefined || body === null) { + return { success: false, error: 'body is required' } + } + const store = ctx.agent.sharedMemory + if (!store) return { success: false, error: 'sharedMemory not configured' } + try { + await store.upsert(slug, summary, body) + return { success: true, data: { slug } } + } catch (e) { + return { success: false, error: `failed: ${e instanceof Error ? e.message : String(e)}` } + } + }, + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @stello-ai/core test -- memory-remember-tool` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/builtin-tools/memory-remember-tool.ts \ + packages/core/src/builtin-tools/__tests__/memory-remember-tool.test.ts +git commit -m "feat(core): add stello_memory_remember builtin tool" +``` + +--- + +## Task 7: Implement `memoryForgetTool` (TDD) + +**Files:** +- Create: `packages/core/src/builtin-tools/__tests__/memory-forget-tool.test.ts` +- Create: `packages/core/src/builtin-tools/memory-forget-tool.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/core/src/builtin-tools/__tests__/memory-forget-tool.test.ts +import { describe, it, expect } from 'vitest' +import { memoryForgetTool } from '../memory-forget-tool' +import { InMemorySharedMemoryStore } from '../../shared-memory/in-memory-shared-memory-store' +import type { ToolExecutionContext } from '../../types/tool' +import type { StelloAgent } from '../../agent/stello-agent' + +function fakeAgent(store: InMemorySharedMemoryStore | undefined): StelloAgent { + return { sharedMemory: store } as unknown as StelloAgent +} + +function ctx(agent: StelloAgent): ToolExecutionContext { + return { agent, sessionId: 's1', toolName: 'stello_memory_forget' } +} + +describe('memoryForgetTool', () => { + it('returns ToolRegistryEntry named "stello_memory_forget"', () => { + expect(memoryForgetTool().name).toBe('stello_memory_forget') + }) + + it('removes existing entry', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'ba') + const r = await memoryForgetTool().execute({ slug: 'a' }, ctx(fakeAgent(store))) + expect(r).toEqual({ success: true, data: { slug: 'a' } }) + expect(await store.get('a')).toBeNull() + }) + + it('returns success even when slug does not exist (no-op)', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryForgetTool().execute({ slug: 'missing' }, ctx(fakeAgent(store))) + expect(r).toEqual({ success: true, data: { slug: 'missing' } }) + }) + + it('returns error for empty slug', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryForgetTool().execute({ slug: '' }, ctx(fakeAgent(store))) + expect(r.success).toBe(false) + expect(r.error).toMatch(/slug/i) + }) + + it('returns error when sharedMemory is not configured', async () => { + const r = await memoryForgetTool().execute({ slug: 'a' }, ctx(fakeAgent(undefined))) + expect(r.success).toBe(false) + expect(r.error).toMatch(/sharedMemory not configured/i) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @stello-ai/core test -- memory-forget-tool` +Expected: FAIL — cannot resolve `'../memory-forget-tool'` + +- [ ] **Step 3: Write implementation** + +```typescript +// packages/core/src/builtin-tools/memory-forget-tool.ts +import type { ToolRegistryEntry } from '../tool/tool-registry' + +const DESCRIPTION = `删除一条共享 memory entry。 + +参数: +- slug(必填): 要删除的 entry slug + +何时使用:原 entry 已过时 / 错误 / 不再相关时调用。slug 不存在不报错(no-op)。` + +const PARAMETERS = { + type: 'object', + properties: { + slug: { type: 'string', description: '要删除的 entry slug' }, + }, + required: ['slug'], +} + +export function memoryForgetTool(): ToolRegistryEntry { + return { + name: 'stello_memory_forget', + description: DESCRIPTION, + parameters: PARAMETERS, + execute: async (args, ctx) => { + const slug = (args.slug as string | undefined)?.trim() + if (!slug) return { success: false, error: 'slug is required and must be non-empty' } + const store = ctx.agent.sharedMemory + if (!store) return { success: false, error: 'sharedMemory not configured' } + try { + await store.remove(slug) + return { success: true, data: { slug } } + } catch (e) { + return { success: false, error: `failed: ${e instanceof Error ? e.message : String(e)}` } + } + }, + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @stello-ai/core test -- memory-forget-tool` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/builtin-tools/memory-forget-tool.ts \ + packages/core/src/builtin-tools/__tests__/memory-forget-tool.test.ts +git commit -m "feat(core): add stello_memory_forget builtin tool" +``` + +--- + +## Task 8: Extend `SessionSendOptions` and inject the slot in context assembly (TDD) + +**Files:** +- Modify: `packages/session/src/types/session-api.ts` +- Modify: `packages/session/src/context-utils.ts` +- Modify: `packages/session/src/create-session.ts` +- Create: `packages/session/src/__tests__/shared-memory-index.test.ts` + +- [ ] **Step 1: Add `sharedMemoryIndex` to `SessionSendOptions`** + +In `packages/session/src/types/session-api.ts`, locate the `SessionSendOptions` interface (around line 19) and replace it with: + +```typescript +/** + * Session.send / Session.stream 的运行时选项 + * + * 通过 signal 取消正在进行的 LLM 调用:abort 后 send() reject 为 AbortError, + * stream() 的 result 同样 reject。被取消的调用不写入 L3(user msg 也不持久化)。 + */ +export interface SessionSendOptions { + /** AbortSignal — abort 后中断 LLM 调用并 reject 为 AbortError */ + signal?: AbortSignal + /** + * Agent 级共享 memory 索引段(已由编排层渲染好)。 + * 非空时插入到 systemPrompt 之后、session_identity 之前;为空 / undefined 时不注入。 + */ + sharedMemoryIndex?: string +} +``` + +- [ ] **Step 2: Extend `assembleSessionContext` signature and slot insertion** + +In `packages/session/src/context-utils.ts`, replace the signature and the first few lines of `assembleSessionContext` (currently around line 172): + +```typescript +export async function assembleSessionContext( + sessionId: string, + storage: SessionStorage, + userContent: string, + compress: CompressContext, + label?: string, + sharedMemoryIndex?: string, +): Promise { + const prefixMessages: Message[] = [] + let insightConsumed = false + + // 1. system prompt + const sysPrompt = await storage.getSystemPrompt(sessionId) + if (sysPrompt) { + prefixMessages.push({ role: 'system', content: sysPrompt }) + } + + // 2. shared memory index (agent-level) + if (sharedMemoryIndex) { + prefixMessages.push({ role: 'system', content: sharedMemoryIndex }) + } + + // 3. session identity (label) + prefixMessages.push(...buildSessionIdentityMessages(label)) + + // 4. insight + const insightContent = await storage.getInsight(sessionId) + if (insightContent) { + prefixMessages.push({ role: 'system', content: insightContent }) + insightConsumed = true + } +``` + +Leave the rest of the function (token estimation, compression branch) untouched. + +- [ ] **Step 3: Extend `assembleSessionReplayContext` signature** + +In `packages/session/src/create-session.ts`, locate `assembleSessionReplayContext` (around line 53) and replace its signature + body up through the insight push: + +```typescript +async function assembleSessionReplayContext( + sessionId: string, + storage: CreateSessionOptions['storage'] | LoadSessionOptions['storage'], + label?: string, + sharedMemoryIndex?: string, +): Promise<{ messages: Message[]; insightConsumed: boolean }> { + const messages: Message[] = [] + let insightConsumed = false + + const sysPrompt = await storage.getSystemPrompt(sessionId) + if (sysPrompt) { + messages.push({ role: 'system', content: sysPrompt }) + } + + if (sharedMemoryIndex) { + messages.push({ role: 'system', content: sharedMemoryIndex }) + } + + messages.push(...buildSessionIdentityMessages(label)) + + const insightContent = await storage.getInsight(sessionId) + if (insightContent) { + messages.push({ role: 'system', content: insightContent }) + insightConsumed = true + } + + const memory = await storage.getMemory(sessionId) + if (memory) { + messages.push({ role: 'system', content: memory }) + } + + // 注意:此处刻意不调用 removeIncompleteToolCallGroups。 + // replay 路径会把"assistant(toolCalls) + 由 envelope 合成的 tool 消息"拼接成完整组, + // 在加载阶段过早裁剪反而会把回灌目标删掉。完整组校验放在拼接后由调用方做。 + const history = await storage.listRecords(sessionId) + messages.push(...history) + return { messages, insightConsumed } +} +``` + +- [ ] **Step 4: Plumb `sharedMemoryIndex` through both send call sites** + +In `packages/session/src/create-session.ts`, the `send` method (around line 150) and `stream` method (around line 223) both call `assembleSessionContext`. They also detect tool-result envelopes and call `assembleSessionReplayContext`. Update both call sites in **both methods** to forward `sendOptions?.sharedMemoryIndex`. Concretely: + +```typescript +// Before (example — assembleSessionContext call) +const assembled = await assembleSessionContext( + meta.id, + storage, + content, + compressCtx, + meta.label, +) + +// After +const assembled = await assembleSessionContext( + meta.id, + storage, + content, + compressCtx, + meta.label, + sendOptions?.sharedMemoryIndex, +) +``` + +Apply the analogous fourth-positional update to every `assembleSessionReplayContext(meta.id, storage, meta.label)` call: + +```typescript +// After +await assembleSessionReplayContext(meta.id, storage, meta.label, sendOptions?.sharedMemoryIndex) +``` + +- [ ] **Step 5: Write the integration test** + +```typescript +// packages/session/src/__tests__/shared-memory-index.test.ts +import { describe, it, expect } from 'vitest' +import { createSession } from '../create-session' +import { InMemoryStorageAdapter } from '../mocks/in-memory-storage' +import type { LLMAdapter, Message } from '../types/llm' + +function makeLLM(): { adapter: LLMAdapter; lastMessages: () => Message[] } { + let captured: Message[] = [] + const adapter: LLMAdapter = { + async complete(messages) { + captured = messages + return { content: 'ok' } + }, + } + return { adapter, lastMessages: () => captured } +} + +describe('shared memory index injection', () => { + it('inserts shared memory index between systemPrompt and session_identity', async () => { + const storage = new InMemoryStorageAdapter() + const { adapter, lastMessages } = makeLLM() + const session = await createSession({ + id: 's1', + label: 'child', + storage, + llm: adapter, + }) + await session.setSystemPrompt('SYS') + await session.send('hello', { sharedMemoryIndex: '\n- a: x\n' }) + + const msgs = lastMessages() + const sysIdx = msgs.findIndex(m => m.role === 'system' && m.content === 'SYS') + const memIdx = msgs.findIndex(m => m.role === 'system' && m.content.includes('')) + const idIdx = msgs.findIndex(m => m.role === 'system' && m.content.includes('')) + + expect(sysIdx).toBeGreaterThanOrEqual(0) + expect(memIdx).toBeGreaterThan(sysIdx) + expect(idIdx).toBeGreaterThan(memIdx) + }) + + it('omits the slot when sharedMemoryIndex is undefined', async () => { + const storage = new InMemoryStorageAdapter() + const { adapter, lastMessages } = makeLLM() + const session = await createSession({ + id: 's1', + label: 'child', + storage, + llm: adapter, + }) + await session.setSystemPrompt('SYS') + await session.send('hello') // no options + + const msgs = lastMessages() + expect(msgs.find(m => m.content.includes(''))).toBeUndefined() + }) + + it('omits the slot when sharedMemoryIndex is empty string', async () => { + const storage = new InMemoryStorageAdapter() + const { adapter, lastMessages } = makeLLM() + const session = await createSession({ + id: 's1', + label: 'child', + storage, + llm: adapter, + }) + await session.send('hi', { sharedMemoryIndex: '' }) + + const msgs = lastMessages() + expect(msgs.find(m => m.content.includes(''))).toBeUndefined() + }) +}) +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `pnpm --filter @stello-ai/session test -- shared-memory-index` +Expected: PASS (all three cases) + +Also run the full session test suite to verify no regressions: + +Run: `pnpm --filter @stello-ai/session test` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/session/src/types/session-api.ts \ + packages/session/src/context-utils.ts \ + packages/session/src/create-session.ts \ + packages/session/src/__tests__/shared-memory-index.test.ts +git commit -m "feat(session): add sharedMemoryIndex slot in context assembly" +``` + +--- + +## Task 9: Wire the index injection through the runtime adapter + +**Files:** +- Modify: `packages/core/src/adapters/session-runtime.ts` +- Modify: `packages/core/src/agent/stello-agent.ts` (resolveRuntimeResolver hook) + +- [ ] **Step 1: Extend `SessionCompatibleSendOptions`** + +In `packages/core/src/adapters/session-runtime.ts`, replace the `SessionCompatibleSendOptions` interface: + +```typescript +/** Session.send / Session.stream 的可选运行时参数(结构兼容 @stello-ai/session) */ +export interface SessionCompatibleSendOptions { + /** AbortSignal — abort 时底层 LLM 调用应被取消 */ + signal?: AbortSignal + /** Agent 级共享 memory 索引段(已由编排层渲染) */ + sharedMemoryIndex?: string +} +``` + +- [ ] **Step 2: Accept a per-send index provider in adapter options** + +In the same file, replace `SessionRuntimeAdapterOptions`: + +```typescript +/** Session -> EngineRuntime 适配配置 */ +export interface SessionRuntimeAdapterOptions { + /** 上下文压缩函数(可选) */ + compressFn?: SessionCompatibleCompressFn + /** 自定义 send() 结果序列化方式,默认转成 JSON 字符串 */ + serializeResult?: (result: SessionCompatibleSendResult) => string + /** + * 每次 send/stream 前调用,返回当前 agent 的共享 memory 索引段。 + * 返回 undefined / 空字符串则不注入。adapter 把结果合并进 sendOptions.sharedMemoryIndex。 + */ + sharedMemoryIndexProvider?: () => Promise +} +``` + +- [ ] **Step 3: Use the provider in both send and stream wrappers** + +In `adaptSessionToEngineRuntime`, replace the inner `send` definition: + +```typescript + async send(input: string, sendOptions?: SessionCompatibleSendOptions): Promise { + const sharedMemoryIndex = await options.sharedMemoryIndexProvider?.() + const mergedOptions: SessionCompatibleSendOptions = { + ...sendOptions, + ...(sharedMemoryIndex ? { sharedMemoryIndex } : {}), + } + const result = await session.send(input, mergedOptions) + turnCount += 1 + return (options.serializeResult ?? serializeSessionSendResult)(result) + }, +``` + +And the stream branch (replace the existing `stream` adapter inside the `...(session.stream ? { ... } : {})` block): + +```typescript + ...(session.stream + ? { + stream(input: string, sendOptions?: SessionCompatibleSendOptions) { + const indexPromise = options.sharedMemoryIndexProvider?.() ?? Promise.resolve(undefined) + const source = (async () => { + const sharedMemoryIndex = await indexPromise + const mergedOptions: SessionCompatibleSendOptions = { + ...sendOptions, + ...(sharedMemoryIndex ? { sharedMemoryIndex } : {}), + } + return session.stream!(input, mergedOptions) + })() + return { + result: (async () => { + const stream = await source + const result = await stream.result + turnCount += 1 + return (options.serializeResult ?? serializeSessionSendResult)(result) + })(), + async *[Symbol.asyncIterator]() { + const stream = await source + for await (const chunk of stream) yield chunk + }, + } + }, + } + : {}), +``` + +- [ ] **Step 4: Plumb the provider from `StelloAgent`'s `resolveRuntimeResolver`** + +In `packages/core/src/agent/stello-agent.ts`, add the import for the renderer near the top: + +```typescript +import { renderSharedMemoryIndex } from '../shared-memory/render-index' +``` + +Locate `resolveRuntimeResolver` (around line 145) and update its signature + body: + +```typescript +function resolveRuntimeResolver(config: StelloAgentConfig, agent: StelloAgent): SessionRuntimeResolver { + if (config.runtime?.resolver) { + return config.runtime.resolver + } + + if (config.session?.sessionLoader) { + const adaptOptions = { + compressFn: config.sessionDefaults?.compressFn, + serializeResult: config.session!.serializeSendResult ?? serializeSessionSendResult, + sharedMemoryIndexProvider: () => renderSharedMemoryIndex(agent.sharedMemory), + } + return { + resolve: async (sessionId: string) => { + const { session } = await config.session!.sessionLoader!(sessionId) + return adaptSessionToEngineRuntime(session, adaptOptions) + }, + } + } + + throw new Error( + 'StelloAgentConfig 缺少 runtime.resolver;若使用 session 配置接入,请提供 session.sessionLoader', + ) +} +``` + +(Note the added `agent` parameter so the provider closure captures `agent.sharedMemory`.) + +Update the call site inside the `StelloAgent` constructor: + +```typescript + sessionRuntimeResolver: resolveRuntimeResolver(config, this), +``` + +- [ ] **Step 5: Verify the existing test suite still passes** + +Run: `pnpm --filter @stello-ai/core test` +Expected: PASS (no regressions) + +Run: `pnpm --filter @stello-ai/session test` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/src/adapters/session-runtime.ts \ + packages/core/src/agent/stello-agent.ts +git commit -m "feat(core): inject sharedMemoryIndex on every session.send via adapter" +``` + +--- + +## Task 10: Remove the legacy `MemoryEngine` entirely + +**Files (delete):** +- `packages/core/src/types/memory.ts` +- `packages/core/src/memory/file-system-memory-engine.ts` +- `packages/core/src/memory/__tests__/` (whole directory) + +**Files (modify):** +- `packages/core/src/agent/stello-agent.ts` +- `packages/core/src/engine/stello-engine.ts` +- `packages/core/src/orchestrator/default-engine-factory.ts` +- `packages/core/src/types/engine.ts` +- `packages/core/src/types.ts` +- `packages/core/src/index.ts` +- `packages/core/src/agent/__tests__/stello-agent.test.ts` +- `packages/core/src/__tests__/builtin-tools-llm-exposure.test.ts` + +- [ ] **Step 1: Delete the legacy memory directory + types file** + +Run: + +```bash +rm -rf packages/core/src/memory +rm packages/core/src/types/memory.ts +``` + +- [ ] **Step 2: Drop `memory` field/import in `StelloAgent`** + +In `packages/core/src/agent/stello-agent.ts`: + +1. Remove the import line: `import type { MemoryEngine } from '../types/memory';` +2. Remove the line `memory: MemoryEngine;` from `StelloAgentConfig` +3. Remove `readonly memory: StelloAgentConfig['memory'];` field +4. Remove `this.memory = config.memory;` from the constructor +5. Remove `memory: config.memory,` from the `DefaultEngineFactory` constructor call + +- [ ] **Step 3: Drop `memory` from `StelloEngineImpl`** + +In `packages/core/src/engine/stello-engine.ts`: + +1. Remove the import: `import type { MemoryEngine, TurnRecord } from '../types/memory';` +2. Remove `memory: MemoryEngine;` from `StelloEngineOptions` (around line 97) +3. Remove `readonly memory: MemoryEngine;` from the class +4. Remove `this.memory = options.memory;` from the constructor +5. Search for any remaining references to `this.memory` or `options.memory` in this file and remove them (per pre-implementation grep there are none in active code paths) + +- [ ] **Step 4: Drop `memory` from `DefaultEngineFactory`** + +In `packages/core/src/orchestrator/default-engine-factory.ts`: + +1. Remove the import: `import type { MemoryEngine } from '../types/memory';` +2. Remove `memory: MemoryEngine;` from `DefaultEngineFactoryOptions` +3. Remove `memory: this.options.memory,` from the `StelloEngineImpl` constructor call inside `create()` + +- [ ] **Step 5: Drop from internal type re-exports** + +In `packages/core/src/types/engine.ts`: +- Remove `MemoryEngine,` from the import list at the top +- Remove `readonly memory: MemoryEngine;` from any interface that declares it (around line 80) + +In `packages/core/src/types.ts`: +- Remove `MemoryEngine,` from the `export type { ... }` block + +- [ ] **Step 6: Drop from public package exports** + +In `packages/core/src/index.ts`: +- Remove the entire `// 记忆系统` block (`InheritancePolicy`, `CoreSchemaField`, `CoreSchema`, `TurnRecord`, `AssembledContext`, `MemoryEngine`) from the `export type { ... }` block +- Remove the line: `export { FileSystemMemoryEngine } from './memory/file-system-memory-engine';` +- Keep `FileSystemAdapter` re-export (it's used elsewhere) + +- [ ] **Step 7: Drop placeholder fixtures from existing tests** + +In `packages/core/src/agent/__tests__/stello-agent.test.ts`, find every occurrence of: + +```typescript +memory: {} as MemoryEngine, +``` + +(grep listed lines 43, 217, 400 in the pre-implementation snapshot) and delete those lines. Also remove the `import type { MemoryEngine } from '...';` at the top if present. + +In `packages/core/src/__tests__/builtin-tools-llm-exposure.test.ts`: +- Line 80 currently has `memory: {} as MemoryEngine,` — delete the line and the matching import. + +- [ ] **Step 8: Verify full test suite passes** + +Run: `pnpm --filter @stello-ai/core test` +Expected: PASS — no remaining references to `MemoryEngine` + +Run: `pnpm --filter @stello-ai/core exec tsc --noEmit` +Expected: PASS — no dangling imports + +- [ ] **Step 9: Commit** + +```bash +git add -A packages/core +git commit -m "refactor(core): drop legacy MemoryEngine and FileSystemMemoryEngine" +``` + +--- + +## Task 11: Update `@stello-ai/core` public exports for shared memory + +**Files:** +- Modify: `packages/core/src/builtin-tools/index.ts` +- Modify: `packages/core/src/index.ts` + +- [ ] **Step 1: Export the three tool factories from builtin-tools** + +Replace `packages/core/src/builtin-tools/index.ts` contents: + +```typescript +export { createSessionTool } from './create-session-tool' +export { activateSkillTool } from './activate-skill-tool' +export { memoryRecallTool } from './memory-recall-tool' +export { memoryRememberTool } from './memory-remember-tool' +export { memoryForgetTool } from './memory-forget-tool' +``` + +- [ ] **Step 2: Export shared memory types + impl + tools from core package** + +In `packages/core/src/index.ts`: + +1. Update the builtin-tools re-export block: + +```typescript +// 内置 tool 工厂(builtin-tools redesign) +export { + createSessionTool, + activateSkillTool, + memoryRecallTool, + memoryRememberTool, + memoryForgetTool, +} from './builtin-tools'; +``` + +2. Add a new dedicated block (after the `createStelloAgent` export block): + +```typescript +// 共享 memory +export { InMemorySharedMemoryStore } from './shared-memory/in-memory-shared-memory-store'; +export { renderSharedMemoryIndex } from './shared-memory/render-index'; +export type { SharedMemoryEntry, SharedMemoryStore } from './shared-memory/types'; +``` + +- [ ] **Step 3: Verify build** + +Run: `pnpm --filter @stello-ai/core build` +Expected: PASS (tsup builds ESM + CJS + DTS without errors) + +- [ ] **Step 4: Verify tests still pass** + +Run: `pnpm --filter @stello-ai/core test` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/builtin-tools/index.ts \ + packages/core/src/index.ts +git commit -m "feat(core): export SharedMemoryStore types, InMemorySharedMemoryStore, and three tool factories" +``` + +--- + +## Task 12: End-to-end smoke test (index visible across multiple sends) + +**Files:** +- Create: `packages/core/src/__tests__/shared-memory-e2e.test.ts` + +- [ ] **Step 1: Write the smoke test** + +```typescript +// packages/core/src/__tests__/shared-memory-e2e.test.ts +import { describe, it, expect } from 'vitest' +import { adaptSessionToEngineRuntime } from '../adapters/session-runtime' +import { InMemorySharedMemoryStore } from '../shared-memory/in-memory-shared-memory-store' +import { renderSharedMemoryIndex } from '../shared-memory/render-index' +import type { SessionCompatible, SessionCompatibleSendOptions, SessionCompatibleSendResult } from '../adapters/session-runtime' + +function makeFakeSession(): { session: SessionCompatible; capturedOptions: SessionCompatibleSendOptions[] } { + const capturedOptions: SessionCompatibleSendOptions[] = [] + const session: SessionCompatible = { + meta: { id: 'r', status: 'active' }, + async send(_input, options) { + capturedOptions.push(options ?? {}) + const result: SessionCompatibleSendResult = { content: 'ok' } + return result + }, + async messages() { return [] }, + async consolidate() {}, + setTools() {}, + } + return { session, capturedOptions } +} + +describe('shared memory end-to-end', () => { + it('adapter injects current index on every send', async () => { + const store = new InMemorySharedMemoryStore() + const { session, capturedOptions } = makeFakeSession() + const runtime = await adaptSessionToEngineRuntime(session, { + sharedMemoryIndexProvider: () => renderSharedMemoryIndex(store), + }) + + // first send — store empty, no index + await runtime.send('hi', {}) + expect(capturedOptions[0]!.sharedMemoryIndex).toBeUndefined() + + // write one entry + await store.upsert('a', 'sa', 'BODY') + + // second send — index present + await runtime.send('hi again', {}) + expect(capturedOptions[1]!.sharedMemoryIndex).toContain('') + expect(capturedOptions[1]!.sharedMemoryIndex).toContain('- a: sa') + + // delete the entry + await store.remove('a') + + // third send — back to undefined + await runtime.send('hi once more', {}) + expect(capturedOptions[2]!.sharedMemoryIndex).toBeUndefined() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `pnpm --filter @stello-ai/core test -- shared-memory-e2e` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add packages/core/src/__tests__/shared-memory-e2e.test.ts +git commit -m "test(core): add end-to-end test for shared memory index injection" +``` + +--- + +## Task 13: Final verification — full monorepo test + build + +- [ ] **Step 1: Run full test suite across both packages** + +Run: `pnpm --filter '@stello-ai/*' test` +Expected: PASS + +- [ ] **Step 2: Run full build to confirm dist is clean** + +Run: `pnpm --filter '@stello-ai/*' build` +Expected: PASS — both `core` and `session` produce ESM + CJS + DTS without errors + +- [ ] **Step 3: Verify there are no remaining references to `MemoryEngine` / `FileSystemMemoryEngine`** + +Run: `grep -rn "MemoryEngine\|FileSystemMemoryEngine" packages/core/src packages/session/src --include="*.ts"` +Expected: **no output** (zero matches) + +- [ ] **Step 4: Verify the shared-memory exports are visible at the package root** + +Run: `grep -n "SharedMemoryStore\|InMemorySharedMemoryStore\|memoryRecallTool" packages/core/src/index.ts` +Expected: at least three matches (the new export block + the tools re-export) + +- [ ] **Step 5: If any of the previous checks failed, file a follow-up commit fixing the issue and re-run.** + +(No commit if everything is green — Task 12 was the last functional change.) diff --git a/docs/superpowers/specs/2026-05-17-shared-memory-design.md b/docs/superpowers/specs/2026-05-17-shared-memory-design.md new file mode 100644 index 0000000..95596d2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-shared-memory-design.md @@ -0,0 +1,351 @@ +# StelloAgent Shared Memory — 设计文档 + +> **状态**:Spec(架构与 API 决策) +> **日期**:2026-05-17 +> **前置**:`2026-05-16-decouple-main-session-design.md` 已实施 +> **范围**:StelloAgent 级跨 Session 共享 memory 的接口与编排定调。实现细节(具体方法签名以外的文件级改动、测试用例清单)由后续 plan 文档承接。 + +--- + +## 1. 背景与目标 + +### 1.1 现状 + +main-session decouple refactor 落地后: + +- `SessionStorage` 持有 per-session 内容(systemPrompt / insight / memory / L3 / SessionMeta CRUD) +- `agent.memory` 字段仍指向遗留 `MemoryEngine` 接口与 `FileSystemMemoryEngine` 实现 —— 这是 refactor 前的旧设计(L1 core.json + L2 per-session memory.md/scope.md/index.md + L3 records.jsonl),**目前已无任何代码调用其方法**,仅作为注入位残留 +- 内置 tool 只有 `createSessionTool` / `activateSkillTool` +- 上下文组装规则:`system prompt → session_identity → insight → memory → L3 → user message`,固定不暴露扩展点 + +外部反思 / 跨 Session 综合由应用层在 `agent.listSessionDigests` / `agent.putInsight` 之上自行实现,框架不持有跨 Session 状态。 + +### 1.2 目标 + +引入 **StelloAgent 级共享 memory** —— + +- 一个 `StelloAgent` 实例对应**一份**共享 memory,所有 root、所有子 Session 都可读可写 +- 适用于"整个 agent 范围内稳定的认知"——用户背景、偏好、跨树事实 +- 索引始终注入对话上下文;详情通过 `stello_memory_recall` 内置 tool 懒加载 +- Stello 同时暴露 SDK 层读写接口,便于应用层维护 / 展示 / 同步 + +### 1.3 非目标 + +- 不引入多 agent 跨进程同步、订阅、事件 +- 不做 embedding / 语义检索(slug-based 直接定位) +- 不做版本控制、历史回溯、diff +- 不引入跨 root 的"局部共享"层(多 root 共享 = agent 范围) +- 不预制文件系统适配器实现(同 SessionStorage,留给应用层) + +--- + +## 2. 设计原则 + +1. **职责单一**:shared memory 只解决"agent 范围、agent 可写、详情懒加载"这一个问题;不耦合 session、tool registry、storage 适配器 +2. **接口收敛**:单一 `SharedMemoryStore` 接口;不分多个子接口 +3. **零应用域建模**:entry 只有 slug / summary / body 三字段,不预判 type / tags / 时间戳 +4. **零隐式 LLM**:所有读写都是数据 IO;agent 写入由内置 tool 显式触发 +5. **延续既有范式**:内置 tool 走 factory + ctx 模式;store 用 writeLock 串行;SDK 方法扁平挂在 StelloAgent 上 +6. **清理 dead code**:同 release 把 legacy `MemoryEngine` 全部删除,避免双轨 + +--- + +## 3. 数据模型 + +### 3.1 Entry + +``` +SharedMemoryEntry { + slug: string + summary: string + body: string +} +``` + +- **slug**:主键,kebab-case,应用层 / agent 自定,框架不校验合法字符集(但要求非空) +- **summary**:索引里出现的一行,无长度限制(建议短,但不强制) +- **body**:recall 时返回的全文 + +不引入字段: + +- 无 `type` / `tags`:违背"零应用域建模",分类需求由 agent 在 summary 里写前缀解决 +- 无 `createdAt` / `updatedAt`:不维护时间元数据,调用方需要时间感知应在 body 里自己写 +- 无嵌套 KV / schema:legacy `MemoryEngine.core.json` 的 point-path 思路不沿用 + +### 3.2 排序 + +`list()` 按**插入顺序**返回。upsert 一条已存在的 slug **不改变其顺序位置**(仅覆盖 summary + body)。 + +### 3.3 索引渲染 + +索引由所有 entries 的 `slug + summary` 渲染: + +``` + +- prefer-concise: 用户偏好简短回答 +- user-profile: 大三本科生 CS 专业 + + +调用 stello_memory_recall 工具按 slug 查阅完整内容; +调用 stello_memory_remember / stello_memory_forget 工具维护此处条目。 +``` + +格式固定,框架不暴露模板扩展点。 + +### 3.4 空状态 + +entries 数组为空时,索引段(含 hint 文本)**完全不注入**上下文。三个内置 tool 仍然注册可用。 + +--- + +## 4. 上下文注入 + +### 4.1 注入位置 + +上下文装配顺序调整为: + +``` +[system prompt] +[shared_memory_index] ← 新增槽位 +[session_identity] +[insight if present, consume] +[memory if present] +[L3 history with sanitize] +[user message] +``` + +- 高于 session_identity:shared memory 是 agent 范围共享认知,比"这个 session 是谁"更全局 +- 低于 system prompt:避免覆盖应用层固化指令 +- 与 memory / insight 严格分槽:shared memory 是 agent 范围、memory 是 per-session 持久、insight 是 per-session 一次性 + +### 4.2 消费策略 + +每次 send 都**全量重新渲染并注入**,不缓存(索引体积小,渲染开销可忽略)。 + +### 4.3 与压缩的关系 + +shared memory 索引是 system 段内容、不进入 L3 历史,因此不参与历史压缩。每次 send 都按当前 store 状态重新拉取。 + +--- + +## 5. 写操作与并发 + +### 5.1 操作集 + +| 操作 | 语义 | +|---|---| +| `upsert(slug, summary, body)` | 不存在则新增(追加到末尾),存在则覆盖 summary + body(保持原插入顺序位置) | +| `remove(slug)` | 按 slug 删除,不存在为 no-op | + +**不提供**: + +- 分别更新 summary 或 body 的细粒度 API(YAGNI) +- 批量写入 / transaction(单操作已原子,YAGNI) +- rename:删 + 新增即可 + +### 5.2 并发 + +`SharedMemoryStore` 实现内部用 **per-store writeLock** 串行化所有写操作(upsert / remove): + +- 多个 tool 调用 / SDK 调用并发触发时,store 内排队,先到先做完 +- 单次写入是 RMW(读全集 → 改 → 写回),lock 保证原子 +- 读取(`list` / `get`)**不加锁**,允许脏读 + +沿用项目里 `SessionTree.writeLock` 的现成范式,认知成本零。 + +### 5.3 错误处理 + +- 内置 tool 写入抛错 → tool 返回 `"failed: {reason}"` 字符串,agent 自行决定要不要重试(不中断对话) +- SDK 调用抛错 → 同步向调用方抛出 +- store 未注入时调用 SDK 或 tool → 抛 `"sharedMemory not configured"`(同 `requireStorage` 写法) + +--- + +## 6. 内置工具 + +放在 `packages/core/src/builtin-tools/`,仿 `createSessionTool` / `activateSkillTool` 的 factory + ctx 模式。 + +### 6.1 工具列表 + +| 工具名 | 参数 | 返回 | +|---|---|---| +| `stello_memory_recall` | `slug: string` | entry body 全文;slug 不存在返回明确错误文本 | +| `stello_memory_remember` | `slug: string, summary: string, body: string` | 成功确认;upsert 语义 | +| `stello_memory_forget` | `slug: string` | 成功确认;slug 不存在仍返回成功(no-op) | + +### 6.2 Factory + +三个工具各自暴露为 factory: + +``` +memoryRecallTool(): ToolFactory +memoryRememberTool(): ToolFactory +memoryForgetTool(): ToolFactory +``` + +应用层在构造 ToolRegistry 时显式 opt-in,与 `createSessionTool()` / `activateSkillTool(skills)` 同款。三个 tool 都从 `ctx.agent` 拿 `SharedMemoryStore`。 + +**不提供 `memoryToolSet()` 打包**:应用可能只想给某些 Session 开 recall、不开 remember;ToolRegistry 已支持按 Session 配置,框架不重复抽象。 + +### 6.3 异常返回 + +按现有 builtin tools 惯例: + +- slug 为空 → tool 返回 error 字符串,不抛 +- store 异常 → tool 返回 `"failed: {reason}"`,agent 决定如何处理 + +--- + +## 7. 外部 SDK + +在 `StelloAgent` 上扁平挂载四个方法,命名风格同 `putMemory` / `getSessionMetadata`: + +| 方法 | 参数 | 返回 | +|---|---|---| +| `listSharedMemory()` | 无 | `SharedMemoryEntry[]`(按插入顺序) | +| `getSharedMemoryEntry(slug)` | `slug` | `SharedMemoryEntry \| null` | +| `upsertSharedMemoryEntry(slug, summary, body)` | 三参数 | `Promise` | +| `removeSharedMemoryEntry(slug)` | `slug` | `Promise` | + +### 7.1 注入 + +`StelloAgentConfig` 添加: + +``` +sharedMemory?: SharedMemoryStore +``` + +未注入时: + +- 四个 SDK 方法抛 `"sharedMemory not configured"` +- 三个内置 tool 调用抛同样错误 +- 索引段不注入上下文(同空 entries 状态) + +### 7.2 不提供的 API + +- **transaction / batch**:YAGNI +- **subscribe / on('changed')**:YAGNI;应用有需求可在自己的 store 实现里挂事件 +- **renderIndex()**:调用方拿到 list 自己渲染,框架不重复 + +--- + +## 8. `SharedMemoryStore` 接口 + +放在 `@stello-ai/core`(agent 级概念,不属 session 层)。 + +### 8.1 接口 + +``` +SharedMemoryStore { + list(): Promise + get(slug: string): Promise + upsert(slug, summary, body): Promise + remove(slug: string): Promise +} +``` + +- 形状与 SDK 一一对应;SDK 方法是薄代理 + 错误兜底 +- `list()` 按插入顺序返回(FIFO 约定,上层 SDK / 索引渲染依赖此约定) +- writeLock 串行**由实现内部保证**,不暴露给调用方 + +### 8.2 内置实现 + +提供 `InMemorySharedMemoryStore` 作为默认 / 测试用: + +- 基于 `Map`(JS Map 天然保留插入顺序,list() 直接按 entries 顺序返回) +- 内置 writeLock(沿用现有 SessionTree.writeLock 工具,或本地 Promise 串行) + +**不提供** `FileSystemSharedMemoryStore`:与 SessionStorage 文件适配器一样的处理——落盘策略(per-entry .md / 单文件 JSON / SQLite)应用层差异大,留给应用层。 + +### 8.3 序列化建议(文档建议,非接口约束) + +给应用层文件实现的参考布局: + +``` +basePath/ + shared-memory/ + INDEX.md + entries/ + prefer-concise.md + user-profile.md +``` + +但 store 实现愿意把所有 entry 塞一个 JSON 也合法。 + +--- + +## 9. Legacy `MemoryEngine` 清理 + +同 release 一次性删除: + +| 项 | 性质 | +|---|---| +| `packages/core/src/types/memory.ts` 整文件 | 类型 | +| `packages/core/src/memory/file-system-memory-engine.ts` | 实现 | +| `packages/core/src/memory/__tests__/` 全部 | 测试 | +| `StelloAgentConfig.memory: MemoryEngine` 字段 | 配置 | +| `StelloAgent.memory` 字段 | 属性 | +| Engine / DefaultEngineFactory / types/engine 对 `memory: MemoryEngine` 的引用 | 内部接线 | +| `index.ts` 的 `MemoryEngine` / `FileSystemMemoryEngine` 公开导出 | 公共 API | + +CHANGELOG 列入 breaking。 + +--- + +## 10. 测试方向 + +| 测试位置 | 覆盖点 | +|---|---| +| `packages/core/src/agent/__tests__/shared-memory.test.ts` | SDK 四方法正常路径 + store 未注入抛错 | +| `packages/core/src/builtin-tools/__tests__/memory-recall.test.ts` 等三个 | 三个 tool 正常路径 / slug 不存在 / 非法参数 | +| `packages/session/src/__tests__/context-assembly.test.ts`(扩) | 索引段注入顺序、空索引不出现、模板格式 | +| `packages/core/src/__tests__/in-memory-shared-memory-store.test.ts` | 内置 store 并发串行化(多 upsert 并发不丢数据)+ 插入顺序保持 | + +覆盖原则按 CLAUDE.md:公开接口正常路径 + 错误输入 + 边界条件。 + +--- + +## 11. 范围、迁移、风险 + +### 11.1 范围内 + +- `@stello-ai/core` 实施本 spec 全部改动 +- `@stello-ai/session` 仅扩 context assembly(加 shared_memory_index 槽) +- 新增三个内置 tool、`InMemorySharedMemoryStore` 实现、`SharedMemoryStore` 接口、四个 SDK 方法 +- 删除 legacy MemoryEngine 全套 + +### 11.2 范围外 + +- 文件系统 / DB 适配器实现 +- demo / devtools / visualizer 集成(CHANGELOG 标注) +- 应用层 reflection 模式与 shared memory 联动的官方示例 + +### 11.3 风险与已知 break + +| 风险 | 说明 | 处置 | +|---|---|---| +| 上下文装配多一槽 | session 包的 assemble 规则变更 | 已在 §4.1 明确位置;测试覆盖注入顺序 | +| Legacy MemoryEngine 删除 | 公共 API 表面收缩 | CHANGELOG 列入 breaking;本来就无生效代码 | +| shared memory 滥用 | agent 写太多冗余 entry → 索引膨胀 | 当前不引入截断;后续按使用反馈再加策略 | +| store 注入遗漏 | 未注入但调用 → 抛错 | 错误消息明确 `"sharedMemory not configured"` | +| writeLock 死锁 | RMW 写操作内部不递归调用其他写 | 实现自检;测试覆盖并发 upsert | + +### 11.4 版本与发布 + +前一次 main-session decouple 的 release commit 尚未推到 npm,本次改动直接并入同一个未发布版本,无需考虑 deprecated alias / 跨版本兼容。CHANGELOG 在最终 npm 发布前一并整理。 + +### 11.5 已知未决问题(spec 不解决,备忘) + +1. 文件系统 store 的官方默认实现(什么时候做、做不做) +2. 索引膨胀治理策略(按字节截断 / 按 LRU 淘汰 / 自动 consolidate) +3. shared memory 的导入导出(备份 / 跨 agent 迁移) +4. 多 root 时是否需要"per-root 子作用域"层(当前 spec 选择不做) + +--- + +## 12. 设计立场回顾 + +1. **agent-writable + 索引注入 + tool 详情**:参考 Claude Code auto-memory 范式,与人工书写的 system prompt 分层 +2. **接口最薄**:四方法 + 三 tool + 一个 store interface,没有多余抽象 +3. **延续既有范式**:writeLock、factory + ctx、扁平 SDK 命名,零认知成本 +4. **死代码同步清理**:legacy MemoryEngine 一次性删完,避免双轨长期共存 diff --git a/packages/core/src/__tests__/builtin-tools-llm-exposure.test.ts b/packages/core/src/__tests__/builtin-tools-llm-exposure.test.ts index 1cf2921..d81e5c8 100644 --- a/packages/core/src/__tests__/builtin-tools-llm-exposure.test.ts +++ b/packages/core/src/__tests__/builtin-tools-llm-exposure.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, vi } from 'vitest' import type { SessionTree } from '../types/session' -import type { MemoryEngine } from '../types/memory' import type { ConfirmProtocol } from '../types/lifecycle' import type { StelloAgent } from '../agent/stello-agent' import type { LLMAdapter, LLMResult } from '@stello-ai/session' @@ -77,7 +76,6 @@ describe('Built-in tool LLM exposure (bug regression)', () => { getConfig: vi.fn().mockResolvedValue(null), putConfig: vi.fn().mockResolvedValue(undefined), } as unknown as SessionTree, - memory: {} as MemoryEngine, skills, confirm: {} as ConfirmProtocol, agent: {} as StelloAgent, diff --git a/packages/core/src/__tests__/shared-memory-e2e.test.ts b/packages/core/src/__tests__/shared-memory-e2e.test.ts new file mode 100644 index 0000000..363ef63 --- /dev/null +++ b/packages/core/src/__tests__/shared-memory-e2e.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest' +import { adaptSessionToEngineRuntime } from '../adapters/session-runtime' +import { InMemorySharedMemoryStore } from '../shared-memory/in-memory-shared-memory-store' +import { renderSharedMemoryIndex } from '../shared-memory/render-index' +import type { + SessionCompatible, + SessionCompatibleSendOptions, + SessionCompatibleSendResult, +} from '../adapters/session-runtime' + +function makeFakeSession(): { + session: SessionCompatible + capturedOptions: SessionCompatibleSendOptions[] +} { + const capturedOptions: SessionCompatibleSendOptions[] = [] + const session: SessionCompatible = { + meta: { id: 'r', status: 'active' }, + async send(_input, options) { + capturedOptions.push(options ?? {}) + const result: SessionCompatibleSendResult = { content: 'ok' } + return result + }, + async messages() { + return [] + }, + async consolidate() { + // no-op + }, + setTools() { + // no-op + }, + } + return { session, capturedOptions } +} + +describe('shared memory end-to-end', () => { + it('adapter injects current index on every send', async () => { + const store = new InMemorySharedMemoryStore() + const { session, capturedOptions } = makeFakeSession() + const runtime = await adaptSessionToEngineRuntime(session, { + sharedMemoryIndexProvider: () => renderSharedMemoryIndex(store), + }) + + // first send — store empty, no index + await runtime.send('hi', {}) + expect(capturedOptions[0]!.sharedMemoryIndex).toBeUndefined() + + // write one entry + await store.upsert('a', 'sa', 'BODY') + + // second send — index present + await runtime.send('hi again', {}) + expect(capturedOptions[1]!.sharedMemoryIndex).toBeDefined() + expect(capturedOptions[1]!.sharedMemoryIndex).toContain('') + expect(capturedOptions[1]!.sharedMemoryIndex).toContain('- a: sa') + + // delete the entry + await store.remove('a') + + // third send — back to undefined + await runtime.send('hi once more', {}) + expect(capturedOptions[2]!.sharedMemoryIndex).toBeUndefined() + }) +}) diff --git a/packages/core/src/adapters/__tests__/session-runtime.test.ts b/packages/core/src/adapters/__tests__/session-runtime.test.ts index 3fe6918..bb00935 100644 --- a/packages/core/src/adapters/__tests__/session-runtime.test.ts +++ b/packages/core/src/adapters/__tests__/session-runtime.test.ts @@ -49,7 +49,7 @@ describe('session-runtime adapters', () => { const raw = await runtime.send('hello'); const parsed = sessionSendResultParser.parse(raw); - expect(session.send).toHaveBeenCalledWith('hello', undefined); + expect(session.send).toHaveBeenCalledWith('hello', {}); expect(runtime.meta.turnCount).toBe(3); expect(parsed.toolCalls[0]).toEqual({ id: 't1', diff --git a/packages/core/src/adapters/session-runtime.ts b/packages/core/src/adapters/session-runtime.ts index e0b261a..2c3df3e 100644 --- a/packages/core/src/adapters/session-runtime.ts +++ b/packages/core/src/adapters/session-runtime.ts @@ -54,6 +54,8 @@ export interface SessionCompatibleForkOptions { export interface SessionCompatibleSendOptions { /** AbortSignal — abort 时底层 LLM 调用应被取消 */ signal?: AbortSignal; + /** Agent 级共享 memory 索引段(已由编排层渲染) */ + sharedMemoryIndex?: string; } /** 结构兼容 @stello-ai/session 的 Session */ @@ -86,6 +88,11 @@ export interface SessionRuntimeAdapterOptions { compressFn?: SessionCompatibleCompressFn; /** 自定义 send() 结果序列化方式,默认转成 JSON 字符串 */ serializeResult?: (result: SessionCompatibleSendResult) => string; + /** + * 每次 send/stream 前调用,返回当前 agent 的共享 memory 索引段。 + * 返回 undefined / 空字符串则不注入。adapter 把结果合并进 sendOptions.sharedMemoryIndex。 + */ + sharedMemoryIndexProvider?: () => Promise; } /** 默认的 Session send() 结果序列化 */ @@ -156,7 +163,12 @@ export async function adaptSessionToEngineRuntime( return turnCount; }, async send(input: string, sendOptions?: SessionCompatibleSendOptions): Promise { - const result = await session.send(input, sendOptions); + const sharedMemoryIndex = await options.sharedMemoryIndexProvider?.(); + const mergedOptions: SessionCompatibleSendOptions = { + ...sendOptions, + ...(sharedMemoryIndex ? { sharedMemoryIndex } : {}), + }; + const result = await session.send(input, mergedOptions); turnCount += 1; return (options.serializeResult ?? serializeSessionSendResult)(result); }, @@ -172,17 +184,25 @@ export async function adaptSessionToEngineRuntime( ...(session.stream ? { stream(input: string, sendOptions?: SessionCompatibleSendOptions) { - const source = session.stream!(input, sendOptions); + const indexPromise = options.sharedMemoryIndexProvider?.() ?? Promise.resolve(undefined); + const source = (async () => { + const sharedMemoryIndex = await indexPromise; + const mergedOptions: SessionCompatibleSendOptions = { + ...sendOptions, + ...(sharedMemoryIndex ? { sharedMemoryIndex } : {}), + }; + return session.stream!(input, mergedOptions); + })(); return { result: (async () => { - const result = await source.result; + const stream = await source; + const result = await stream.result; turnCount += 1; return (options.serializeResult ?? serializeSessionSendResult)(result); })(), async *[Symbol.asyncIterator]() { - for await (const chunk of source) { - yield chunk; - } + const stream = await source; + for await (const chunk of stream) yield chunk; }, }; }, diff --git a/packages/core/src/agent/__tests__/shared-memory-sdk.test.ts b/packages/core/src/agent/__tests__/shared-memory-sdk.test.ts new file mode 100644 index 0000000..a901419 --- /dev/null +++ b/packages/core/src/agent/__tests__/shared-memory-sdk.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest' +import { StelloAgent } from '../stello-agent' +import { InMemorySharedMemoryStore } from '../../shared-memory/in-memory-shared-memory-store' +import { SkillRouterImpl } from '../../skill/skill-router' +import { ToolRegistryImpl } from '../../tool/tool-registry' +import type { StelloAgentConfig } from '../stello-agent' +import type { SessionTree } from '../../types/session' +import type { EngineLifecycleAdapter } from '../../engine/stello-engine' +import type { ConfirmProtocol } from '../../types/lifecycle' + +function makeAgent(sharedMemory?: InMemorySharedMemoryStore): StelloAgent { + const config: StelloAgentConfig = { + sessions: { + createSession: async () => ({ id: 'r', label: 'r', parentId: null, status: 'active' }), + listRoots: async () => [], + getTree: async () => [], + getNode: async () => null, + listAll: async () => [], + get: async () => null, + archive: async () => undefined, + addRef: async () => undefined, + updateMeta: async () => undefined, + getAncestors: async () => [], + getSiblings: async () => [], + getConfig: async () => null, + putConfig: async () => undefined, + } as unknown as SessionTree, + capabilities: { + lifecycle: {} as EngineLifecycleAdapter, + tools: new ToolRegistryImpl(), + skills: new SkillRouterImpl(), + confirm: {} as ConfirmProtocol, + }, + runtime: { resolver: { resolve: async () => ({} as never) } }, + ...(sharedMemory ? { sharedMemory } : {}), + } + return new StelloAgent(config) +} + +describe('StelloAgent shared memory SDK', () => { + it('exposes agent.sharedMemory when configured', () => { + const store = new InMemorySharedMemoryStore() + const agent = makeAgent(store) + expect(agent.sharedMemory).toBe(store) + }) + + it('listSharedMemory returns [] when store is empty', async () => { + const agent = makeAgent(new InMemorySharedMemoryStore()) + expect(await agent.listSharedMemory()).toEqual([]) + }) + + it('upsertSharedMemoryEntry + listSharedMemory round-trip', async () => { + const agent = makeAgent(new InMemorySharedMemoryStore()) + await agent.upsertSharedMemoryEntry('a', 'sa', 'ba') + await agent.upsertSharedMemoryEntry('b', 'sb', 'bb') + expect(await agent.listSharedMemory()).toEqual([ + { slug: 'a', summary: 'sa', body: 'ba' }, + { slug: 'b', summary: 'sb', body: 'bb' }, + ]) + }) + + it('getSharedMemoryEntry returns null when missing, entry when present', async () => { + const agent = makeAgent(new InMemorySharedMemoryStore()) + expect(await agent.getSharedMemoryEntry('a')).toBeNull() + await agent.upsertSharedMemoryEntry('a', 'sa', 'ba') + expect(await agent.getSharedMemoryEntry('a')).toEqual({ slug: 'a', summary: 'sa', body: 'ba' }) + }) + + it('removeSharedMemoryEntry deletes the entry', async () => { + const agent = makeAgent(new InMemorySharedMemoryStore()) + await agent.upsertSharedMemoryEntry('a', 'sa', 'ba') + await agent.removeSharedMemoryEntry('a') + expect(await agent.getSharedMemoryEntry('a')).toBeNull() + }) + + it('throws "sharedMemory not configured" when store is absent', async () => { + const agent = makeAgent(undefined) + await expect(agent.listSharedMemory()).rejects.toThrow(/sharedMemory not configured/) + await expect(agent.getSharedMemoryEntry('a')).rejects.toThrow(/sharedMemory not configured/) + await expect(agent.upsertSharedMemoryEntry('a', 's', 'b')).rejects.toThrow(/sharedMemory not configured/) + await expect(agent.removeSharedMemoryEntry('a')).rejects.toThrow(/sharedMemory not configured/) + }) +}) diff --git a/packages/core/src/agent/__tests__/stello-agent.test.ts b/packages/core/src/agent/__tests__/stello-agent.test.ts index 32ffc86..223ece9 100644 --- a/packages/core/src/agent/__tests__/stello-agent.test.ts +++ b/packages/core/src/agent/__tests__/stello-agent.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { SessionStorage } from '@stello-ai/session'; import type { SessionTree } from '../../types/session'; -import type { MemoryEngine } from '../../types/memory'; import type { ConfirmProtocol, SkillRouter } from '../../types/lifecycle'; import { createStelloAgent, type StelloAgentConfig } from '../stello-agent'; @@ -40,7 +39,6 @@ describe('StelloAgent', () => { archive: vi.fn(), ...overrides?.sessions, } as unknown as SessionTree, - memory: {} as MemoryEngine, capabilities: { lifecycle: { bootstrap: vi.fn().mockResolvedValue({ @@ -214,7 +212,6 @@ describe('StelloAgent', () => { getConfig: vi.fn().mockResolvedValue(null), putConfig: vi.fn().mockResolvedValue(undefined), } as unknown as SessionTree, - memory: {} as MemoryEngine, capabilities: { lifecycle: { bootstrap: vi.fn(), @@ -397,7 +394,6 @@ describe('StelloAgent', () => { get: vi.fn().mockResolvedValue(rootSession), archive: vi.fn(), } as unknown as SessionTree, - memory: {} as MemoryEngine, session: { sessionLoader: vi.fn().mockResolvedValue({ session, config: null }), }, diff --git a/packages/core/src/agent/stello-agent.ts b/packages/core/src/agent/stello-agent.ts index adf519d..17da511 100644 --- a/packages/core/src/agent/stello-agent.ts +++ b/packages/core/src/agent/stello-agent.ts @@ -22,7 +22,6 @@ import { type SessionCompatibleSendResult, } from '../adapters/session-runtime'; import type { SessionMeta, SessionTree, SessionTreeNode, TopologyNode } from '../types/session'; -import type { MemoryEngine } from '../types/memory'; import type { ConfirmProtocol, SkillRouter } from '../types/lifecycle'; import type { EngineLifecycleAdapter, EngineToolRuntime } from '../engine/stello-engine'; import type { ForkProfileRegistry } from '../engine/fork-profile'; @@ -34,6 +33,8 @@ import type { import type { SessionStorage, ListRecordsOptions, Message, } from '@stello-ai/session'; +import type { SharedMemoryEntry, SharedMemoryStore } from '../shared-memory/types'; +import { renderSharedMemoryIndex } from '../shared-memory/render-index'; /** Session 能力相关配置 */ export interface StelloAgentCapabilitiesConfig { @@ -91,7 +92,6 @@ export interface StelloAgentOrchestrationConfig { */ export interface StelloAgentConfig { sessions: SessionTree; - memory: MemoryEngine; /** * Session 数据存储(L3 / system prompt / insight / memory)。 * @@ -99,6 +99,19 @@ export interface StelloAgentConfig { * 应用层应保证 sessions(拓扑)与 storage(内容)指向同一份持久化后端。 */ storage?: SessionStorage; + /** + * Agent 级共享 memory 存储。 + * + * 注入后:四个 SDK 方法可用;当 agent 走默认 `session.sessionLoader` 路径时, + * 索引段每次 send 前由内置 adapter 自动渲染并注入到上下文。 + * + * 未注入:四个 SDK 方法和三个内置 tool 抛 "sharedMemory not configured",索引段不进入上下文。 + * + * 注意:如果调用方提供自定义 `runtime.resolver` 而非 `session.sessionLoader`, + * 自动注入不会发生 —— 调用方需要自行把 `renderSharedMemoryIndex(agent.sharedMemory)` + * 接入到自己构造的 EngineRuntimeSession 的 send/stream 调用上。 + */ + sharedMemory?: SharedMemoryStore; /** Regular session 的 agent 级默认配置,fork 合成链的最低优先级 */ sessionDefaults?: SessionConfig; session?: StelloAgentSessionConfig; @@ -123,7 +136,7 @@ export interface SessionDigest { } -function resolveRuntimeResolver(config: StelloAgentConfig): SessionRuntimeResolver { +function resolveRuntimeResolver(config: StelloAgentConfig, agent: StelloAgent): SessionRuntimeResolver { if (config.runtime?.resolver) { return config.runtime.resolver; } @@ -133,6 +146,7 @@ function resolveRuntimeResolver(config: StelloAgentConfig): SessionRuntimeResolv // TODO(unified-session-config): 接入 fork 合成链后,compressFn 应来自合成配置而非 sessionDefaults compressFn: config.sessionDefaults?.compressFn, serializeResult: config.session!.serializeSendResult ?? serializeSessionSendResult, + sharedMemoryIndexProvider: () => renderSharedMemoryIndex(agent.sharedMemory), }; return { resolve: async (sessionId: string) => { @@ -177,12 +191,12 @@ export class StelloAgent { /** 暴露 SessionTree,方便调用方做拓扑查询 */ readonly sessions: StelloAgentConfig['sessions']; - /** 暴露 MemoryEngine,方便调用方做数据读写 */ - readonly memory: StelloAgentConfig['memory']; - /** 注入的数据存储;data-IO SDK 方法依赖该字段 */ readonly storage?: SessionStorage; + /** 暴露 SharedMemoryStore,供 builtin tool / adapter / SDK 使用 */ + readonly sharedMemory?: SharedMemoryStore; + /** 暴露 ForkProfileRegistry,供 tool 在运行时校验 profile 名称 */ get profiles(): ForkProfileRegistry | undefined { return this.config.capabilities.profiles; @@ -194,16 +208,15 @@ export class StelloAgent { constructor(config: StelloAgentConfig) { this.config = config; this.sessions = config.sessions; - this.memory = config.memory; this.storage = config.storage; + this.sharedMemory = config.sharedMemory; const engineFactory = new DefaultEngineFactory({ sessions: config.sessions, - memory: config.memory, lifecycle: config.capabilities.lifecycle, tools: config.capabilities.tools, skills: config.capabilities.skills, confirm: config.capabilities.confirm, - sessionRuntimeResolver: resolveRuntimeResolver(config), + sessionRuntimeResolver: resolveRuntimeResolver(config, this), profiles: config.capabilities.profiles, splitGuard: config.orchestration?.splitGuard, turnRunner: resolveTurnRunner(config), @@ -343,6 +356,36 @@ export class StelloAgent { return this.storage; } + // 校验 sharedMemory 已注入,否则抛出 "sharedMemory not configured" 错误 + private requireSharedMemory(method: string): SharedMemoryStore { + if (!this.sharedMemory) { + throw new Error( + `StelloAgent.${method} 需要 StelloAgentConfig.sharedMemory;请在创建 agent 时注入 SharedMemoryStore (sharedMemory not configured)`, + ); + } + return this.sharedMemory; + } + + /** 列举全部共享 memory entries(按插入顺序) */ + async listSharedMemory(): Promise { + return this.requireSharedMemory('listSharedMemory').list(); + } + + /** 读取一条共享 memory entry;不存在返回 null */ + async getSharedMemoryEntry(slug: string): Promise { + return this.requireSharedMemory('getSharedMemoryEntry').get(slug); + } + + /** 写入或覆盖一条共享 memory entry */ + async upsertSharedMemoryEntry(slug: string, summary: string, body: string): Promise { + return this.requireSharedMemory('upsertSharedMemoryEntry').upsert(slug, summary, body); + } + + /** 删除一条共享 memory entry;slug 不存在为 no-op */ + async removeSharedMemoryEntry(slug: string): Promise { + return this.requireSharedMemory('removeSharedMemoryEntry').remove(slug); + } + /** 进入指定 session 的整轮对话 */ enterSession(sessionId: string): Promise { return this.orchestrator.enterSession(sessionId); diff --git a/packages/core/src/builtin-tools/__tests__/memory-forget-tool.test.ts b/packages/core/src/builtin-tools/__tests__/memory-forget-tool.test.ts new file mode 100644 index 0000000..9d7232a --- /dev/null +++ b/packages/core/src/builtin-tools/__tests__/memory-forget-tool.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { memoryForgetTool } from '../memory-forget-tool' +import { InMemorySharedMemoryStore } from '../../shared-memory/in-memory-shared-memory-store' +import type { ToolExecutionContext } from '../../types/tool' +import type { StelloAgent } from '../../agent/stello-agent' + +function fakeAgent(store: InMemorySharedMemoryStore | undefined): StelloAgent { + return { sharedMemory: store } as unknown as StelloAgent +} + +function ctx(agent: StelloAgent): ToolExecutionContext { + return { agent, sessionId: 's1', toolName: 'stello_memory_forget' } +} + +describe('memoryForgetTool', () => { + it('returns ToolRegistryEntry named "stello_memory_forget"', () => { + expect(memoryForgetTool().name).toBe('stello_memory_forget') + }) + + it('removes existing entry', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'ba') + const r = await memoryForgetTool().execute({ slug: 'a' }, ctx(fakeAgent(store))) + expect(r).toEqual({ success: true, data: { slug: 'a' } }) + expect(await store.get('a')).toBeNull() + }) + + it('returns success even when slug does not exist (no-op)', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryForgetTool().execute({ slug: 'missing' }, ctx(fakeAgent(store))) + expect(r).toEqual({ success: true, data: { slug: 'missing' } }) + }) + + it('returns error for empty slug', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryForgetTool().execute({ slug: '' }, ctx(fakeAgent(store))) + expect(r.success).toBe(false) + expect(r.error).toMatch(/slug/i) + }) + + it('returns error when sharedMemory is not configured', async () => { + const r = await memoryForgetTool().execute({ slug: 'a' }, ctx(fakeAgent(undefined))) + expect(r.success).toBe(false) + expect(r.error).toMatch(/sharedMemory not configured/i) + }) +}) diff --git a/packages/core/src/builtin-tools/__tests__/memory-recall-tool.test.ts b/packages/core/src/builtin-tools/__tests__/memory-recall-tool.test.ts new file mode 100644 index 0000000..87c7cea --- /dev/null +++ b/packages/core/src/builtin-tools/__tests__/memory-recall-tool.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { memoryRecallTool } from '../memory-recall-tool' +import { InMemorySharedMemoryStore } from '../../shared-memory/in-memory-shared-memory-store' +import type { ToolExecutionContext } from '../../types/tool' +import type { StelloAgent } from '../../agent/stello-agent' + +function fakeAgent(store: InMemorySharedMemoryStore | undefined): StelloAgent { + return { sharedMemory: store } as unknown as StelloAgent +} + +function ctx(agent: StelloAgent): ToolExecutionContext { + return { agent, sessionId: 's1', toolName: 'stello_memory_recall' } +} + +describe('memoryRecallTool', () => { + it('returns ToolRegistryEntry named "stello_memory_recall"', () => { + expect(memoryRecallTool().name).toBe('stello_memory_recall') + }) + + it('returns body for known slug', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'BODY-A') + const r = await memoryRecallTool().execute({ slug: 'a' }, ctx(fakeAgent(store))) + expect(r).toEqual({ success: true, data: { body: 'BODY-A' } }) + }) + + it('returns error for unknown slug', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryRecallTool().execute({ slug: 'nope' }, ctx(fakeAgent(store))) + expect(r.success).toBe(false) + expect(r.error).toMatch(/slug.*nope/i) + }) + + it('returns error when slug is empty', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryRecallTool().execute({ slug: '' }, ctx(fakeAgent(store))) + expect(r.success).toBe(false) + expect(r.error).toMatch(/slug/i) + }) + + it('returns error when sharedMemory is not configured', async () => { + const r = await memoryRecallTool().execute({ slug: 'a' }, ctx(fakeAgent(undefined))) + expect(r.success).toBe(false) + expect(r.error).toMatch(/sharedMemory not configured/i) + }) +}) diff --git a/packages/core/src/builtin-tools/__tests__/memory-remember-tool.test.ts b/packages/core/src/builtin-tools/__tests__/memory-remember-tool.test.ts new file mode 100644 index 0000000..3b3b718 --- /dev/null +++ b/packages/core/src/builtin-tools/__tests__/memory-remember-tool.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest' +import { memoryRememberTool } from '../memory-remember-tool' +import { InMemorySharedMemoryStore } from '../../shared-memory/in-memory-shared-memory-store' +import type { ToolExecutionContext } from '../../types/tool' +import type { StelloAgent } from '../../agent/stello-agent' + +function fakeAgent(store: InMemorySharedMemoryStore | undefined): StelloAgent { + return { sharedMemory: store } as unknown as StelloAgent +} + +function ctx(agent: StelloAgent): ToolExecutionContext { + return { agent, sessionId: 's1', toolName: 'stello_memory_remember' } +} + +describe('memoryRememberTool', () => { + it('returns ToolRegistryEntry named "stello_memory_remember"', () => { + expect(memoryRememberTool().name).toBe('stello_memory_remember') + }) + + it('upserts a new entry', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryRememberTool().execute( + { slug: 'a', summary: 'sa', body: 'BODY' }, + ctx(fakeAgent(store)), + ) + expect(r).toEqual({ success: true, data: { slug: 'a' } }) + expect(await store.get('a')).toEqual({ slug: 'a', summary: 'sa', body: 'BODY' }) + }) + + it('overwrites existing entry', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'old') + await memoryRememberTool().execute( + { slug: 'a', summary: 'sa2', body: 'NEW' }, + ctx(fakeAgent(store)), + ) + expect(await store.get('a')).toEqual({ slug: 'a', summary: 'sa2', body: 'NEW' }) + }) + + it('returns error for empty slug', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryRememberTool().execute( + { slug: '', summary: 's', body: 'b' }, + ctx(fakeAgent(store)), + ) + expect(r.success).toBe(false) + expect(r.error).toMatch(/slug/i) + }) + + it('returns error for missing summary', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryRememberTool().execute( + { slug: 'a', body: 'b' }, + ctx(fakeAgent(store)), + ) + expect(r.success).toBe(false) + expect(r.error).toMatch(/summary/i) + }) + + it('returns error for missing body', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryRememberTool().execute( + { slug: 'a', summary: 's' }, + ctx(fakeAgent(store)), + ) + expect(r.success).toBe(false) + expect(r.error).toMatch(/body/i) + }) + + it('returns error when sharedMemory is not configured', async () => { + const r = await memoryRememberTool().execute( + { slug: 'a', summary: 's', body: 'b' }, + ctx(fakeAgent(undefined)), + ) + expect(r.success).toBe(false) + expect(r.error).toMatch(/sharedMemory not configured/i) + }) +}) diff --git a/packages/core/src/builtin-tools/index.ts b/packages/core/src/builtin-tools/index.ts index 19d7603..9bf83a2 100644 --- a/packages/core/src/builtin-tools/index.ts +++ b/packages/core/src/builtin-tools/index.ts @@ -1,2 +1,5 @@ export { createSessionTool } from './create-session-tool' export { activateSkillTool } from './activate-skill-tool' +export { memoryRecallTool } from './memory-recall-tool' +export { memoryRememberTool } from './memory-remember-tool' +export { memoryForgetTool } from './memory-forget-tool' diff --git a/packages/core/src/builtin-tools/memory-forget-tool.ts b/packages/core/src/builtin-tools/memory-forget-tool.ts new file mode 100644 index 0000000..b552684 --- /dev/null +++ b/packages/core/src/builtin-tools/memory-forget-tool.ts @@ -0,0 +1,36 @@ +import type { ToolRegistryEntry } from '../tool/tool-registry' + +const DESCRIPTION = `删除一条共享 memory entry。 + +参数: +- slug(必填): 要删除的 entry slug + +何时使用:原 entry 已过时 / 错误 / 不再相关时调用。slug 不存在不报错(no-op)。` + +const PARAMETERS = { + type: 'object', + properties: { + slug: { type: 'string', description: '要删除的 entry slug' }, + }, + required: ['slug'], +} + +export function memoryForgetTool(): ToolRegistryEntry { + return { + name: 'stello_memory_forget', + description: DESCRIPTION, + parameters: PARAMETERS, + execute: async (args, ctx) => { + const slug = (args.slug as string | undefined)?.trim() + if (!slug) return { success: false, error: 'slug is required and must be non-empty' } + const store = ctx.agent.sharedMemory + if (!store) return { success: false, error: 'sharedMemory not configured' } + try { + await store.remove(slug) + return { success: true, data: { slug } } + } catch (e) { + return { success: false, error: `failed: ${e instanceof Error ? e.message : String(e)}` } + } + }, + } +} diff --git a/packages/core/src/builtin-tools/memory-recall-tool.ts b/packages/core/src/builtin-tools/memory-recall-tool.ts new file mode 100644 index 0000000..983c35d --- /dev/null +++ b/packages/core/src/builtin-tools/memory-recall-tool.ts @@ -0,0 +1,37 @@ +import type { ToolRegistryEntry } from '../tool/tool-registry' + +const DESCRIPTION = `按 slug 读取一条共享 memory 的完整内容。 + +参数: +- slug(必填): 索引中列出的某条 entry 的 slug + +何时使用:上下文里 出现了你需要详读的 slug 时调用。` + +const PARAMETERS = { + type: 'object', + properties: { + slug: { type: 'string', description: '索引中的 entry slug' }, + }, + required: ['slug'], +} + +export function memoryRecallTool(): ToolRegistryEntry { + return { + name: 'stello_memory_recall', + description: DESCRIPTION, + parameters: PARAMETERS, + execute: async (args, ctx) => { + const slug = (args.slug as string | undefined)?.trim() + if (!slug) return { success: false, error: 'slug is required and must be non-empty' } + const store = ctx.agent.sharedMemory + if (!store) return { success: false, error: 'sharedMemory not configured' } + try { + const entry = await store.get(slug) + if (!entry) return { success: false, error: `slug "${slug}" not found` } + return { success: true, data: { body: entry.body } } + } catch (e) { + return { success: false, error: `failed: ${e instanceof Error ? e.message : String(e)}` } + } + }, + } +} diff --git a/packages/core/src/builtin-tools/memory-remember-tool.ts b/packages/core/src/builtin-tools/memory-remember-tool.ts new file mode 100644 index 0000000..9dabaa2 --- /dev/null +++ b/packages/core/src/builtin-tools/memory-remember-tool.ts @@ -0,0 +1,49 @@ +import type { ToolRegistryEntry } from '../tool/tool-registry' + +const DESCRIPTION = `写入或覆盖一条共享 memory entry。所有 Session 共享同一份 store。 + +参数: +- slug(必填): kebab-case 主键 +- summary(必填): 索引行展示的一句话 +- body(必填): 详情全文(recall 时返回) + +何时使用:当你判断某个事实 / 偏好 / 背景对整个 agent 都有用,且未来对话需要复用时调用。 +存在则覆盖,不存在则追加;不会改变 entry 的原有插入顺序。` + +const PARAMETERS = { + type: 'object', + properties: { + slug: { type: 'string', description: 'kebab-case 主键' }, + summary: { type: 'string', description: '索引行的一句话' }, + body: { type: 'string', description: '完整内容' }, + }, + required: ['slug', 'summary', 'body'], +} + +export function memoryRememberTool(): ToolRegistryEntry { + return { + name: 'stello_memory_remember', + description: DESCRIPTION, + parameters: PARAMETERS, + execute: async (args, ctx) => { + const slug = (args.slug as string | undefined)?.trim() + if (!slug) return { success: false, error: 'slug is required and must be non-empty' } + const summary = args.summary as string | undefined + if (summary === undefined || summary === null) { + return { success: false, error: 'summary is required' } + } + const body = args.body as string | undefined + if (body === undefined || body === null) { + return { success: false, error: 'body is required' } + } + const store = ctx.agent.sharedMemory + if (!store) return { success: false, error: 'sharedMemory not configured' } + try { + await store.upsert(slug, summary, body) + return { success: true, data: { slug } } + } catch (e) { + return { success: false, error: `failed: ${e instanceof Error ? e.message : String(e)}` } + } + }, + } +} diff --git a/packages/core/src/engine/__tests__/fork-compress.test.ts b/packages/core/src/engine/__tests__/fork-compress.test.ts index 89cf6aa..e759cde 100644 --- a/packages/core/src/engine/__tests__/fork-compress.test.ts +++ b/packages/core/src/engine/__tests__/fork-compress.test.ts @@ -4,7 +4,6 @@ import type { LLMCallFn } from '../../llm/defaults' import { StelloEngineImpl } from '../stello-engine' import { ForkProfileRegistryImpl, type ForkProfile } from '../fork-profile' import type { SessionTree } from '../../types/session' -import type { MemoryEngine } from '../../types/memory' import type { ConfirmProtocol, SkillRouter } from '../../types/lifecycle' import type { SessionConfig } from '../../types/session-config' @@ -152,7 +151,6 @@ describe('forkSession compress integration', () => { label: 'x', }), } - const memory = {} as MemoryEngine const skills = { get: vi.fn().mockReturnValue(undefined), register: vi.fn(), @@ -171,7 +169,6 @@ describe('forkSession compress integration', () => { const engine = new StelloEngineImpl({ session: fakeSession, sessions: fakeSessions as unknown as SessionTree, - memory, skills, confirm, agent: {} as never, diff --git a/packages/core/src/engine/__tests__/stello-engine.test.ts b/packages/core/src/engine/__tests__/stello-engine.test.ts index de5e291..d34b5f0 100644 --- a/packages/core/src/engine/__tests__/stello-engine.test.ts +++ b/packages/core/src/engine/__tests__/stello-engine.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; import type { SessionTree } from '../../types/session'; -import type { MemoryEngine } from '../../types/memory'; import type { ConfirmProtocol, SkillRouter } from '../../types/lifecycle'; import { StelloEngineImpl } from '../stello-engine'; import { TurnRunner, type ToolCallParser } from '../turn-runner'; @@ -24,7 +23,6 @@ describe('StelloEngineImpl', () => { putConfig: vi.fn().mockResolvedValue(undefined), } as unknown as SessionTree; - const memory = {} as MemoryEngine; const skills = { get: vi.fn().mockReturnValue(undefined), register: vi.fn(), @@ -61,7 +59,6 @@ describe('StelloEngineImpl', () => { const engine = new StelloEngineImpl({ session, sessions, - memory, skills, confirm, agent: {} as never, @@ -130,7 +127,6 @@ describe('StelloEngineImpl', () => { const engine = new StelloEngineImpl({ session, sessions, - memory, skills, confirm, agent: {} as never, @@ -188,7 +184,6 @@ describe('StelloEngineImpl', () => { setTools: vi.fn(), }, sessions, - memory, skills, confirm, agent: {} as never, @@ -241,7 +236,6 @@ describe('StelloEngineImpl', () => { setTools: vi.fn(), }, sessions, - memory, skills, confirm, agent: {} as never, @@ -275,7 +269,6 @@ describe('StelloEngineImpl', () => { const engine = new StelloEngineImpl({ session, sessions, - memory, skills, confirm, agent: {} as never, @@ -313,7 +306,6 @@ describe('StelloEngineImpl', () => { const engine = new StelloEngineImpl({ session, sessions: { archive, getNode: vi.fn(), getTree: vi.fn() } as unknown as SessionTree, - memory, skills, confirm, agent: {} as never, @@ -362,7 +354,6 @@ describe('StelloEngineImpl', () => { fork: sessionFork, }, sessions: { ...sessions, createSession } as unknown as SessionTree, - memory, skills, confirm, agent: {} as never, @@ -411,7 +402,7 @@ describe('StelloEngineImpl', () => { fork: sessionFork, }, sessions: { ...sessions, createSession } as unknown as SessionTree, - memory, skills, confirm, agent: {} as never, + skills, confirm, agent: {} as never, lifecycle: { bootstrap: vi.fn(), afterTurn: vi.fn() }, tools: { getToolDefinitions: vi.fn().mockReturnValue([]), executeTool: vi.fn() }, splitGuard: splitGuard as never, @@ -441,7 +432,7 @@ describe('StelloEngineImpl', () => { fork: sessionFork, }, sessions: { ...sessions, createSession } as unknown as SessionTree, - memory, skills, confirm, agent: {} as never, + skills, confirm, agent: {} as never, lifecycle: { bootstrap: vi.fn(), afterTurn: vi.fn() }, tools: { getToolDefinitions: vi.fn().mockReturnValue([]), executeTool: vi.fn() }, }); @@ -467,7 +458,7 @@ describe('StelloEngineImpl', () => { messages: vi.fn().mockResolvedValue([]), setTools: vi.fn(), }, - sessions, memory, skills, confirm, agent: {} as never, + sessions, skills, confirm, agent: {} as never, lifecycle: { bootstrap: vi.fn(), afterTurn: vi.fn() }, tools: { getToolDefinitions: vi.fn().mockReturnValue([]), executeTool: vi.fn() }, }); @@ -510,7 +501,6 @@ describe('StelloEngineImpl', () => { fork: sessionFork, }, sessions: { ...sessions, createSession: createSessionFn, getConfig, putConfig } as unknown as SessionTree, - memory, skills, confirm, agent: {} as never, @@ -562,7 +552,6 @@ describe('StelloEngineImpl', () => { new StelloEngineImpl({ session, sessions, - memory, skills, confirm, agent: {} as never, @@ -615,7 +604,6 @@ describe('StelloEngineImpl', () => { fork: sessionFork, }, sessions: { ...sessions, createSession } as unknown as SessionTree, - memory, skills, confirm, agent: {} as never, diff --git a/packages/core/src/engine/stello-engine.ts b/packages/core/src/engine/stello-engine.ts index 5af9ab3..d9dfa04 100644 --- a/packages/core/src/engine/stello-engine.ts +++ b/packages/core/src/engine/stello-engine.ts @@ -1,5 +1,5 @@ import type { SessionTree } from '../types/session'; -import type { MemoryEngine, TurnRecord } from '../types/memory'; +import type { TurnRecord } from '../types/memory'; import type { LLMCompleteOptions } from '@stello-ai/session'; import type { BootstrapResult, @@ -94,7 +94,6 @@ export interface EngineToolRuntime { export interface StelloEngineOptions { session: EngineRuntimeSession; sessions: SessionTree; - memory: MemoryEngine; skills: SkillRouter; confirm: ConfirmProtocol; lifecycle: EngineLifecycleAdapter; @@ -162,7 +161,6 @@ export interface EngineHooks { */ export class StelloEngineImpl implements StelloEngine { readonly sessions: SessionTree; - readonly memory: MemoryEngine; readonly skills: SkillRouter; readonly confirm: ConfirmProtocol; @@ -180,7 +178,6 @@ export class StelloEngineImpl implements StelloEngine { constructor(options: StelloEngineOptions) { this.session = options.session; this.sessions = options.sessions; - this.memory = options.memory; this.skills = options.skills; this.confirm = options.confirm; this.lifecycle = options.lifecycle; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 51f2f0d..61dba47 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,13 +10,6 @@ export type { SessionTreeNode, CreateSessionOptions, SessionTree, - // 记忆系统 - InheritancePolicy, - CoreSchemaField, - CoreSchema, - TurnRecord, - AssembledContext, - MemoryEngine, // 文件系统适配器 FileSystemAdapter, // 生命周期钩子 @@ -46,11 +39,13 @@ export type { } from './types'; export type { ToolExecutionContext } from './types/tool'; +// Re-export internal types that appear in public interfaces (BootstrapResult, +// StelloEngine) so consumers can type their lifecycle implementations. +export type { TurnRecord, AssembledContext } from './types/memory'; // 导出实现 export { NodeFileSystemAdapter } from './fs'; export { SessionTreeImpl } from './session'; -export { FileSystemMemoryEngine } from './memory/file-system-memory-engine'; export { SplitGuard } from './session/split-guard'; export type { SplitCheckResult } from './session/split-guard'; export { SkillRouterImpl } from './skill/skill-router'; @@ -125,8 +120,19 @@ export type { SessionDigest, } from './agent/stello-agent'; +// 共享 memory +export { InMemorySharedMemoryStore } from './shared-memory/in-memory-shared-memory-store'; +export { renderSharedMemoryIndex } from './shared-memory/render-index'; +export type { SharedMemoryEntry, SharedMemoryStore } from './shared-memory/types'; + // 内置 tool 工厂(builtin-tools redesign) -export { createSessionTool, activateSkillTool } from './builtin-tools'; +export { + createSessionTool, + activateSkillTool, + memoryRecallTool, + memoryRememberTool, + memoryForgetTool, +} from './builtin-tools'; // 导出 LLM 默认实现 export { diff --git a/packages/core/src/memory/__tests__/file-system-memory-engine.test.ts b/packages/core/src/memory/__tests__/file-system-memory-engine.test.ts deleted file mode 100644 index 63cc721..0000000 --- a/packages/core/src/memory/__tests__/file-system-memory-engine.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { NodeFileSystemAdapter } from '../../fs/file-system-adapter'; -import { SessionTreeImpl } from '../../session/session-tree'; -import { FileSystemMemoryEngine } from '../file-system-memory-engine'; -import type { TurnRecord } from '../../types/memory'; - -/** 创建临时目录并初始化 engine */ -async function makeEngine() { - const dir = await mkdtemp(join(tmpdir(), 'stello-mem-')); - const fs = new NodeFileSystemAdapter(dir); - const sessions = new SessionTreeImpl(fs); - const engine = new FileSystemMemoryEngine(fs, sessions); - return { dir, fs, sessions, engine }; -} - -/** 构造一条测试用 TurnRecord */ -function makeRecord(role: TurnRecord['role'], content: string): TurnRecord { - return { role, content, timestamp: new Date().toISOString() }; -} - -describe('FileSystemMemoryEngine', () => { - let dir: string; - let engine: FileSystemMemoryEngine; - let sessions: SessionTreeImpl; - let adapter: NodeFileSystemAdapter; - - beforeEach(async () => { - const ctx = await makeEngine(); - dir = ctx.dir; - engine = ctx.engine; - sessions = ctx.sessions; - adapter = ctx.fs; - }); - - afterEach(async () => { - await rm(dir, { recursive: true, force: true }); - }); - - // ─── L1 core ─────────────────────────────────────────────────────────────── - - describe('L1 core (core.json)', () => { - it('readCore() returns null when file does not exist', async () => { - const result = await engine.readCore(); - expect(result).toBeNull(); - }); - - it('readCore() returns full object after write', async () => { - await engine.writeCore('name', 'Alice'); - await engine.writeCore('age', 30); - const result = await engine.readCore(); - expect(result).toEqual({ name: 'Alice', age: 30 }); - }); - - it('writeCore / readCore round-trip for simple key', async () => { - await engine.writeCore('key', 'value'); - const result = await engine.readCore('key'); - expect(result).toBe('value'); - }); - - it('readCore(path) returns null for missing key', async () => { - await engine.writeCore('a', 1); - const result = await engine.readCore('nonexistent'); - expect(result).toBeNull(); - }); - - it('writeCore supports nested dot-path (a.b)', async () => { - await engine.writeCore('a.b', 42); - const result = await engine.readCore('a.b'); - expect(result).toBe(42); - }); - - it('writeCore nested dot-path preserves sibling keys', async () => { - await engine.writeCore('profile.name', 'Alice'); - await engine.writeCore('profile.age', 25); - const full = await engine.readCore() as Record; - expect((full['profile'] as Record)['name']).toBe('Alice'); - expect((full['profile'] as Record)['age']).toBe(25); - }); - - it('writeCore overwrites existing value', async () => { - await engine.writeCore('x', 1); - await engine.writeCore('x', 2); - expect(await engine.readCore('x')).toBe(2); - }); - }); - - // ─── L2 per-session ──────────────────────────────────────────────────────── - - describe('L2 per-session markdown files', () => { - it('readMemory returns null for non-existent session', async () => { - const result = await engine.readMemory('no-such-id'); - expect(result).toBeNull(); - }); - - it('writeMemory / readMemory round-trip', async () => { - const node = await sessions.createSession({ label: 'Test Root' }); - await engine.writeMemory(node.id, '# Memory\nSome content'); - const result = await engine.readMemory(node.id); - expect(result).toBe('# Memory\nSome content'); - }); - - it('readScope returns null for non-existent session', async () => { - expect(await engine.readScope('no-such-id')).toBeNull(); - }); - - it('writeScope / readScope round-trip', async () => { - const node = await sessions.createSession({ label: 'Root' }); - await engine.writeScope(node.id, '# Scope'); - expect(await engine.readScope(node.id)).toBe('# Scope'); - }); - - it('readIndex returns null for non-existent session', async () => { - expect(await engine.readIndex('no-such-id')).toBeNull(); - }); - - it('writeIndex / readIndex round-trip', async () => { - const node = await sessions.createSession({ label: 'Root' }); - await engine.writeIndex(node.id, '# Index'); - expect(await engine.readIndex(node.id)).toBe('# Index'); - }); - - it('writeMemory creates directory if needed', async () => { - // Use a fresh ID that has no directory yet - const id = 'new-session-id'; - await engine.writeMemory(id, 'content'); - expect(await engine.readMemory(id)).toBe('content'); - }); - }); - - // ─── L3 JSONL records ────────────────────────────────────────────────────── - - describe('L3 JSONL records', () => { - it('readRecords returns empty array for non-existent session', async () => { - const result = await engine.readRecords('no-such-id'); - expect(result).toEqual([]); - }); - - it('appendRecord / readRecords round-trip', async () => { - const node = await sessions.createSession({ label: 'Root' }); - const record = makeRecord('user', 'Hello'); - await engine.appendRecord(node.id, record); - const records = await engine.readRecords(node.id); - expect(records).toHaveLength(1); - expect(records[0]).toMatchObject({ role: 'user', content: 'Hello' }); - }); - - it('appendRecord preserves insertion order', async () => { - const node = await sessions.createSession({ label: 'Root' }); - await engine.appendRecord(node.id, makeRecord('user', 'first')); - await engine.appendRecord(node.id, makeRecord('assistant', 'second')); - await engine.appendRecord(node.id, makeRecord('user', 'third')); - const records = await engine.readRecords(node.id); - expect(records).toHaveLength(3); - expect(records[0]!.content).toBe('first'); - expect(records[1]!.content).toBe('second'); - expect(records[2]!.content).toBe('third'); - }); - - it('replaceRecords overwrites all records', async () => { - const node = await sessions.createSession({ label: 'Root' }); - await engine.appendRecord(node.id, makeRecord('user', 'old')); - const newRecords: TurnRecord[] = [ - makeRecord('user', 'new1'), - makeRecord('assistant', 'new2'), - ]; - await engine.replaceRecords(node.id, newRecords); - const records = await engine.readRecords(node.id); - expect(records).toHaveLength(2); - expect(records[0]!.content).toBe('new1'); - expect(records[1]!.content).toBe('new2'); - }); - - it('replaceRecords with empty array clears records', async () => { - const node = await sessions.createSession({ label: 'Root' }); - await engine.appendRecord(node.id, makeRecord('user', 'data')); - await engine.replaceRecords(node.id, []); - const records = await engine.readRecords(node.id); - expect(records).toEqual([]); - }); - - it('appendRecord preserves metadata field', async () => { - const node = await sessions.createSession({ label: 'Root' }); - const record: TurnRecord = { - role: 'tool', - content: 'result', - timestamp: new Date().toISOString(), - metadata: { toolId: 'search', exitCode: 0 }, - }; - await engine.appendRecord(node.id, record); - const records = await engine.readRecords(node.id); - expect(records[0]!.metadata).toEqual({ toolId: 'search', exitCode: 0 }); - }); - - it('readRecords skips corrupt lines', async () => { - const node = await (sessions as SessionTreeImpl).createSession({ label: 'test' }) - const good: TurnRecord = { role: 'user', content: 'hi', timestamp: '2026-01-01T00:00:00Z' } - await engine.appendRecord(node.id, good) - // Manually inject a corrupt line - await adapter.appendLine(`sessions/${node.id}/records.jsonl`, 'not-valid-json{') - const records = await engine.readRecords(node.id) - expect(records).toHaveLength(1) - expect(records[0]!.content).toBe('hi') - }); - }); - - // ─── assembleContext ──────────────────────────────────────────────────────── - - describe('assembleContext', () => { - it('returns empty core and no memories for root with no data', async () => { - const root = await sessions.createSession({ label: 'Root' }); - // Need core.json to exist (createSession does this for roots) - const ctx = await engine.assembleContext(root.id); - expect(ctx.core).toEqual({}); - expect(ctx.memories).toEqual([]); - expect(ctx.currentMemory).toBeNull(); - expect(ctx.scope).toBeNull(); - }); - - it('includes currentMemory and scope for session', async () => { - const root = await sessions.createSession({ label: 'Root' }); - await engine.writeMemory(root.id, '# Root Memory'); - await engine.writeScope(root.id, '# Root Scope'); - const ctx = await engine.assembleContext(root.id); - expect(ctx.currentMemory).toBe('# Root Memory'); - expect(ctx.scope).toBe('# Root Scope'); - }); - - it('collects ancestor memories from parent to root', async () => { - const root = await sessions.createSession({ label: 'Root' }); - await engine.writeMemory(root.id, '# Root Memory'); - const child = await sessions.createSession({ parentId: root.id, label: 'Child' }); - await engine.writeMemory(child.id, '# Child Memory'); - const grandchild = await sessions.createSession({ parentId: child.id, label: 'Grandchild' }); - - const ctx = await engine.assembleContext(grandchild.id); - // ancestors from parent to root: [child, root] - expect(ctx.memories).toHaveLength(2); - expect(ctx.memories[0]).toBe('# Child Memory'); - expect(ctx.memories[1]).toBe('# Root Memory'); - expect(ctx.currentMemory).toBeNull(); - }); - - it('includes L1 core data in context', async () => { - const root = await sessions.createSession({ label: 'Root' }); - await engine.writeCore('user', 'Bob'); - const ctx = await engine.assembleContext(root.id); - expect(ctx.core).toEqual({ user: 'Bob' }); - }); - - it('skips ancestors with no memory', async () => { - const root = await sessions.createSession({ label: 'Root' }); - // no memory written to root - const child = await sessions.createSession({ parentId: root.id, label: 'Child' }); - const ctx = await engine.assembleContext(child.id); - expect(ctx.memories).toEqual([]); - }); - }); -}); diff --git a/packages/core/src/memory/file-system-memory-engine.ts b/packages/core/src/memory/file-system-memory-engine.ts deleted file mode 100644 index 48a2252..0000000 --- a/packages/core/src/memory/file-system-memory-engine.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { FileSystemAdapter } from '../types/fs'; -import type { SessionTree } from '../types/session'; -import type { MemoryEngine, TurnRecord, AssembledContext } from '../types/memory'; - -/** - * FileSystemMemoryEngine — 基于文件系统的 MemoryEngine 实现 - * - * 数据布局: - * basePath/core.json — L1 全局核心档案 - * basePath/sessions/{id}/memory.md — L2 记忆摘要 - * basePath/sessions/{id}/scope.md — L2 对话边界 - * basePath/sessions/{id}/index.md — L2 子节点目录 - * basePath/sessions/{id}/records.jsonl — L3 原始对话记录 - */ -export class FileSystemMemoryEngine implements MemoryEngine { - constructor( - private readonly fs: FileSystemAdapter, - private readonly sessions: SessionTree, - ) {} - - /** 生成 session 文件路径(相对于 basePath) */ - private sessionPath(id: string, file: string): string { - return `sessions/${id}/${file}`; - } - - /** 确保 session 目录存在 */ - private async ensureSessionDir(id: string): Promise { - await this.fs.mkdir(`sessions/${id}`); - } - - /** 读取 L1 核心档案,支持点路径导航;路径不存在时返回 null */ - async readCore(path?: string): Promise { - const raw = await this.fs.readJSON>('core.json'); - if (raw === null) return null; - if (!path) return raw; - // 按点路径逐层访问,找不到时返回 null - const result = path.split('.').reduce((obj, key) => { - if (obj !== null && typeof obj === 'object') { - return (obj as Record)[key]; - } - return undefined; - }, raw); - return result === undefined ? null : result; - } - - /** 写入 L1 核心档案的某个字段,支持点路径嵌套写入 */ - async writeCore(path: string, value: unknown): Promise { - const raw = (await this.fs.readJSON>('core.json')) ?? {}; - const keys = path.split('.'); - let current: Record = raw; - for (let i = 0; i < keys.length - 1; i++) { - const key = keys[i]!; - if (typeof current[key] !== 'object' || current[key] === null) { - current[key] = {}; - } - current = current[key] as Record; - } - current[keys[keys.length - 1]!] = value; - await this.fs.writeJSON('core.json', raw); - } - - /** 读取某 Session 的 memory.md,空文件视为 null */ - async readMemory(sessionId: string): Promise { - const content = await this.fs.readFile(this.sessionPath(sessionId, 'memory.md')); - return content === null || content === '' ? null : content; - } - - /** 写入某 Session 的 memory.md */ - async writeMemory(sessionId: string, content: string): Promise { - await this.ensureSessionDir(sessionId); - await this.fs.writeFile(this.sessionPath(sessionId, 'memory.md'), content); - } - - /** 读取某 Session 的 scope.md,空文件视为 null */ - async readScope(sessionId: string): Promise { - const content = await this.fs.readFile(this.sessionPath(sessionId, 'scope.md')); - return content === null || content === '' ? null : content; - } - - /** 写入某 Session 的 scope.md */ - async writeScope(sessionId: string, content: string): Promise { - await this.ensureSessionDir(sessionId); - await this.fs.writeFile(this.sessionPath(sessionId, 'scope.md'), content); - } - - /** 读取某 Session 的 index.md,空文件视为 null */ - async readIndex(sessionId: string): Promise { - const content = await this.fs.readFile(this.sessionPath(sessionId, 'index.md')); - return content === null || content === '' ? null : content; - } - - /** 写入某 Session 的 index.md */ - async writeIndex(sessionId: string, content: string): Promise { - await this.ensureSessionDir(sessionId); - await this.fs.writeFile(this.sessionPath(sessionId, 'index.md'), content); - } - - /** 追加一条 L3 对话记录到 records.jsonl */ - async appendRecord(sessionId: string, record: TurnRecord): Promise { - await this.ensureSessionDir(sessionId); - await this.fs.appendLine(this.sessionPath(sessionId, 'records.jsonl'), JSON.stringify(record)); - } - - /** 覆盖某 Session 的全部 L3 对话记录 */ - async replaceRecords(sessionId: string, records: TurnRecord[]): Promise { - await this.ensureSessionDir(sessionId); - const content = records.map((r) => JSON.stringify(r)).join('\n'); - await this.fs.writeFile(this.sessionPath(sessionId, 'records.jsonl'), content ? content + '\n' : ''); - } - - /** 读取某 Session 的所有 L3 对话记录,跳过损坏的行 */ - async readRecords(sessionId: string): Promise { - const lines = await this.fs.readLines(this.sessionPath(sessionId, 'records.jsonl')); - const records: TurnRecord[] = []; - for (const line of lines) { - if (line.trim().length === 0) continue; - try { - records.push(JSON.parse(line) as TurnRecord); - } catch { - console.warn(`[FileSystemMemoryEngine] Skipping corrupt JSONL line in session ${sessionId}: ${line}`); - } - } - return records; - } - - /** 按祖先链组装上下文(从父到根收集 memory) */ - async assembleContext(sessionId: string): Promise { - // 读取 L1 核心档案,复用 readCore() 避免重复路径 - const core: Record = (await this.readCore() as Record) ?? {}; - - // 获取祖先节点(从直接父到根),收集各自的 memory - const ancestors = await this.sessions.getAncestors(sessionId); - const memories: string[] = []; - for (const ancestor of ancestors) { - const mem = await this.readMemory(ancestor.id); - if (mem) memories.push(mem); - } - - // 当前 Session 的 memory 和 scope - const currentMemory = await this.readMemory(sessionId); - const scope = await this.readScope(sessionId); - - return { core, memories, currentMemory, scope }; - } -} diff --git a/packages/core/src/orchestrator/__tests__/default-engine-factory.test.ts b/packages/core/src/orchestrator/__tests__/default-engine-factory.test.ts index 60308a7..f5d9968 100644 --- a/packages/core/src/orchestrator/__tests__/default-engine-factory.test.ts +++ b/packages/core/src/orchestrator/__tests__/default-engine-factory.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; import type { SessionTree } from '../../types/session'; -import type { MemoryEngine } from '../../types/memory'; import type { ConfirmProtocol, SkillRouter } from '../../types/lifecycle'; import { DefaultEngineFactory } from '../default-engine-factory'; @@ -12,7 +11,6 @@ describe('DefaultEngineFactory', () => { getTree: vi.fn(), updateMeta: vi.fn().mockResolvedValue(undefined), } as unknown as SessionTree, - memory: {} as MemoryEngine, skills: { get: vi.fn().mockReturnValue(undefined), register: vi.fn(), diff --git a/packages/core/src/orchestrator/default-engine-factory.ts b/packages/core/src/orchestrator/default-engine-factory.ts index 729d5cd..9f6985d 100644 --- a/packages/core/src/orchestrator/default-engine-factory.ts +++ b/packages/core/src/orchestrator/default-engine-factory.ts @@ -1,5 +1,4 @@ import type { SessionTree } from '../types/session'; -import type { MemoryEngine } from '../types/memory'; import type { ConfirmProtocol, SkillRouter } from '../types/lifecycle'; import { FilteredSkillRouter } from '../skill/filtered-skill-router'; import { @@ -25,7 +24,6 @@ export type EngineHookProvider = /** 默认 EngineFactory 的构造参数 */ export interface DefaultEngineFactoryOptions { sessions: SessionTree; - memory: MemoryEngine; skills: SkillRouter; confirm: ConfirmProtocol; lifecycle: EngineLifecycleAdapter; @@ -61,7 +59,6 @@ export class DefaultEngineFactory implements EngineFactory { return new StelloEngineImpl({ session, sessions: this.options.sessions, - memory: this.options.memory, skills, confirm: this.options.confirm, lifecycle: this.options.lifecycle, diff --git a/packages/core/src/shared-memory/__tests__/in-memory-shared-memory-store.test.ts b/packages/core/src/shared-memory/__tests__/in-memory-shared-memory-store.test.ts new file mode 100644 index 0000000..be51cb7 --- /dev/null +++ b/packages/core/src/shared-memory/__tests__/in-memory-shared-memory-store.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest' +import { InMemorySharedMemoryStore } from '../in-memory-shared-memory-store' + +describe('InMemorySharedMemoryStore', () => { + it('list returns [] when empty', async () => { + const store = new InMemorySharedMemoryStore() + expect(await store.list()).toEqual([]) + }) + + it('upsert adds new entry and list returns it', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sum-a', 'body-a') + expect(await store.list()).toEqual([{ slug: 'a', summary: 'sum-a', body: 'body-a' }]) + }) + + it('get returns the entry by slug, null if missing', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sum-a', 'body-a') + expect(await store.get('a')).toEqual({ slug: 'a', summary: 'sum-a', body: 'body-a' }) + expect(await store.get('missing')).toBeNull() + }) + + it('upsert preserves insertion order across multiple slugs', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'ba') + await store.upsert('b', 'sb', 'bb') + await store.upsert('c', 'sc', 'bc') + expect((await store.list()).map(e => e.slug)).toEqual(['a', 'b', 'c']) + }) + + it('upsert on existing slug overwrites summary + body but keeps position', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'ba') + await store.upsert('b', 'sb', 'bb') + await store.upsert('a', 'sa2', 'ba2') + expect(await store.list()).toEqual([ + { slug: 'a', summary: 'sa2', body: 'ba2' }, + { slug: 'b', summary: 'sb', body: 'bb' }, + ]) + }) + + it('remove deletes the entry; subsequent list omits it', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'ba') + await store.upsert('b', 'sb', 'bb') + await store.remove('a') + expect(await store.list()).toEqual([{ slug: 'b', summary: 'sb', body: 'bb' }]) + }) + + it('remove on missing slug is a no-op', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'ba') + await store.remove('missing') + expect(await store.list()).toEqual([{ slug: 'a', summary: 'sa', body: 'ba' }]) + }) + + it('serializes concurrent upserts to same slug (last value wins, no lost write)', async () => { + const store = new InMemorySharedMemoryStore() + await Promise.all([ + store.upsert('a', 's1', 'b1'), + store.upsert('a', 's2', 'b2'), + store.upsert('a', 's3', 'b3'), + ]) + const entries = await store.list() + expect(entries.length).toBe(1) + expect(entries[0]!.slug).toBe('a') + expect(['s1', 's2', 's3']).toContain(entries[0]!.summary) + expect(['b1', 'b2', 'b3']).toContain(entries[0]!.body) + }) + + it('serializes mixed concurrent upsert/remove without corruption', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'ba') + await Promise.all([ + store.upsert('b', 'sb', 'bb'), + store.remove('a'), + store.upsert('c', 'sc', 'bc'), + ]) + const entries = await store.list() + expect(entries.find(e => e.slug === 'a')).toBeUndefined() + expect(entries.find(e => e.slug === 'b')).toEqual({ slug: 'b', summary: 'sb', body: 'bb' }) + expect(entries.find(e => e.slug === 'c')).toEqual({ slug: 'c', summary: 'sc', body: 'bc' }) + }) +}) diff --git a/packages/core/src/shared-memory/__tests__/render-index.test.ts b/packages/core/src/shared-memory/__tests__/render-index.test.ts new file mode 100644 index 0000000..bf277ee --- /dev/null +++ b/packages/core/src/shared-memory/__tests__/render-index.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { renderSharedMemoryIndex } from '../render-index' +import { InMemorySharedMemoryStore } from '../in-memory-shared-memory-store' + +describe('renderSharedMemoryIndex', () => { + it('returns undefined when store is undefined', async () => { + expect(await renderSharedMemoryIndex(undefined)).toBeUndefined() + }) + + it('returns undefined when store has no entries', async () => { + const store = new InMemorySharedMemoryStore() + expect(await renderSharedMemoryIndex(store)).toBeUndefined() + }) + + it('renders entries inside with hint footer', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('prefer-concise', '用户偏好简短回答', 'body-1') + await store.upsert('user-profile', '大三本科生 CS 专业', 'body-2') + const out = await renderSharedMemoryIndex(store) + expect(out).toContain('') + expect(out).toContain('- prefer-concise: 用户偏好简短回答') + expect(out).toContain('- user-profile: 大三本科生 CS 专业') + expect(out).toContain('') + expect(out).toMatch(/stello_memory_recall/) + expect(out).toMatch(/stello_memory_remember/) + expect(out).toMatch(/stello_memory_forget/) + }) + + it('preserves entry order in output', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'sa', 'ba') + await store.upsert('b', 'sb', 'bb') + await store.upsert('c', 'sc', 'bc') + const out = await renderSharedMemoryIndex(store) + const aIdx = out!.indexOf('- a:') + const bIdx = out!.indexOf('- b:') + const cIdx = out!.indexOf('- c:') + expect(aIdx).toBeGreaterThan(-1) + expect(aIdx).toBeLessThan(bIdx) + expect(bIdx).toBeLessThan(cIdx) + }) +}) diff --git a/packages/core/src/shared-memory/in-memory-shared-memory-store.ts b/packages/core/src/shared-memory/in-memory-shared-memory-store.ts new file mode 100644 index 0000000..0a7e83c --- /dev/null +++ b/packages/core/src/shared-memory/in-memory-shared-memory-store.ts @@ -0,0 +1,44 @@ +import type { SharedMemoryEntry, SharedMemoryStore } from './types' + +/** + * 内置 SharedMemoryStore — 基于 JS Map(天然保留插入顺序)。 + * + * 所有写操作通过 writeLock 串行化(沿用 SessionTreeImpl 的范式), + * 避免并发 upsert / remove 时读到中间状态。读操作不加锁,允许脏读。 + */ +export class InMemorySharedMemoryStore implements SharedMemoryStore { + private readonly entries = new Map() + private writeLock: Promise = Promise.resolve() + + /** 把 fn 排入写队列,串行执行 */ + private withWriteLock(fn: () => Promise): Promise { + const next = this.writeLock.then(fn, fn) + this.writeLock = next.catch(() => undefined) + return next + } + + /** 列举全部 entries,按 Map 插入顺序 */ + async list(): Promise { + return [...this.entries].map(([slug, { summary, body }]) => ({ slug, summary, body })) + } + + /** 读取单条 entry */ + async get(slug: string): Promise { + const v = this.entries.get(slug) + return v ? { slug, summary: v.summary, body: v.body } : null + } + + /** 写入或覆盖;JS Map.set 在已有 key 上不改变插入位置 */ + upsert(slug: string, summary: string, body: string): Promise { + return this.withWriteLock(async () => { + this.entries.set(slug, { summary, body }) + }) + } + + /** 删除一条;不存在为 no-op */ + remove(slug: string): Promise { + return this.withWriteLock(async () => { + this.entries.delete(slug) + }) + } +} diff --git a/packages/core/src/shared-memory/render-index.ts b/packages/core/src/shared-memory/render-index.ts new file mode 100644 index 0000000..38c30d3 --- /dev/null +++ b/packages/core/src/shared-memory/render-index.ts @@ -0,0 +1,19 @@ +import type { SharedMemoryStore } from './types' + +const HINT = `调用 stello_memory_recall 工具按 slug 查阅完整内容; +调用 stello_memory_remember / stello_memory_forget 工具维护此处条目。` + +/** + * 渲染共享 memory 索引段。 + * - store 为 undefined 或无 entry:返回 undefined(调用方应跳过注入) + * - 否则返回 + hint 文本 + */ +export async function renderSharedMemoryIndex( + store: SharedMemoryStore | undefined, +): Promise { + if (!store) return undefined + const entries = await store.list() + if (entries.length === 0) return undefined + const lines = entries.map(e => `- ${e.slug}: ${e.summary}`).join('\n') + return `\n${lines}\n\n\n${HINT}` +} diff --git a/packages/core/src/shared-memory/types.ts b/packages/core/src/shared-memory/types.ts new file mode 100644 index 0000000..c3af55b --- /dev/null +++ b/packages/core/src/shared-memory/types.ts @@ -0,0 +1,27 @@ +/** + * 共享 memory 的单条记录。 + * slug: 主键 / summary: 出现在索引行的一句话 / body: recall 时返回的全文。 + */ +export interface SharedMemoryEntry { + slug: string + summary: string + body: string +} + +/** + * StelloAgent 级共享 memory 存储接口。 + * + * - 一个 StelloAgent 实例对应一份 store;所有 Session 共享 + * - list() 按"插入顺序"返回;upsert 已存在 slug 时**不改变其顺序位置** + * - 写操作(upsert / remove)由实现内部串行化(writeLock 范式),读操作允许脏读 + */ +export interface SharedMemoryStore { + /** 列举全部 entries(按插入顺序) */ + list(): Promise + /** 读取单条 entry,不存在返回 null */ + get(slug: string): Promise + /** 写入或覆盖一条 entry(不存在则追加到末尾,存在则覆盖 summary + body 并保持顺序) */ + upsert(slug: string, summary: string, body: string): Promise + /** 删除一条 entry;不存在为 no-op */ + remove(slug: string): Promise +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f313687..7b4cf26 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3,16 +3,6 @@ // Session 系统 export type { SessionStatus, SessionMeta, TopologyNode, SessionTreeNode, CreateSessionOptions, SessionTree } from './types/session'; -// 记忆系统 -export type { - InheritancePolicy, - CoreSchemaField, - CoreSchema, - TurnRecord, - AssembledContext, - MemoryEngine, -} from './types/memory'; - // 文件系统适配器 export type { FileSystemAdapter } from './types/fs'; diff --git a/packages/core/src/types/engine.ts b/packages/core/src/types/engine.ts index e7b89d9..d485e00 100644 --- a/packages/core/src/types/engine.ts +++ b/packages/core/src/types/engine.ts @@ -1,10 +1,7 @@ // ─── 引擎主接口 + 事件 + 策略类型定义 ─── import type { SessionTree } from './session'; -import type { - TurnRecord, - MemoryEngine, -} from './memory'; +import type { TurnRecord } from './memory'; import type { BootstrapResult, AfterTurnResult, @@ -76,8 +73,6 @@ export interface StelloEngine { readonly sessionId: string; /** Session 树操作 */ readonly sessions: SessionTree; - /** 记忆系统 */ - readonly memory: MemoryEngine; /** Skill 路由 */ readonly skills: SkillRouter; /** 确认协议 */ diff --git a/packages/core/src/types/memory.ts b/packages/core/src/types/memory.ts index 984786e..7451bf1 100644 --- a/packages/core/src/types/memory.ts +++ b/packages/core/src/types/memory.ts @@ -1,30 +1,4 @@ -// ─── 记忆系统类型定义 ─── - -/** 记忆继承策略 */ -export type InheritancePolicy = 'full' | 'summary' | 'minimal' | 'scoped'; - -/** - * L1 schema 字段描述 - * - * 定义核心档案中每个字段的类型、默认值及行为标记。 - */ -export interface CoreSchemaField { - /** 字段值类型 */ - type: 'string' | 'number' | 'boolean' | 'array' | 'object'; - /** 默认值 */ - default?: unknown; - /** 是否允许从子 Session 冒泡到全局 core.json */ - bubbleable?: boolean; - /** 变更是否需要用户确认 */ - requireConfirm?: boolean; -} - -/** - * L1 完整 schema - * - * 由开发者定义,描述核心档案的结构。 - */ -export type CoreSchema = Record; +// ─── 对话记录 / 上下文类型定义 ─── /** * L3 单条对话记录 @@ -58,35 +32,3 @@ export interface AssembledContext { scope: string | null; } -/** - * 记忆系统接口 - * - * 管理三层记忆的读写,以及按继承策略组装上下文。 - * L2 内容文件使用 markdown 格式(LLM 天然理解,用户可直接阅读)。 - */ -export interface MemoryEngine { - /** 读取 L1 核心档案(支持点路径,如 'profile.gpa') */ - readCore(path?: string): Promise; - /** 写入 L1 核心档案的某个字段 */ - writeCore(path: string, value: unknown): Promise; - /** 读取某 Session 的 memory.md(记忆摘要) */ - readMemory(sessionId: string): Promise; - /** 写入某 Session 的 memory.md */ - writeMemory(sessionId: string, content: string): Promise; - /** 读取某 Session 的 scope.md(对话边界) */ - readScope(sessionId: string): Promise; - /** 写入某 Session 的 scope.md */ - writeScope(sessionId: string, content: string): Promise; - /** 读取某 Session 的 index.md(子节点目录) */ - readIndex(sessionId: string): Promise; - /** 写入某 Session 的 index.md */ - writeIndex(sessionId: string, content: string): Promise; - /** 追加一条 L3 对话记录 */ - appendRecord(sessionId: string, record: TurnRecord): Promise; - /** 覆盖某 Session 的全部 L3 对话记录 */ - replaceRecords?(sessionId: string, records: TurnRecord[]): Promise; - /** 读取某 Session 的所有 L3 对话记录 */ - readRecords(sessionId: string): Promise; - /** 按继承策略组装上下文 */ - assembleContext(sessionId: string): Promise; -} diff --git a/packages/session/src/__tests__/shared-memory-index.test.ts b/packages/session/src/__tests__/shared-memory-index.test.ts new file mode 100644 index 0000000..b62b060 --- /dev/null +++ b/packages/session/src/__tests__/shared-memory-index.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest' +import { createSession } from '../create-session' +import { InMemoryStorageAdapter } from '../mocks/in-memory-storage' +import type { LLMAdapter, Message } from '../types/llm' + +function makeLLM(): { adapter: LLMAdapter; lastMessages: () => Message[] } { + let captured: Message[] = [] + const adapter: LLMAdapter = { + async complete(messages) { + captured = messages + return { content: 'ok' } + }, + } + return { adapter, lastMessages: () => captured } +} + +describe('shared memory index injection', () => { + it('inserts shared memory index between systemPrompt and session_identity', async () => { + const storage = new InMemoryStorageAdapter() + const { adapter, lastMessages } = makeLLM() + const session = await createSession({ + id: 's1', + label: 'child', + storage, + llm: adapter, + }) + await session.setSystemPrompt('SYS') + await session.send('hello', { sharedMemoryIndex: '\n- a: x\n' }) + + const msgs = lastMessages() + const sysIdx = msgs.findIndex(m => m.role === 'system' && m.content === 'SYS') + const memIdx = msgs.findIndex(m => m.role === 'system' && m.content.includes('')) + const idIdx = msgs.findIndex(m => m.role === 'system' && m.content.includes('')) + + expect(sysIdx).toBeGreaterThanOrEqual(0) + expect(memIdx).toBeGreaterThan(sysIdx) + expect(idIdx).toBeGreaterThan(memIdx) + }) + + it('omits the slot when sharedMemoryIndex is undefined', async () => { + const storage = new InMemoryStorageAdapter() + const { adapter, lastMessages } = makeLLM() + const session = await createSession({ + id: 's1', + label: 'child', + storage, + llm: adapter, + }) + await session.setSystemPrompt('SYS') + await session.send('hello') // no options + + const msgs = lastMessages() + expect(msgs.find(m => m.content.includes(''))).toBeUndefined() + }) + + it('omits the slot when sharedMemoryIndex is empty string', async () => { + const storage = new InMemoryStorageAdapter() + const { adapter, lastMessages } = makeLLM() + const session = await createSession({ + id: 's1', + label: 'child', + storage, + llm: adapter, + }) + await session.send('hi', { sharedMemoryIndex: '' }) + + const msgs = lastMessages() + expect(msgs.find(m => m.content.includes(''))).toBeUndefined() + }) +}) diff --git a/packages/session/src/context-utils.ts b/packages/session/src/context-utils.ts index 8b1760a..183ab42 100644 --- a/packages/session/src/context-utils.ts +++ b/packages/session/src/context-utils.ts @@ -175,6 +175,7 @@ export async function assembleSessionContext( userContent: string, compress: CompressContext, label?: string, + sharedMemoryIndex?: string, ): Promise { const prefixMessages: Message[] = [] let insightConsumed = false @@ -185,10 +186,15 @@ export async function assembleSessionContext( prefixMessages.push({ role: 'system', content: sysPrompt }) } - // 2. session identity (label) + // 2. shared memory index (agent-level) + if (sharedMemoryIndex) { + prefixMessages.push({ role: 'system', content: sharedMemoryIndex }) + } + + // 3. session identity (label) prefixMessages.push(...buildSessionIdentityMessages(label)) - // 3. insight + // 4. insight const insightContent = await storage.getInsight(sessionId) if (insightContent) { prefixMessages.push({ role: 'system', content: insightContent }) diff --git a/packages/session/src/create-session.ts b/packages/session/src/create-session.ts index 5a69a2c..642b63c 100644 --- a/packages/session/src/create-session.ts +++ b/packages/session/src/create-session.ts @@ -52,6 +52,7 @@ async function assembleSessionReplayContext( sessionId: string, storage: CreateSessionOptions['storage'] | LoadSessionOptions['storage'], label?: string, + sharedMemoryIndex?: string, ): Promise<{ messages: Message[]; insightConsumed: boolean }> { const messages: Message[] = [] let insightConsumed = false @@ -61,6 +62,10 @@ async function assembleSessionReplayContext( messages.push({ role: 'system', content: sysPrompt }) } + if (sharedMemoryIndex) { + messages.push({ role: 'system', content: sharedMemoryIndex }) + } + messages.push(...buildSessionIdentityMessages(label)) const insightContent = await storage.getInsight(sessionId) @@ -162,6 +167,7 @@ function buildSession( currentMeta.id, storage, content, { maxContextTokens: options.llm.maxContextTokens, lastPromptTokens, compressFn: resolveCompressFn(), compressionCache }, currentMeta.label, + sendOptions?.sharedMemoryIndex, ) if (assembled.compressionCache !== undefined) { compressionCache = assembled.compressionCache @@ -176,7 +182,7 @@ function buildSession( let recordsToPersist: Message[] = [{ role: 'user', content, timestamp: assembled.userTimestamp }] const toolEnvelope = parseToolResultEnvelope(content) if (toolEnvelope) { - const replayContext = await assembleSessionReplayContext(currentMeta.id, storage, currentMeta.label) + const replayContext = await assembleSessionReplayContext(currentMeta.id, storage, currentMeta.label, sendOptions?.sharedMemoryIndex) promptMessages = [ ...replayContext.messages, ...toolEnvelope.toolResults.map((result) => ({ @@ -237,6 +243,7 @@ function buildSession( currentMeta.id, storage, content, { maxContextTokens: options.llm!.maxContextTokens, lastPromptTokens, compressFn: resolveCompressFn(), compressionCache }, currentMeta.label, + sendOptions?.sharedMemoryIndex, ) if (assembled.compressionCache !== undefined) { compressionCache = assembled.compressionCache @@ -251,7 +258,7 @@ function buildSession( let recordsToPersist: Message[] = [{ role: 'user', content, timestamp: assembled.userTimestamp }] const toolEnvelope = parseToolResultEnvelope(content) if (toolEnvelope) { - const replayContext = await assembleSessionReplayContext(currentMeta.id, storage, currentMeta.label) + const replayContext = await assembleSessionReplayContext(currentMeta.id, storage, currentMeta.label, sendOptions?.sharedMemoryIndex) promptMessages = [ ...replayContext.messages, ...toolEnvelope.toolResults.map((result) => ({ diff --git a/packages/session/src/types/session-api.ts b/packages/session/src/types/session-api.ts index 0b5c5e9..708d3b7 100644 --- a/packages/session/src/types/session-api.ts +++ b/packages/session/src/types/session-api.ts @@ -19,6 +19,11 @@ export interface MessageQueryOptions { export interface SessionSendOptions { /** AbortSignal — abort 后中断 LLM 调用并 reject 为 AbortError */ signal?: AbortSignal + /** + * Agent 级共享 memory 索引段(已由编排层渲染好)。 + * 非空时插入到 systemPrompt 之后、session_identity 之前;为空 / undefined 时不注入。 + */ + sharedMemoryIndex?: string } /** Session 错误:操作归档中的 Session */ From ca2021caa56e59227b7ee5cdfc75ae8b784ef9f0 Mon Sep 17 00:00:00 2001 From: uchouT Date: Thu, 21 May 2026 03:20:01 +0800 Subject: [PATCH 21/40] feat: session storage compression cache (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(session): add optional compression cache persistence to SessionStorage * refactor(session): address review — add clear method, Chinese JSDoc, type-level tests * feat(session): add hydrate/flush helpers for compression cache persistence * refactor(session): address review — fire-and-forget flush + warn on cache errors * feat(session): hydrate compression cache from storage at session startup * refactor(session): document hydrate race edge case; improve hydration test stub * feat(session): flush compression cache to storage after compress * refactor(session): extract compression flush helper; skip redundant cache-hit writes * test(session): end-to-end hydrate/flush cycle for compression cache * refactor(session): address review — move e2e test, strengthen hydrate assertion - Move 'flushed snapshot from one session is hydrated by the next' out of 'compress persistence — flush after success' into a new sibling describe block 'end-to-end: flush ↔ hydrate cycle', with its own warn-spy guard. The test exercises the full hydrate+flush round-trip across two session instances and is the only test touching hydrateCompressionCache from this side; co-locating it with flush-only tests was misleading. - Strengthen the bonus assertion from a 'sessionB.send resolves' smoke check to a meaningful claim about hydrate consumption: compressFnB is now a vi.fn() spy and we assert at most one call. The stricter 'compressFnB NOT called' assertion is not achievable in this end-to-end setup because cache-hit keys on compressedCount === compressCount, and the recentBudget computation uses ESTIMATED_SUMMARY_TOKENS (500) when compressionCache is null (Session A's first send) vs. the actual hydrated summary length (Session B after hydrate) — yielding different compressedCount values and thus a forced cache miss on Session B's first send. This is an inherent property of the current cache key, not a wiring bug. Inline comment explains the trade-off. * test(session): drop non-discriminating compress call assertion from e2e * refactor(session): drop unused clearCompressionCache optional method * test(session): replace any with SessionStorage typings to satisfy lint 测试中 16 处 `any` 改为 `SessionStorage` / `CompressionCacheSnapshot`,并删除未使用的 sessionB 变量。无运行时行为变化。 --- .../context-cache-persistence.test.ts | 416 ++++++++++++++++++ packages/session/src/context-utils.ts | 44 +- packages/session/src/create-session.ts | 45 +- packages/session/src/index.ts | 2 +- packages/session/src/types/storage.ts | 24 + 5 files changed, 522 insertions(+), 9 deletions(-) create mode 100644 packages/session/src/__tests__/context-cache-persistence.test.ts diff --git a/packages/session/src/__tests__/context-cache-persistence.test.ts b/packages/session/src/__tests__/context-cache-persistence.test.ts new file mode 100644 index 0000000..9d07b83 --- /dev/null +++ b/packages/session/src/__tests__/context-cache-persistence.test.ts @@ -0,0 +1,416 @@ +import { describe, it, expect, expectTypeOf, vi, beforeEach, afterEach } from 'vitest' +import type { SessionStorage, CompressionCacheSnapshot } from '../types/storage' + +describe('SessionStorage compression cache extension', () => { + it('CompressionCacheSnapshot has the expected shape', () => { + expectTypeOf().toEqualTypeOf<{ + summary: string + compressedCount: number + }>() + }) + + it('get/putCompressionCache are optional on SessionStorage', () => { + expectTypeOf().toEqualTypeOf< + ((sessionId: string) => Promise) | undefined + >() + expectTypeOf().toEqualTypeOf< + ((sessionId: string, snapshot: CompressionCacheSnapshot) => Promise) | undefined + >() + }) +}) + +describe('hydrateCompressionCache', () => { + let warnSpy: ReturnType + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('returns null when storage has no method', async () => { + const { hydrateCompressionCache } = await import('../context-utils') + const storage = {} as unknown as SessionStorage + const result = await hydrateCompressionCache(storage, 'sid-1') + expect(result).toBeNull() + }) + + it('returns null when storage returns null', async () => { + const { hydrateCompressionCache } = await import('../context-utils') + const storage = { + getCompressionCache: async () => null, + } as unknown as SessionStorage + const result = await hydrateCompressionCache(storage, 'sid-1') + expect(result).toBeNull() + }) + + it('returns CompressionCache mirroring snapshot', async () => { + const { hydrateCompressionCache } = await import('../context-utils') + const storage = { + getCompressionCache: async () => ({ summary: 's', compressedCount: 7 }), + } as unknown as SessionStorage + const result = await hydrateCompressionCache(storage, 'sid-1') + expect(result).toEqual({ summary: 's', compressedCount: 7 }) + }) + + it('swallows errors and returns null', async () => { + const { hydrateCompressionCache } = await import('../context-utils') + const storage = { + getCompressionCache: async () => { throw new Error('db down') }, + } as unknown as SessionStorage + const result = await hydrateCompressionCache(storage, 'sid-1') + expect(result).toBeNull() + expect(warnSpy).toHaveBeenCalledWith( + '[stello/session] hydrateCompressionCache failed', + expect.objectContaining({ sessionId: 'sid-1', err: expect.any(Error) }), + ) + }) +}) + +describe('flushCompressionCache', () => { + let warnSpy: ReturnType + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('is a no-op when storage has no putCompressionCache method', async () => { + const { flushCompressionCache } = await import('../context-utils') + const storage = {} as unknown as SessionStorage + expect(() => flushCompressionCache(storage, 'sid-1', { summary: 's', compressedCount: 3 })).not.toThrow() + }) + + it('calls putCompressionCache when present', async () => { + const { flushCompressionCache } = await import('../context-utils') + const calls: Array<[string, CompressionCacheSnapshot]> = [] + const storage = { + putCompressionCache: async (sid: string, snap: CompressionCacheSnapshot) => { calls.push([sid, snap]) }, + } as unknown as SessionStorage + flushCompressionCache(storage, 'sid-1', { summary: 's', compressedCount: 3 }) + // 等待 microtask queue flush + await new Promise((resolve) => setImmediate(resolve)) + expect(calls).toEqual([['sid-1', { summary: 's', compressedCount: 3 }]]) + }) + + it('swallows put errors (must not block caller)', async () => { + const { flushCompressionCache } = await import('../context-utils') + const storage = { + putCompressionCache: async () => { throw new Error('disk full') }, + } as unknown as SessionStorage + expect(() => flushCompressionCache(storage, 'sid-1', { summary: 's', compressedCount: 1 })).not.toThrow() + // 让 microtask 跑完;不应产生 unhandled rejection + await new Promise((resolve) => setImmediate(resolve)) + expect(warnSpy).toHaveBeenCalledWith( + '[stello/session] flushCompressionCache failed', + expect.objectContaining({ sessionId: 'sid-1', err: expect.any(Error) }), + ) + }) +}) + +describe('createSession compressionCache hydration', () => { + it('hydrates compressionCache from storage on creation when getCompressionCache returns a snapshot', async () => { + const { createSession } = await import('../create-session') + const calls: string[] = [] + const fakeStorage: SessionStorage = { + getSession: async (id: string) => ({ id, label: 'test', status: 'active', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }), + putSession: async () => {}, + listSessions: async () => [], + appendRecord: async () => {}, + listRecords: async () => [], + trimRecords: async () => {}, + getSystemPrompt: async () => null, + putSystemPrompt: async () => {}, + getInsight: async () => null, + putInsight: async () => {}, + clearInsight: async () => {}, + getMemory: async () => null, + putMemory: async () => {}, + transaction: async (fn: (tx: SessionStorage) => Promise) => fn(fakeStorage), + getCompressionCache: async (sid: string) => { + calls.push(sid) + return { summary: 'hydrated', compressedCount: 5 } + }, + } + + const session = await createSession({ + id: 'sid-1', + storage: fakeStorage, + }) + + // 等待 microtask + I/O 跑完(fire-and-forget hydrate) + await new Promise((resolve) => setImmediate(resolve)) + + expect(calls).toEqual(['sid-1']) + expect(session).toBeDefined() + expect(session.meta.id).toBe('sid-1') + }) +}) + +describe('compress persistence — flush after success', () => { + let warnSpy: ReturnType + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('calls storage.putCompressionCache after a successful compress in send()', async () => { + const { createSession } = await import('../create-session') + const { InMemoryStorageAdapter } = await import('../mocks/in-memory-storage') + const { createMockLLM } = await import('./helpers') + + // 基于 InMemoryStorageAdapter,但额外捕获 putCompressionCache 调用 + const baseStorage = new InMemoryStorageAdapter() + const puts: Array<[string, CompressionCacheSnapshot]> = [] + const storage: SessionStorage = baseStorage + storage.putCompressionCache = async (sid, snap) => { + puts.push([sid, snap]) + } + + // 极小上下文窗口 → 必然触发压缩;mock LLM 一次响应即可 + const llm = { ...createMockLLM([{ content: 'OK', usage: { promptTokens: 100, completionTokens: 10 } }]), maxContextTokens: 50 } + const compressFn = async () => 'compressed summary text' + + const session = await createSession({ + id: 'sid-flush-1', + storage, + llm, + compressFn, + label: 'Test', + }) + + // 预填充足量历史,确保超阈触发 compress + for (let i = 0; i < 20; i++) { + await storage.appendRecord(session.meta.id, { role: 'user', content: `message number ${i} with some padding text` }) + await storage.appendRecord(session.meta.id, { role: 'assistant', content: `reply number ${i} with some padding text` }) + } + + await session.send('trigger compress') + + // flush 是 fire-and-forget,等 microtask 跑完 + await new Promise((resolve) => setImmediate(resolve)) + + expect(puts).toHaveLength(1) + expect(puts[0]![0]).toBe('sid-flush-1') + expect(puts[0]![1]).toEqual({ summary: 'compressed summary text', compressedCount: expect.any(Number) }) + expect(puts[0]![1].compressedCount).toBeGreaterThan(0) + }) + + it('does NOT flush when no compress occurs (under threshold)', async () => { + const { createSession } = await import('../create-session') + const { InMemoryStorageAdapter } = await import('../mocks/in-memory-storage') + const { createMockLLM } = await import('./helpers') + + const baseStorage = new InMemoryStorageAdapter() + const puts: Array<[string, CompressionCacheSnapshot]> = [] + const storage: SessionStorage = baseStorage + storage.putCompressionCache = async (sid, snap) => { + puts.push([sid, snap]) + } + + // 巨大上下文窗口 → 不触发 compress + const llm = { ...createMockLLM([{ content: 'OK', usage: { promptTokens: 100, completionTokens: 10 } }]), maxContextTokens: 1_000_000 } + const compressFn = async () => 'should not be called' + + const session = await createSession({ + id: 'sid-noflush', + storage, + llm, + compressFn, + label: 'Test', + }) + + await session.send('a normal message') + await new Promise((resolve) => setImmediate(resolve)) + + expect(puts).toHaveLength(0) + }) + + it('does NOT re-flush on cache hits (same reference returned)', async () => { + const { createSession } = await import('../create-session') + const { InMemoryStorageAdapter } = await import('../mocks/in-memory-storage') + const { createMockLLM } = await import('./helpers') + + const baseStorage = new InMemoryStorageAdapter() + const puts: Array<[string, CompressionCacheSnapshot]> = [] + const storage: SessionStorage = baseStorage + storage.putCompressionCache = async (sid, snap) => { + puts.push([sid, snap]) + } + + // 用统一长度的 user / assistant 消息(包括 send 时的输入和 mock LLM 的回复), + // 这样每轮 send 后历史增长的 token 量与旧消息一致,recentMessages 选择窗口 + // 会等量右移,history.length - recentMessages.length 保持稳定 → 触发缓存命中。 + const UNIFORM = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' // 44 chars ≈ 11 tokens + const llm = { + ...createMockLLM([ + { content: UNIFORM, usage: { promptTokens: 100, completionTokens: 10 } }, + { content: UNIFORM, usage: { promptTokens: 100, completionTokens: 10 } }, + ]), + maxContextTokens: 200, + } + + // 计数 compressFn 实际被调用的次数;返回稳定摘要 + let compressFnCalls = 0 + const compressFn = async () => { + compressFnCalls++ + return 'stable' + } + + const session = await createSession({ + id: 'sid-cachehit', + storage, + llm, + compressFn, + label: 'Test', + }) + + // 预填充足量历史,确保第一次 send 时超阈触发 compress + for (let i = 0; i < 30; i++) { + await storage.appendRecord(session.meta.id, { role: 'user', content: UNIFORM }) + await storage.appendRecord(session.meta.id, { role: 'assistant', content: UNIFORM }) + } + + // 第一次 send → 产生新压缩快照,flush 一次 + await session.send(UNIFORM) + await new Promise((resolve) => setImmediate(resolve)) + + // 第二次 send → 缓存命中(compressedCount 不变),compressWithFn 返回同引用, + // persistAndApplyCompressionCache 跳过 flush + await session.send(UNIFORM) + await new Promise((resolve) => setImmediate(resolve)) + + // 关键断言:flush 只发生一次,即使两次 send 都走 compress 路径 + expect(puts).toHaveLength(1) + // 同时验证 compressFn 也只被实际调用一次(进一步佐证第二次是缓存命中) + expect(compressFnCalls).toBe(1) + }) + + it('does NOT flush when compressFn throws (failed compress)', async () => { + const { createSession } = await import('../create-session') + const { InMemoryStorageAdapter } = await import('../mocks/in-memory-storage') + const { createMockLLM } = await import('./helpers') + + const baseStorage = new InMemoryStorageAdapter() + const puts: Array<[string, CompressionCacheSnapshot]> = [] + const storage: SessionStorage = baseStorage + storage.putCompressionCache = async (sid, snap) => { + puts.push([sid, snap]) + } + + const llm = { ...createMockLLM([{ content: 'OK', usage: { promptTokens: 100, completionTokens: 10 } }]), maxContextTokens: 50 } + const compressFn = async () => { throw new Error('compress boom') } + + const session = await createSession({ + id: 'sid-failcompress', + storage, + llm, + compressFn, + label: 'Test', + }) + + for (let i = 0; i < 20; i++) { + await storage.appendRecord(session.meta.id, { role: 'user', content: `message number ${i} with some padding text` }) + await storage.appendRecord(session.meta.id, { role: 'assistant', content: `reply number ${i} with some padding text` }) + } + + await expect(session.send('trigger')).rejects.toThrow('compress boom') + await new Promise((resolve) => setImmediate(resolve)) + + expect(puts).toHaveLength(0) + }) +}) + +describe('end-to-end: flush ↔ hydrate cycle', () => { + let warnSpy: ReturnType + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('flushed snapshot from one session is readable via hydrate on the next', async () => { + const { createSession } = await import('../create-session') + const { InMemoryStorageAdapter } = await import('../mocks/in-memory-storage') + const { hydrateCompressionCache } = await import('../context-utils') + const { createMockLLM } = await import('./helpers') + + // 单一 storage 实例,跨两个 session 生命周期共享(模拟"重启后同 sid 再起") + const baseStorage = new InMemoryStorageAdapter() + const persistedCaches = new Map() + const storage: SessionStorage = baseStorage + storage.getCompressionCache = async (sid) => persistedCaches.get(sid) ?? null + storage.putCompressionCache = async (sid, snap) => { + persistedCaches.set(sid, { summary: snap.summary, compressedCount: snap.compressedCount }) + } + + // 统一长度的 user/assistant 消息,让 send 时历史增长平稳,与 S4 cache-hit 测试同款 + const UNIFORM = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + const SHARED_SID = 'sid-e2e' + const makeLLM = () => ({ + ...createMockLLM([ + { content: UNIFORM, usage: { promptTokens: 100, completionTokens: 10 } }, + { content: UNIFORM, usage: { promptTokens: 100, completionTokens: 10 } }, + ]), + maxContextTokens: 200, + }) + + // —— Session A:产生压缩,flush 写入"持久化" map + const sessionA = await createSession({ + id: SHARED_SID, + storage, + llm: makeLLM(), + compressFn: async () => 'PERSISTED-SUMMARY-FROM-SESSION-A', + label: 'A', + }) + + // 预填充足量历史,确保第一次 send 时超阈触发 compress + for (let i = 0; i < 30; i++) { + await storage.appendRecord(sessionA.meta.id, { role: 'user', content: UNIFORM }) + await storage.appendRecord(sessionA.meta.id, { role: 'assistant', content: UNIFORM }) + } + + await sessionA.send(UNIFORM) + // fire-and-forget flush 跑完 + await new Promise((resolve) => setImmediate(resolve)) + + // 1) Session A 之后,storage 中已有快照 + expect(persistedCaches.has(SHARED_SID)).toBe(true) + const persisted = persistedCaches.get(SHARED_SID)! + expect(persisted.summary).toBe('PERSISTED-SUMMARY-FROM-SESSION-A') + expect(persisted.compressedCount).toBeGreaterThan(0) + + // —— Session B:同 sid + 同 storage(模拟进程重启 / 新 session 实例) + // 仅为触发 createSession 内部的 fire-and-forget hydrate 副作用,session 实例本身不参与断言 + await createSession({ + id: SHARED_SID, + storage, + llm: makeLLM(), + compressFn: async () => 'would-not-affect-test', + label: 'B', + }) + + // 等待 createSession 内部的 fire-and-forget hydrate 完成 + await new Promise((resolve) => setImmediate(resolve)) + + // 2) hydrateCompressionCache 直接调用应该返回与 session A flush 同样的快照 + // (hydrate 自身契约,不依赖 session 内部状态,是真实的 round-trip 证明) + const restored = await hydrateCompressionCache(storage, SHARED_SID) + expect(restored).toEqual({ + summary: 'PERSISTED-SUMMARY-FROM-SESSION-A', + compressedCount: persisted.compressedCount, + }) + }) +}) diff --git a/packages/session/src/context-utils.ts b/packages/session/src/context-utils.ts index 183ab42..c963185 100644 --- a/packages/session/src/context-utils.ts +++ b/packages/session/src/context-utils.ts @@ -1,5 +1,5 @@ import type { Message, LLMAdapter } from './types/llm.js' -import type { SessionStorage } from './types/storage.js' +import type { SessionStorage, CompressionCacheSnapshot } from './types/storage.js' import type { CompressFn } from './types/functions.js' /** @@ -287,3 +287,45 @@ async function compressWithFn( compressionCache: newCache, } } + +/** + * 从 storage 读取已持久化的压缩缓存(若实现该方法)。 + * + * 返回 null 的情形: + * - storage 未实现 getCompressionCache(可选方法) + * - storage 返回 null(无快照) + * - 读取抛错(错误通过 console.warn 记录,调用方回退到内存行为) + */ +export async function hydrateCompressionCache( + storage: SessionStorage, + sessionId: string, +): Promise { + if (typeof storage.getCompressionCache !== 'function') return null + try { + const snap = await storage.getCompressionCache(sessionId) + if (!snap) return null + return { summary: snap.summary, compressedCount: snap.compressedCount } + } catch (err) { + console.warn('[stello/session] hydrateCompressionCache failed', { sessionId, err }) + return null + } +} + +/** + * 把压缩缓存快照写入 storage(若实现该方法)。fire-and-forget: + * 调用立即返回,持久化在后台异步进行。失败会通过 console.warn 记录, + * 但永远不会阻塞 LLM 轮次,也不会抛错。 + * 未实现 putCompressionCache 的 storage 后端,本函数等效 no-op。 + */ +export function flushCompressionCache( + storage: SessionStorage, + sessionId: string, + snapshot: CompressionCacheSnapshot, +): void { + if (typeof storage.putCompressionCache !== 'function') return + // Fire-and-forget: persistence latency must not block the calling LLM turn. + // Errors are warned but never thrown. + void storage.putCompressionCache(sessionId, snapshot).catch((err) => { + console.warn('[stello/session] flushCompressionCache failed', { sessionId, err }) + }) +} diff --git a/packages/session/src/create-session.ts b/packages/session/src/create-session.ts index 642b63c..363f196 100644 --- a/packages/session/src/create-session.ts +++ b/packages/session/src/create-session.ts @@ -4,7 +4,7 @@ import { SessionArchivedError } from './types/session-api.js' import type { SessionMeta, SessionMetaUpdate, ForkOptions } from './types/session.js' import type { Message } from './types/llm.js' import type { CreateSessionOptions, LoadSessionOptions, SendResult, StreamResult } from './types/functions.js' -import { assembleSessionContext, buildSessionIdentityMessages, createBuiltinCompressFn, removeIncompleteToolCallGroups, type CompressionCache } from './context-utils.js' +import { assembleSessionContext, buildSessionIdentityMessages, createBuiltinCompressFn, flushCompressionCache, hydrateCompressionCache, removeIncompleteToolCallGroups, type CompressionCache } from './context-utils.js' interface ToolResultEnvelope { toolResults: Array<{ @@ -142,11 +142,46 @@ function buildSession( let tools = options.tools let lastPromptTokens: number | null = null let compressionCache: CompressionCache | null = null + // 从 storage 后端加载持久化压缩缓存(若支持);fire-and-forget。 + // 若 hydrate 在首次 compress 之前到达,缓存命中,跳过一次 compress 调用。 + // helper 内部已 console.warn 错误,此处永不抛错。 + // + // 边界:如果 hydrate Promise 完成前发生 "compress → reset" 序列 + //(compressionCache 被显式置 null),迟到的 hydrate 会按此 guard 把 + // stale snapshot 重新装入。在实践中该窗口极窄(hydrate 是亚秒级 DB 读, + // reset 通常是用户动作),且会被下一次 compress 自然纠正。 + void hydrateCompressionCache(storage, currentMeta.id).then((cache) => { + if (cache && !compressionCache) compressionCache = cache + }) /** 解析 compressFn:用户提供 > 内置 LLM 压缩 */ function resolveCompressFn() { return options.compressFn ?? createBuiltinCompressFn(options.llm!) } + /** + * 在一次 turn 结束后同步内存压缩缓存,并在确实有新压缩快照产生时把它 + * 持久化到 storage(fire-and-forget;失败由 helper 内部 warn 记录)。 + * + * 行为: + * - assembledCache === undefined:本轮 compress 未运行,直接返回。 + * - assembledCache 为真值且与当前内存引用不同:产生了新压缩快照,flush。 + * - assembledCache 为真值且与当前内存引用相同:compressWithFn 在缓存命中 + * 时会返回同一引用,跳过 flush,避免重复写入。 + * + * TODO: AssembleResult.compressionCache 类型包含 `| null`,但 + * compressWithFn 实际不会返回 null。可在独立清理中收紧该类型,使下方 + * truthy 检查变得多余。 + */ + function persistAndApplyCompressionCache( + assembledCache: CompressionCache | null | undefined, + ): void { + if (assembledCache === undefined) return + if (assembledCache && assembledCache !== compressionCache) { + flushCompressionCache(storage, currentMeta.id, assembledCache) + } + compressionCache = assembledCache + } + const session: Session = { get meta(): Readonly { return currentMeta @@ -169,9 +204,7 @@ function buildSession( currentMeta.label, sendOptions?.sharedMemoryIndex, ) - if (assembled.compressionCache !== undefined) { - compressionCache = assembled.compressionCache - } + persistAndApplyCompressionCache(assembled.compressionCache) // 消费 insight if (assembled.insightConsumed) { @@ -245,9 +278,7 @@ function buildSession( currentMeta.label, sendOptions?.sharedMemoryIndex, ) - if (assembled.compressionCache !== undefined) { - compressionCache = assembled.compressionCache - } + persistAndApplyCompressionCache(assembled.compressionCache) // 消费 insight if (assembled.insightConsumed) { diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index b2b62fe..1e545f7 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -1,6 +1,6 @@ // 类型导出 — Session export type { SessionMeta, SessionMetaUpdate, SessionFilter, ForkOptions, ForkContextFn } from './types/session.js' -export type { SessionStorage, ListRecordsOptions } from './types/storage.js' +export type { SessionStorage, ListRecordsOptions, CompressionCacheSnapshot } from './types/storage.js' export type { Message, ToolCall, LLMCompleteOptions, LLMResult, LLMChunk, LLMAdapter, } from './types/llm.js' diff --git a/packages/session/src/types/storage.ts b/packages/session/src/types/storage.ts index 0d9b989..f5f9e75 100644 --- a/packages/session/src/types/storage.ts +++ b/packages/session/src/types/storage.ts @@ -47,6 +47,30 @@ export interface SessionStorage { /** 写入 Session 的 memory */ putMemory(sessionId: string, content: string): Promise + /** + * (可选)读取 session 的已持久化压缩缓存。 + * 未实现该方法的 storage 后端,压缩缓存仅保留在内存(进程重启即丢)。 + * 无快照时返回 null。 + */ + getCompressionCache?(sessionId: string): Promise + + /** + * (可选)持久化压缩缓存快照。 + * 每次压缩成功后被调用。失败应记录日志但不得阻塞当前 LLM 轮次。 + */ + putCompressionCache?(sessionId: string, snapshot: CompressionCacheSnapshot): Promise + /** 事务(内存实现可直接执行 fn) */ transaction(fn: (tx: SessionStorage) => Promise): Promise } + +/** + * 压缩缓存快照,可通过 SessionStorage 持久化。 + * 形态与 context-utils.ts 中的 CompressionCache 保持一致。 + */ +export interface CompressionCacheSnapshot { + /** 最新的压缩摘要文本 */ + summary: string + /** 该摘要覆盖的历史消息起始条数 */ + compressedCount: number +} From 390beff200776fb86e68fbaf6bfd89b3724b84cb Mon Sep 17 00:00:00 2001 From: uchouT Date: Thu, 21 May 2026 05:17:16 +0800 Subject: [PATCH 22/40] feat(core): add forkCompressFn field and DEFAULT_FORK_COMPRESS_PROMPT --- packages/core/src/index.ts | 1 + packages/core/src/llm/defaults.ts | 9 +++++++++ packages/core/src/types/session-config.ts | 2 ++ 3 files changed, 12 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 61dba47..0674836 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -140,6 +140,7 @@ export { createDefaultCompressFn, DEFAULT_CONSOLIDATE_PROMPT, DEFAULT_COMPRESS_PROMPT, + DEFAULT_FORK_COMPRESS_PROMPT, } from './llm/defaults'; export type { LLMCallFn, DefaultFnOptions } from './llm/defaults'; diff --git a/packages/core/src/llm/defaults.ts b/packages/core/src/llm/defaults.ts index 35e810c..8084678 100644 --- a/packages/core/src/llm/defaults.ts +++ b/packages/core/src/llm/defaults.ts @@ -85,6 +85,15 @@ export const DEFAULT_COMPRESS_PROMPT = `你是对话压缩助手。请将以下 - 输出一段连贯文字 - 语言精炼,像一份上下文备忘录` +/** Fork 子会话上下文压缩的默认提示词:作为子会话开场的"交接备忘"风格摘要 */ +export const DEFAULT_FORK_COMPRESS_PROMPT = `你是对话压缩助手。请将以下对话历史压缩为一段简洁的摘要,作为子会话开场的上下文背景。 +要求: +- 保留对话的核心主题、关键事实、已确认的结论 +- 突出对子会话延续可能有用的人物 / 设定 / 待办 +- 省略寒暄、重复信息和已收尾的细节 +- 输出一段连贯文字,不用列表或 Markdown 标记 +- 语言精炼客观,像一份交接备忘` + /** 根据 prompt 创建默认 compressFn:历史消息 → 压缩摘要 */ export function createDefaultCompressFn( prompt: string, diff --git a/packages/core/src/types/session-config.ts b/packages/core/src/types/session-config.ts index 5bfca72..7b99e98 100644 --- a/packages/core/src/types/session-config.ts +++ b/packages/core/src/types/session-config.ts @@ -25,6 +25,8 @@ export interface SessionConfig { consolidateFn?: SessionCompatibleConsolidateFn; /** 上下文压缩函数 */ compressFn?: SessionCompatibleCompressFn; + /** Fork 时用于压缩父对话作为子会话上下文背景的函数;缺省时降级到 compressFn,再缺省则用 DEFAULT_FORK_COMPRESS_PROMPT */ + forkCompressFn?: SessionCompatibleCompressFn; } /** From c2a4e304e47bf28e7bfb063b31fa8953b52468ee Mon Sep 17 00:00:00 2001 From: uchouT Date: Thu, 21 May 2026 05:20:15 +0800 Subject: [PATCH 23/40] style(core): normalize punctuation in DEFAULT_FORK_COMPRESS_PROMPT to full-width --- packages/core/src/llm/defaults.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/llm/defaults.ts b/packages/core/src/llm/defaults.ts index 8084678..38bdab4 100644 --- a/packages/core/src/llm/defaults.ts +++ b/packages/core/src/llm/defaults.ts @@ -86,13 +86,13 @@ export const DEFAULT_COMPRESS_PROMPT = `你是对话压缩助手。请将以下 - 语言精炼,像一份上下文备忘录` /** Fork 子会话上下文压缩的默认提示词:作为子会话开场的"交接备忘"风格摘要 */ -export const DEFAULT_FORK_COMPRESS_PROMPT = `你是对话压缩助手。请将以下对话历史压缩为一段简洁的摘要,作为子会话开场的上下文背景。 -要求: +export const DEFAULT_FORK_COMPRESS_PROMPT = `你是对话压缩助手。请将以下对话历史压缩为一段简洁的摘要,作为子会话开场的上下文背景。 +要求: - 保留对话的核心主题、关键事实、已确认的结论 - 突出对子会话延续可能有用的人物 / 设定 / 待办 - 省略寒暄、重复信息和已收尾的细节 -- 输出一段连贯文字,不用列表或 Markdown 标记 -- 语言精炼客观,像一份交接备忘` +- 输出一段连贯文字,不用列表或 Markdown 标记 +- 语言精炼客观,像一份交接备忘` /** 根据 prompt 创建默认 compressFn:历史消息 → 压缩摘要 */ export function createDefaultCompressFn( From 864bc39e26bdec7d9d42343555f19ab3680cbd1d Mon Sep 17 00:00:00 2001 From: uchouT Date: Thu, 21 May 2026 05:21:39 +0800 Subject: [PATCH 24/40] feat(core): merge forkCompressFn through layered session config --- .../__tests__/merge-session-config.test.ts | 37 +++++++++++++++++++ .../core/src/engine/merge-session-config.ts | 1 + 2 files changed, 38 insertions(+) diff --git a/packages/core/src/engine/__tests__/merge-session-config.test.ts b/packages/core/src/engine/__tests__/merge-session-config.test.ts index 4352b94..99f02cf 100644 --- a/packages/core/src/engine/__tests__/merge-session-config.test.ts +++ b/packages/core/src/engine/__tests__/merge-session-config.test.ts @@ -264,4 +264,41 @@ describe('mergeSessionConfig', () => { }) expect(result.systemPrompt).toBe('P') }) + + it('forkCompressFn: defaults → parent → profile → forkOptions later-wins', () => { + const fnDefaults = makeCompressFn('defaults') + const fnParent = makeCompressFn('parent') + const fnProfile = makeCompressFn('profile') + const fnFork = makeCompressFn('fork') + + const result = mergeSessionConfig({ + defaults: { forkCompressFn: fnDefaults }, + parent: { forkCompressFn: fnParent }, + profile: { forkCompressFn: fnProfile, systemPromptMode: 'preset' }, + forkOptions: { label: 't', forkCompressFn: fnFork }, + }) + expect(result.forkCompressFn).toBe(fnFork) + }) + + it('forkCompressFn: undefined 不覆盖下层值', () => { + const fnDefaults = makeCompressFn('defaults') + const result = mergeSessionConfig({ + defaults: { forkCompressFn: fnDefaults }, + parent: {}, + profile: { systemPromptMode: 'preset' }, + forkOptions: { label: 't' }, + }) + expect(result.forkCompressFn).toBe(fnDefaults) + }) + + it('forkCompressFn 与 compressFn 互不干扰', () => { + const fnCompress = makeCompressFn('compress') + const fnFork = makeCompressFn('fork') + const result = mergeSessionConfig({ + defaults: { compressFn: fnCompress, forkCompressFn: fnFork }, + forkOptions: { label: 't' }, + }) + expect(result.compressFn).toBe(fnCompress) + expect(result.forkCompressFn).toBe(fnFork) + }) }) diff --git a/packages/core/src/engine/merge-session-config.ts b/packages/core/src/engine/merge-session-config.ts index 2206444..0453cd2 100644 --- a/packages/core/src/engine/merge-session-config.ts +++ b/packages/core/src/engine/merge-session-config.ts @@ -45,6 +45,7 @@ export function mergeSessionConfig(input: MergeSessionConfigInput): SessionConfi if (layer.skills !== undefined) result.skills = layer.skills if (layer.consolidateFn !== undefined) result.consolidateFn = layer.consolidateFn if (layer.compressFn !== undefined) result.compressFn = layer.compressFn + if (layer.forkCompressFn !== undefined) result.forkCompressFn = layer.forkCompressFn } // systemPrompt 合成:profile 存在时走 mode 规则,缺省时退化为普通覆盖链 From 7634b3a3ca0104ec95491e34ce4282ba900761a0 Mon Sep 17 00:00:00 2001 From: uchouT Date: Thu, 21 May 2026 05:26:08 +0800 Subject: [PATCH 25/40] feat(core): applyCompressContext prefers forkCompressFn over compressFn --- .../engine/__tests__/fork-compress.test.ts | 83 ++++++++++++++----- packages/core/src/engine/fork-compress.ts | 13 +-- packages/core/src/engine/stello-engine.ts | 1 + 3 files changed, 71 insertions(+), 26 deletions(-) diff --git a/packages/core/src/engine/__tests__/fork-compress.test.ts b/packages/core/src/engine/__tests__/fork-compress.test.ts index e759cde..f874f26 100644 --- a/packages/core/src/engine/__tests__/fork-compress.test.ts +++ b/packages/core/src/engine/__tests__/fork-compress.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, vi } from 'vitest' import { applyCompressContext, ForkConfigError } from '../fork-compress' -import type { LLMCallFn } from '../../llm/defaults' import { StelloEngineImpl } from '../stello-engine' import { ForkProfileRegistryImpl, type ForkProfile } from '../fork-profile' import type { SessionTree } from '../../types/session' @@ -18,6 +17,7 @@ describe('applyCompressContext', () => { const result = await applyCompressContext({ context: 'inherit', systemPrompt: 'role', + forkCompressFn: undefined, compressFn: undefined, llmCallFn: undefined, sourceMessages: async () => makeMessages(2), @@ -30,6 +30,7 @@ describe('applyCompressContext', () => { const result = await applyCompressContext({ context: 'compress', systemPrompt: 'role', + forkCompressFn: undefined, compressFn, llmCallFn: undefined, sourceMessages: async () => makeMessages(4), @@ -44,6 +45,7 @@ describe('applyCompressContext', () => { const result = await applyCompressContext({ context: 'compress', systemPrompt: 'role', + forkCompressFn: undefined, compressFn, llmCallFn: undefined, sourceMessages: async () => [], @@ -57,6 +59,7 @@ describe('applyCompressContext', () => { const result = await applyCompressContext({ context: 'compress', systemPrompt: undefined, + forkCompressFn: undefined, compressFn: async () => 'X', llmCallFn: undefined, sourceMessages: async () => makeMessages(2), @@ -65,46 +68,84 @@ describe('applyCompressContext', () => { expect(result.forwardedContext).toBe('none') }) - it('compress + 无 compressFn 但有 llmCallFn:fallback 用 DEFAULT_COMPRESS_PROMPT 构建', async () => { - const llmCallFn = vi.fn(async () => 'fallback-summary') + it('compress + compressFn 抛异常:向上传播', async () => { + const boom = new Error('LLM down') + await expect( + applyCompressContext({ + context: 'compress', + systemPrompt: 'r', + forkCompressFn: undefined, + compressFn: async () => { + throw boom + }, + llmCallFn: undefined, + sourceMessages: async () => makeMessages(2), + }), + ).rejects.toBe(boom) + }) + + it('compress + 同时提供 forkCompressFn 与 compressFn:优先 forkCompressFn', async () => { + const forkFn = vi.fn(async () => 'fork-summary') + const compressFn = vi.fn(async () => 'normal-summary') + const result = await applyCompressContext({ + context: 'compress', + systemPrompt: 'role', + forkCompressFn: forkFn, + compressFn, + llmCallFn: undefined, + sourceMessages: async () => makeMessages(2), + }) + expect(forkFn).toHaveBeenCalledOnce() + expect(compressFn).not.toHaveBeenCalled() + expect(result.systemPrompt).toBe('role\n\n\nfork-summary\n') + expect(result.forwardedContext).toBe('none') + }) + + it('compress + forkCompressFn 缺失但 compressFn 存在:回退到 compressFn', async () => { + const compressFn = vi.fn(async () => 'normal-summary') + const result = await applyCompressContext({ + context: 'compress', + systemPrompt: 'role', + forkCompressFn: undefined, + compressFn, + llmCallFn: undefined, + sourceMessages: async () => makeMessages(2), + }) + expect(compressFn).toHaveBeenCalledOnce() + expect(result.systemPrompt).toBe('role\n\n\nnormal-summary\n') + }) + + it('compress + 两者均缺失但 llmCallFn 存在:用 DEFAULT_FORK_COMPRESS_PROMPT 构造默认', async () => { + const llmCallFn = vi.fn(async (msgs: Array<{ role: string; content: string }>) => { + // 断言 system 消息包含新默认 prompt 的关键字 + const sysMsg = msgs.find((m) => m.role === 'system') + expect(sysMsg?.content).toContain('子会话开场') + return 'default-summary' + }) const result = await applyCompressContext({ context: 'compress', systemPrompt: 'r', + forkCompressFn: undefined, compressFn: undefined, llmCallFn, sourceMessages: async () => makeMessages(2), }) expect(llmCallFn).toHaveBeenCalledOnce() - expect(result.systemPrompt).toBe('r\n\n\nfallback-summary\n') - expect(result.forwardedContext).toBe('none') + expect(result.systemPrompt).toBe('r\n\n\ndefault-summary\n') }) - it('compress + 无 compressFn 也无 llmCallFn:抛 ForkConfigError', async () => { + it('compress + 三者均缺失:仍抛 ForkConfigError', async () => { await expect( applyCompressContext({ context: 'compress', systemPrompt: 'r', + forkCompressFn: undefined, compressFn: undefined, llmCallFn: undefined, sourceMessages: async () => makeMessages(2), }), ).rejects.toBeInstanceOf(ForkConfigError) }) - - it('compress + compressFn 抛异常:向上传播', async () => { - const boom = new Error('LLM down') - await expect( - applyCompressContext({ - context: 'compress', - systemPrompt: 'r', - compressFn: async () => { - throw boom - }, - llmCallFn: undefined, - sourceMessages: async () => makeMessages(2), - }), - ).rejects.toBe(boom) - }) }) describe('forkSession compress integration', () => { diff --git a/packages/core/src/engine/fork-compress.ts b/packages/core/src/engine/fork-compress.ts index 2358250..bced31e 100644 --- a/packages/core/src/engine/fork-compress.ts +++ b/packages/core/src/engine/fork-compress.ts @@ -4,7 +4,7 @@ import type { } from '../adapters/session-runtime' import { createDefaultCompressFn, - DEFAULT_COMPRESS_PROMPT, + DEFAULT_FORK_COMPRESS_PROMPT, type LLMCallFn, } from '../llm/defaults' @@ -21,7 +21,9 @@ export interface ApplyCompressContextArgs { context: SessionCompatibleForkOptions['context'] /** compose 链拼好的 systemPrompt(未追加 parent_context) */ systemPrompt: string | undefined - /** 显式传入的 compressFn(优先级高于 llmCallFn fallback) */ + /** 显式 fork-compress 函数(优先级最高) */ + forkCompressFn: SessionCompatibleCompressFn | undefined + /** 普通 compress 函数(forkCompressFn 缺失时的回退) */ compressFn: SessionCompatibleCompressFn | undefined /** 已经从 LLMAdapter 包装好的 LLMCallFn;用于 fallback 构造默认 compressFn */ llmCallFn: LLMCallFn | undefined @@ -55,21 +57,22 @@ export interface ApplyCompressContextResult { export async function applyCompressContext( args: ApplyCompressContextArgs, ): Promise { - const { context, systemPrompt, compressFn, llmCallFn, sourceMessages } = args + const { context, systemPrompt, forkCompressFn, compressFn, llmCallFn, sourceMessages } = args if (context !== 'compress') { return { systemPrompt, forwardedContext: context } } const resolvedCompressFn: SessionCompatibleCompressFn | undefined = + forkCompressFn ?? compressFn ?? (llmCallFn - ? createDefaultCompressFn(DEFAULT_COMPRESS_PROMPT, llmCallFn) + ? createDefaultCompressFn(DEFAULT_FORK_COMPRESS_PROMPT, llmCallFn) : undefined) if (!resolvedCompressFn) { throw new ForkConfigError( - 'compress context requires compressFn or llm in compose chain', + 'compress context requires forkCompressFn, compressFn, or llm in compose chain', ) } diff --git a/packages/core/src/engine/stello-engine.ts b/packages/core/src/engine/stello-engine.ts index d9dfa04..d3ba168 100644 --- a/packages/core/src/engine/stello-engine.ts +++ b/packages/core/src/engine/stello-engine.ts @@ -377,6 +377,7 @@ export class StelloEngineImpl implements StelloEngine { await applyCompressContext({ context: effectiveContext, systemPrompt: merged.systemPrompt, + forkCompressFn: merged.forkCompressFn, compressFn: merged.compressFn, llmCallFn, sourceMessages: () => this.session.messages(), From 3286136e9e0260a0dd226cd59a53f05c94de0a78 Mon Sep 17 00:00:00 2001 From: uchouT Date: Fri, 22 May 2026 18:47:03 +0800 Subject: [PATCH 26/40] refactor(core): drop summary field from SharedMemoryEntry --- packages/core/src/shared-memory/types.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/core/src/shared-memory/types.ts b/packages/core/src/shared-memory/types.ts index c3af55b..60ce5dc 100644 --- a/packages/core/src/shared-memory/types.ts +++ b/packages/core/src/shared-memory/types.ts @@ -1,10 +1,9 @@ /** * 共享 memory 的单条记录。 - * slug: 主键 / summary: 出现在索引行的一句话 / body: recall 时返回的全文。 + * slug: 主键 / body: 完整内容,渲染到 system prompt 的 段。 */ export interface SharedMemoryEntry { slug: string - summary: string body: string } @@ -20,8 +19,8 @@ export interface SharedMemoryStore { list(): Promise /** 读取单条 entry,不存在返回 null */ get(slug: string): Promise - /** 写入或覆盖一条 entry(不存在则追加到末尾,存在则覆盖 summary + body 并保持顺序) */ - upsert(slug: string, summary: string, body: string): Promise + /** 写入或覆盖一条 entry(不存在则追加到末尾,存在则覆盖 body 并保持顺序) */ + upsert(slug: string, body: string): Promise /** 删除一条 entry;不存在为 no-op */ remove(slug: string): Promise } From 9bb89b6c1f854f80e0b3132af0a03d8552cc227d Mon Sep 17 00:00:00 2001 From: uchouT Date: Fri, 22 May 2026 18:50:04 +0800 Subject: [PATCH 27/40] refactor(core): InMemorySharedMemoryStore drops summary param --- .../in-memory-shared-memory-store.test.ts | 53 +++++++++---------- .../in-memory-shared-memory-store.ts | 10 ++-- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/packages/core/src/shared-memory/__tests__/in-memory-shared-memory-store.test.ts b/packages/core/src/shared-memory/__tests__/in-memory-shared-memory-store.test.ts index be51cb7..c87f40c 100644 --- a/packages/core/src/shared-memory/__tests__/in-memory-shared-memory-store.test.ts +++ b/packages/core/src/shared-memory/__tests__/in-memory-shared-memory-store.test.ts @@ -9,76 +9,75 @@ describe('InMemorySharedMemoryStore', () => { it('upsert adds new entry and list returns it', async () => { const store = new InMemorySharedMemoryStore() - await store.upsert('a', 'sum-a', 'body-a') - expect(await store.list()).toEqual([{ slug: 'a', summary: 'sum-a', body: 'body-a' }]) + await store.upsert('a', 'body-a') + expect(await store.list()).toEqual([{ slug: 'a', body: 'body-a' }]) }) it('get returns the entry by slug, null if missing', async () => { const store = new InMemorySharedMemoryStore() - await store.upsert('a', 'sum-a', 'body-a') - expect(await store.get('a')).toEqual({ slug: 'a', summary: 'sum-a', body: 'body-a' }) + await store.upsert('a', 'body-a') + expect(await store.get('a')).toEqual({ slug: 'a', body: 'body-a' }) expect(await store.get('missing')).toBeNull() }) it('upsert preserves insertion order across multiple slugs', async () => { const store = new InMemorySharedMemoryStore() - await store.upsert('a', 'sa', 'ba') - await store.upsert('b', 'sb', 'bb') - await store.upsert('c', 'sc', 'bc') + await store.upsert('a', 'ba') + await store.upsert('b', 'bb') + await store.upsert('c', 'bc') expect((await store.list()).map(e => e.slug)).toEqual(['a', 'b', 'c']) }) - it('upsert on existing slug overwrites summary + body but keeps position', async () => { + it('upsert on existing slug overwrites body but keeps position', async () => { const store = new InMemorySharedMemoryStore() - await store.upsert('a', 'sa', 'ba') - await store.upsert('b', 'sb', 'bb') - await store.upsert('a', 'sa2', 'ba2') + await store.upsert('a', 'ba') + await store.upsert('b', 'bb') + await store.upsert('a', 'ba2') expect(await store.list()).toEqual([ - { slug: 'a', summary: 'sa2', body: 'ba2' }, - { slug: 'b', summary: 'sb', body: 'bb' }, + { slug: 'a', body: 'ba2' }, + { slug: 'b', body: 'bb' }, ]) }) it('remove deletes the entry; subsequent list omits it', async () => { const store = new InMemorySharedMemoryStore() - await store.upsert('a', 'sa', 'ba') - await store.upsert('b', 'sb', 'bb') + await store.upsert('a', 'ba') + await store.upsert('b', 'bb') await store.remove('a') - expect(await store.list()).toEqual([{ slug: 'b', summary: 'sb', body: 'bb' }]) + expect(await store.list()).toEqual([{ slug: 'b', body: 'bb' }]) }) it('remove on missing slug is a no-op', async () => { const store = new InMemorySharedMemoryStore() - await store.upsert('a', 'sa', 'ba') + await store.upsert('a', 'ba') await store.remove('missing') - expect(await store.list()).toEqual([{ slug: 'a', summary: 'sa', body: 'ba' }]) + expect(await store.list()).toEqual([{ slug: 'a', body: 'ba' }]) }) it('serializes concurrent upserts to same slug (last value wins, no lost write)', async () => { const store = new InMemorySharedMemoryStore() await Promise.all([ - store.upsert('a', 's1', 'b1'), - store.upsert('a', 's2', 'b2'), - store.upsert('a', 's3', 'b3'), + store.upsert('a', 'b1'), + store.upsert('a', 'b2'), + store.upsert('a', 'b3'), ]) const entries = await store.list() expect(entries.length).toBe(1) expect(entries[0]!.slug).toBe('a') - expect(['s1', 's2', 's3']).toContain(entries[0]!.summary) expect(['b1', 'b2', 'b3']).toContain(entries[0]!.body) }) it('serializes mixed concurrent upsert/remove without corruption', async () => { const store = new InMemorySharedMemoryStore() - await store.upsert('a', 'sa', 'ba') + await store.upsert('a', 'ba') await Promise.all([ - store.upsert('b', 'sb', 'bb'), + store.upsert('b', 'bb'), store.remove('a'), - store.upsert('c', 'sc', 'bc'), + store.upsert('c', 'bc'), ]) const entries = await store.list() expect(entries.find(e => e.slug === 'a')).toBeUndefined() - expect(entries.find(e => e.slug === 'b')).toEqual({ slug: 'b', summary: 'sb', body: 'bb' }) - expect(entries.find(e => e.slug === 'c')).toEqual({ slug: 'c', summary: 'sc', body: 'bc' }) + expect(entries.find(e => e.slug === 'b')).toEqual({ slug: 'b', body: 'bb' }) + expect(entries.find(e => e.slug === 'c')).toEqual({ slug: 'c', body: 'bc' }) }) }) diff --git a/packages/core/src/shared-memory/in-memory-shared-memory-store.ts b/packages/core/src/shared-memory/in-memory-shared-memory-store.ts index 0a7e83c..3d46c6c 100644 --- a/packages/core/src/shared-memory/in-memory-shared-memory-store.ts +++ b/packages/core/src/shared-memory/in-memory-shared-memory-store.ts @@ -7,7 +7,7 @@ import type { SharedMemoryEntry, SharedMemoryStore } from './types' * 避免并发 upsert / remove 时读到中间状态。读操作不加锁,允许脏读。 */ export class InMemorySharedMemoryStore implements SharedMemoryStore { - private readonly entries = new Map() + private readonly entries = new Map() private writeLock: Promise = Promise.resolve() /** 把 fn 排入写队列,串行执行 */ @@ -19,19 +19,19 @@ export class InMemorySharedMemoryStore implements SharedMemoryStore { /** 列举全部 entries,按 Map 插入顺序 */ async list(): Promise { - return [...this.entries].map(([slug, { summary, body }]) => ({ slug, summary, body })) + return [...this.entries].map(([slug, body]) => ({ slug, body })) } /** 读取单条 entry */ async get(slug: string): Promise { const v = this.entries.get(slug) - return v ? { slug, summary: v.summary, body: v.body } : null + return v !== undefined ? { slug, body: v } : null } /** 写入或覆盖;JS Map.set 在已有 key 上不改变插入位置 */ - upsert(slug: string, summary: string, body: string): Promise { + upsert(slug: string, body: string): Promise { return this.withWriteLock(async () => { - this.entries.set(slug, { summary, body }) + this.entries.set(slug, body) }) } From 9088d705d9da04db2b702ec495caf40e8261de84 Mon Sep 17 00:00:00 2001 From: uchouT Date: Fri, 22 May 2026 18:52:12 +0800 Subject: [PATCH 28/40] refactor(core): replace renderSharedMemoryIndex with renderSharedMemoryContext --- .../__tests__/render-index.test.ts | 42 ---------------- .../__tests__/render-shared-memory.test.ts | 50 +++++++++++++++++++ .../core/src/shared-memory/render-index.ts | 19 ------- .../src/shared-memory/render-shared-memory.ts | 19 +++++++ 4 files changed, 69 insertions(+), 61 deletions(-) delete mode 100644 packages/core/src/shared-memory/__tests__/render-index.test.ts create mode 100644 packages/core/src/shared-memory/__tests__/render-shared-memory.test.ts delete mode 100644 packages/core/src/shared-memory/render-index.ts create mode 100644 packages/core/src/shared-memory/render-shared-memory.ts diff --git a/packages/core/src/shared-memory/__tests__/render-index.test.ts b/packages/core/src/shared-memory/__tests__/render-index.test.ts deleted file mode 100644 index bf277ee..0000000 --- a/packages/core/src/shared-memory/__tests__/render-index.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { renderSharedMemoryIndex } from '../render-index' -import { InMemorySharedMemoryStore } from '../in-memory-shared-memory-store' - -describe('renderSharedMemoryIndex', () => { - it('returns undefined when store is undefined', async () => { - expect(await renderSharedMemoryIndex(undefined)).toBeUndefined() - }) - - it('returns undefined when store has no entries', async () => { - const store = new InMemorySharedMemoryStore() - expect(await renderSharedMemoryIndex(store)).toBeUndefined() - }) - - it('renders entries inside with hint footer', async () => { - const store = new InMemorySharedMemoryStore() - await store.upsert('prefer-concise', '用户偏好简短回答', 'body-1') - await store.upsert('user-profile', '大三本科生 CS 专业', 'body-2') - const out = await renderSharedMemoryIndex(store) - expect(out).toContain('') - expect(out).toContain('- prefer-concise: 用户偏好简短回答') - expect(out).toContain('- user-profile: 大三本科生 CS 专业') - expect(out).toContain('') - expect(out).toMatch(/stello_memory_recall/) - expect(out).toMatch(/stello_memory_remember/) - expect(out).toMatch(/stello_memory_forget/) - }) - - it('preserves entry order in output', async () => { - const store = new InMemorySharedMemoryStore() - await store.upsert('a', 'sa', 'ba') - await store.upsert('b', 'sb', 'bb') - await store.upsert('c', 'sc', 'bc') - const out = await renderSharedMemoryIndex(store) - const aIdx = out!.indexOf('- a:') - const bIdx = out!.indexOf('- b:') - const cIdx = out!.indexOf('- c:') - expect(aIdx).toBeGreaterThan(-1) - expect(aIdx).toBeLessThan(bIdx) - expect(bIdx).toBeLessThan(cIdx) - }) -}) diff --git a/packages/core/src/shared-memory/__tests__/render-shared-memory.test.ts b/packages/core/src/shared-memory/__tests__/render-shared-memory.test.ts new file mode 100644 index 0000000..02fe9bc --- /dev/null +++ b/packages/core/src/shared-memory/__tests__/render-shared-memory.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { renderSharedMemoryContext } from '../render-shared-memory' +import { InMemorySharedMemoryStore } from '../in-memory-shared-memory-store' + +describe('renderSharedMemoryContext', () => { + it('returns undefined when store is undefined', async () => { + expect(await renderSharedMemoryContext(undefined)).toBeUndefined() + }) + + it('returns undefined when store has no entries', async () => { + const store = new InMemorySharedMemoryStore() + expect(await renderSharedMemoryContext(store)).toBeUndefined() + }) + + it('renders entries inside with hint footer', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('prefer-concise', '用户偏好简短回答。') + await store.upsert('user-profile', '大三本科生 CS 专业。') + const out = await renderSharedMemoryContext(store) + expect(out).toContain('') + expect(out).toContain('## prefer-concise') + expect(out).toContain('用户偏好简短回答。') + expect(out).toContain('## user-profile') + expect(out).toContain('大三本科生 CS 专业。') + expect(out).toContain('') + expect(out).toMatch(/stello_memory_edit/) + }) + + it('preserves entry order in output', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'body-a') + await store.upsert('b', 'body-b') + await store.upsert('c', 'body-c') + const out = await renderSharedMemoryContext(store) + const aIdx = out!.indexOf('## a') + const bIdx = out!.indexOf('## b') + const cIdx = out!.indexOf('## c') + expect(aIdx).toBeGreaterThan(-1) + expect(aIdx).toBeLessThan(bIdx) + expect(bIdx).toBeLessThan(cIdx) + }) + + it('separates entries with a blank line between bodies', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'body-a') + await store.upsert('b', 'body-b') + const out = await renderSharedMemoryContext(store) + expect(out).toContain('body-a\n\n## b') + }) +}) diff --git a/packages/core/src/shared-memory/render-index.ts b/packages/core/src/shared-memory/render-index.ts deleted file mode 100644 index 38c30d3..0000000 --- a/packages/core/src/shared-memory/render-index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { SharedMemoryStore } from './types' - -const HINT = `调用 stello_memory_recall 工具按 slug 查阅完整内容; -调用 stello_memory_remember / stello_memory_forget 工具维护此处条目。` - -/** - * 渲染共享 memory 索引段。 - * - store 为 undefined 或无 entry:返回 undefined(调用方应跳过注入) - * - 否则返回 + hint 文本 - */ -export async function renderSharedMemoryIndex( - store: SharedMemoryStore | undefined, -): Promise { - if (!store) return undefined - const entries = await store.list() - if (entries.length === 0) return undefined - const lines = entries.map(e => `- ${e.slug}: ${e.summary}`).join('\n') - return `\n${lines}\n\n\n${HINT}` -} diff --git a/packages/core/src/shared-memory/render-shared-memory.ts b/packages/core/src/shared-memory/render-shared-memory.ts new file mode 100644 index 0000000..2ae54a5 --- /dev/null +++ b/packages/core/src/shared-memory/render-shared-memory.ts @@ -0,0 +1,19 @@ +import type { SharedMemoryStore } from './types' + +const HINT = `调用 stello_memory_edit 工具新增、修改或删除上面的条目。` + +/** + * 渲染共享 memory 全量内容段。 + * - store 为 undefined 或无 entry:返回 undefined(调用方应跳过注入) + * - 否则返回 ... + hint 文本, + * 每条 entry 用 `## {slug}` 标题 + body 正文,条目间空行分隔 + */ +export async function renderSharedMemoryContext( + store: SharedMemoryStore | undefined, +): Promise { + if (!store) return undefined + const entries = await store.list() + if (entries.length === 0) return undefined + const blocks = entries.map(e => `## ${e.slug}\n${e.body}`).join('\n\n') + return `\n${blocks}\n\n\n${HINT}` +} From 5611baaf705cd6305e1249bef7c12d0b4d77f813 Mon Sep 17 00:00:00 2001 From: uchouT Date: Fri, 22 May 2026 18:55:18 +0800 Subject: [PATCH 29/40] refactor(core): collapse memory tools into single memoryEditTool --- .../__tests__/memory-edit-tool.test.ts | 110 ++++++++++++++++++ .../__tests__/memory-forget-tool.test.ts | 46 -------- .../__tests__/memory-recall-tool.test.ts | 46 -------- .../__tests__/memory-remember-tool.test.ts | 78 ------------- packages/core/src/builtin-tools/index.ts | 4 +- .../src/builtin-tools/memory-edit-tool.ts | 52 +++++++++ .../src/builtin-tools/memory-forget-tool.ts | 36 ------ .../src/builtin-tools/memory-recall-tool.ts | 37 ------ .../src/builtin-tools/memory-remember-tool.ts | 49 -------- 9 files changed, 163 insertions(+), 295 deletions(-) create mode 100644 packages/core/src/builtin-tools/__tests__/memory-edit-tool.test.ts delete mode 100644 packages/core/src/builtin-tools/__tests__/memory-forget-tool.test.ts delete mode 100644 packages/core/src/builtin-tools/__tests__/memory-recall-tool.test.ts delete mode 100644 packages/core/src/builtin-tools/__tests__/memory-remember-tool.test.ts create mode 100644 packages/core/src/builtin-tools/memory-edit-tool.ts delete mode 100644 packages/core/src/builtin-tools/memory-forget-tool.ts delete mode 100644 packages/core/src/builtin-tools/memory-recall-tool.ts delete mode 100644 packages/core/src/builtin-tools/memory-remember-tool.ts diff --git a/packages/core/src/builtin-tools/__tests__/memory-edit-tool.test.ts b/packages/core/src/builtin-tools/__tests__/memory-edit-tool.test.ts new file mode 100644 index 0000000..6cf06ca --- /dev/null +++ b/packages/core/src/builtin-tools/__tests__/memory-edit-tool.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest' +import { memoryEditTool } from '../memory-edit-tool' +import { InMemorySharedMemoryStore } from '../../shared-memory/in-memory-shared-memory-store' +import type { ToolExecutionContext } from '../../types/tool' +import type { StelloAgent } from '../../agent/stello-agent' + +function fakeAgent(store: InMemorySharedMemoryStore | undefined): StelloAgent { + return { sharedMemory: store } as unknown as StelloAgent +} + +function ctx(agent: StelloAgent): ToolExecutionContext { + return { agent, sessionId: 's1', toolName: 'stello_memory_edit' } +} + +describe('memoryEditTool', () => { + it('exposes tool name "stello_memory_edit"', () => { + expect(memoryEditTool().name).toBe('stello_memory_edit') + }) + + it('upserts a new entry when body provided and delete not true', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryEditTool().execute( + { slug: 'a', body: 'BODY' }, + ctx(fakeAgent(store)), + ) + expect(r).toEqual({ success: true, data: { slug: 'a', op: 'upsert' } }) + expect(await store.get('a')).toEqual({ slug: 'a', body: 'BODY' }) + }) + + it('overwrites existing entry on upsert', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'old') + const r = await memoryEditTool().execute( + { slug: 'a', body: 'NEW' }, + ctx(fakeAgent(store)), + ) + expect(r.success).toBe(true) + expect(await store.get('a')).toEqual({ slug: 'a', body: 'NEW' }) + }) + + it('deletes entry when delete=true', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'BODY') + const r = await memoryEditTool().execute( + { slug: 'a', delete: true }, + ctx(fakeAgent(store)), + ) + expect(r).toEqual({ success: true, data: { slug: 'a', op: 'delete' } }) + expect(await store.get('a')).toBeNull() + }) + + it('delete=true on missing slug returns success (no-op)', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryEditTool().execute( + { slug: 'missing', delete: true }, + ctx(fakeAgent(store)), + ) + expect(r).toEqual({ success: true, data: { slug: 'missing', op: 'delete' } }) + }) + + it('delete=true ignores body even when provided', async () => { + const store = new InMemorySharedMemoryStore() + await store.upsert('a', 'old') + const r = await memoryEditTool().execute( + { slug: 'a', body: 'IGNORED', delete: true }, + ctx(fakeAgent(store)), + ) + expect(r.success).toBe(true) + expect(await store.get('a')).toBeNull() + }) + + it('returns error for empty slug', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryEditTool().execute( + { slug: '', body: 'b' }, + ctx(fakeAgent(store)), + ) + expect(r.success).toBe(false) + expect(r.error).toMatch(/slug/i) + }) + + it('returns error when body missing and delete not set', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryEditTool().execute( + { slug: 'a' }, + ctx(fakeAgent(store)), + ) + expect(r.success).toBe(false) + expect(r.error).toMatch(/body is required/i) + }) + + it('returns error when body is empty string and delete not set', async () => { + const store = new InMemorySharedMemoryStore() + const r = await memoryEditTool().execute( + { slug: 'a', body: '' }, + ctx(fakeAgent(store)), + ) + expect(r.success).toBe(false) + expect(r.error).toMatch(/body is required/i) + }) + + it('returns error when sharedMemory not configured', async () => { + const r = await memoryEditTool().execute( + { slug: 'a', body: 'b' }, + ctx(fakeAgent(undefined)), + ) + expect(r.success).toBe(false) + expect(r.error).toMatch(/sharedMemory not configured/) + }) +}) diff --git a/packages/core/src/builtin-tools/__tests__/memory-forget-tool.test.ts b/packages/core/src/builtin-tools/__tests__/memory-forget-tool.test.ts deleted file mode 100644 index 9d7232a..0000000 --- a/packages/core/src/builtin-tools/__tests__/memory-forget-tool.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { memoryForgetTool } from '../memory-forget-tool' -import { InMemorySharedMemoryStore } from '../../shared-memory/in-memory-shared-memory-store' -import type { ToolExecutionContext } from '../../types/tool' -import type { StelloAgent } from '../../agent/stello-agent' - -function fakeAgent(store: InMemorySharedMemoryStore | undefined): StelloAgent { - return { sharedMemory: store } as unknown as StelloAgent -} - -function ctx(agent: StelloAgent): ToolExecutionContext { - return { agent, sessionId: 's1', toolName: 'stello_memory_forget' } -} - -describe('memoryForgetTool', () => { - it('returns ToolRegistryEntry named "stello_memory_forget"', () => { - expect(memoryForgetTool().name).toBe('stello_memory_forget') - }) - - it('removes existing entry', async () => { - const store = new InMemorySharedMemoryStore() - await store.upsert('a', 'sa', 'ba') - const r = await memoryForgetTool().execute({ slug: 'a' }, ctx(fakeAgent(store))) - expect(r).toEqual({ success: true, data: { slug: 'a' } }) - expect(await store.get('a')).toBeNull() - }) - - it('returns success even when slug does not exist (no-op)', async () => { - const store = new InMemorySharedMemoryStore() - const r = await memoryForgetTool().execute({ slug: 'missing' }, ctx(fakeAgent(store))) - expect(r).toEqual({ success: true, data: { slug: 'missing' } }) - }) - - it('returns error for empty slug', async () => { - const store = new InMemorySharedMemoryStore() - const r = await memoryForgetTool().execute({ slug: '' }, ctx(fakeAgent(store))) - expect(r.success).toBe(false) - expect(r.error).toMatch(/slug/i) - }) - - it('returns error when sharedMemory is not configured', async () => { - const r = await memoryForgetTool().execute({ slug: 'a' }, ctx(fakeAgent(undefined))) - expect(r.success).toBe(false) - expect(r.error).toMatch(/sharedMemory not configured/i) - }) -}) diff --git a/packages/core/src/builtin-tools/__tests__/memory-recall-tool.test.ts b/packages/core/src/builtin-tools/__tests__/memory-recall-tool.test.ts deleted file mode 100644 index 87c7cea..0000000 --- a/packages/core/src/builtin-tools/__tests__/memory-recall-tool.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { memoryRecallTool } from '../memory-recall-tool' -import { InMemorySharedMemoryStore } from '../../shared-memory/in-memory-shared-memory-store' -import type { ToolExecutionContext } from '../../types/tool' -import type { StelloAgent } from '../../agent/stello-agent' - -function fakeAgent(store: InMemorySharedMemoryStore | undefined): StelloAgent { - return { sharedMemory: store } as unknown as StelloAgent -} - -function ctx(agent: StelloAgent): ToolExecutionContext { - return { agent, sessionId: 's1', toolName: 'stello_memory_recall' } -} - -describe('memoryRecallTool', () => { - it('returns ToolRegistryEntry named "stello_memory_recall"', () => { - expect(memoryRecallTool().name).toBe('stello_memory_recall') - }) - - it('returns body for known slug', async () => { - const store = new InMemorySharedMemoryStore() - await store.upsert('a', 'sa', 'BODY-A') - const r = await memoryRecallTool().execute({ slug: 'a' }, ctx(fakeAgent(store))) - expect(r).toEqual({ success: true, data: { body: 'BODY-A' } }) - }) - - it('returns error for unknown slug', async () => { - const store = new InMemorySharedMemoryStore() - const r = await memoryRecallTool().execute({ slug: 'nope' }, ctx(fakeAgent(store))) - expect(r.success).toBe(false) - expect(r.error).toMatch(/slug.*nope/i) - }) - - it('returns error when slug is empty', async () => { - const store = new InMemorySharedMemoryStore() - const r = await memoryRecallTool().execute({ slug: '' }, ctx(fakeAgent(store))) - expect(r.success).toBe(false) - expect(r.error).toMatch(/slug/i) - }) - - it('returns error when sharedMemory is not configured', async () => { - const r = await memoryRecallTool().execute({ slug: 'a' }, ctx(fakeAgent(undefined))) - expect(r.success).toBe(false) - expect(r.error).toMatch(/sharedMemory not configured/i) - }) -}) diff --git a/packages/core/src/builtin-tools/__tests__/memory-remember-tool.test.ts b/packages/core/src/builtin-tools/__tests__/memory-remember-tool.test.ts deleted file mode 100644 index 3b3b718..0000000 --- a/packages/core/src/builtin-tools/__tests__/memory-remember-tool.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { memoryRememberTool } from '../memory-remember-tool' -import { InMemorySharedMemoryStore } from '../../shared-memory/in-memory-shared-memory-store' -import type { ToolExecutionContext } from '../../types/tool' -import type { StelloAgent } from '../../agent/stello-agent' - -function fakeAgent(store: InMemorySharedMemoryStore | undefined): StelloAgent { - return { sharedMemory: store } as unknown as StelloAgent -} - -function ctx(agent: StelloAgent): ToolExecutionContext { - return { agent, sessionId: 's1', toolName: 'stello_memory_remember' } -} - -describe('memoryRememberTool', () => { - it('returns ToolRegistryEntry named "stello_memory_remember"', () => { - expect(memoryRememberTool().name).toBe('stello_memory_remember') - }) - - it('upserts a new entry', async () => { - const store = new InMemorySharedMemoryStore() - const r = await memoryRememberTool().execute( - { slug: 'a', summary: 'sa', body: 'BODY' }, - ctx(fakeAgent(store)), - ) - expect(r).toEqual({ success: true, data: { slug: 'a' } }) - expect(await store.get('a')).toEqual({ slug: 'a', summary: 'sa', body: 'BODY' }) - }) - - it('overwrites existing entry', async () => { - const store = new InMemorySharedMemoryStore() - await store.upsert('a', 'sa', 'old') - await memoryRememberTool().execute( - { slug: 'a', summary: 'sa2', body: 'NEW' }, - ctx(fakeAgent(store)), - ) - expect(await store.get('a')).toEqual({ slug: 'a', summary: 'sa2', body: 'NEW' }) - }) - - it('returns error for empty slug', async () => { - const store = new InMemorySharedMemoryStore() - const r = await memoryRememberTool().execute( - { slug: '', summary: 's', body: 'b' }, - ctx(fakeAgent(store)), - ) - expect(r.success).toBe(false) - expect(r.error).toMatch(/slug/i) - }) - - it('returns error for missing summary', async () => { - const store = new InMemorySharedMemoryStore() - const r = await memoryRememberTool().execute( - { slug: 'a', body: 'b' }, - ctx(fakeAgent(store)), - ) - expect(r.success).toBe(false) - expect(r.error).toMatch(/summary/i) - }) - - it('returns error for missing body', async () => { - const store = new InMemorySharedMemoryStore() - const r = await memoryRememberTool().execute( - { slug: 'a', summary: 's' }, - ctx(fakeAgent(store)), - ) - expect(r.success).toBe(false) - expect(r.error).toMatch(/body/i) - }) - - it('returns error when sharedMemory is not configured', async () => { - const r = await memoryRememberTool().execute( - { slug: 'a', summary: 's', body: 'b' }, - ctx(fakeAgent(undefined)), - ) - expect(r.success).toBe(false) - expect(r.error).toMatch(/sharedMemory not configured/i) - }) -}) diff --git a/packages/core/src/builtin-tools/index.ts b/packages/core/src/builtin-tools/index.ts index 9bf83a2..342ea28 100644 --- a/packages/core/src/builtin-tools/index.ts +++ b/packages/core/src/builtin-tools/index.ts @@ -1,5 +1,3 @@ export { createSessionTool } from './create-session-tool' export { activateSkillTool } from './activate-skill-tool' -export { memoryRecallTool } from './memory-recall-tool' -export { memoryRememberTool } from './memory-remember-tool' -export { memoryForgetTool } from './memory-forget-tool' +export { memoryEditTool } from './memory-edit-tool' diff --git a/packages/core/src/builtin-tools/memory-edit-tool.ts b/packages/core/src/builtin-tools/memory-edit-tool.ts new file mode 100644 index 0000000..062490c --- /dev/null +++ b/packages/core/src/builtin-tools/memory-edit-tool.ts @@ -0,0 +1,52 @@ +import type { ToolRegistryEntry } from '../tool/tool-registry' + +const DESCRIPTION = `新增、覆盖或删除一条共享 memory entry。所有 Session 共享同一份 store, +内容已直接在 段呈现给你,无需另外查询。 + +参数: +- slug(必填): kebab-case 主键 +- body(可选): 完整内容。提供时执行 upsert(存在则覆盖 body,顺序不变;不存在则追加)。 +- delete(可选,默认 false): true 时按 slug 删除,忽略 body;slug 不存在为 no-op。 + +何时使用:用户告诉你一条跨 session 持久成立的认知 / 偏好 / 背景, +或原条目已过时需要更新 / 移除时调用。` + +const PARAMETERS = { + type: 'object', + properties: { + slug: { type: 'string', description: 'kebab-case 主键' }, + body: { type: 'string', description: '完整内容;upsert 时必填' }, + delete: { type: 'boolean', description: 'true 时删除该 slug' }, + }, + required: ['slug'], +} + +/** 共享 memory 的单一编辑工具:upsert / delete */ +export function memoryEditTool(): ToolRegistryEntry { + return { + name: 'stello_memory_edit', + description: DESCRIPTION, + parameters: PARAMETERS, + execute: async (args, ctx) => { + const slug = (args.slug as string | undefined)?.trim() + if (!slug) return { success: false, error: 'slug is required and must be non-empty' } + const store = ctx.agent.sharedMemory + if (!store) return { success: false, error: 'sharedMemory not configured' } + const del = args.delete === true + try { + if (del) { + await store.remove(slug) + return { success: true, data: { slug, op: 'delete' } } + } + const body = args.body as string | undefined + if (!body || body.length === 0) { + return { success: false, error: 'body is required when not deleting' } + } + await store.upsert(slug, body) + return { success: true, data: { slug, op: 'upsert' } } + } catch (e) { + return { success: false, error: `failed: ${e instanceof Error ? e.message : String(e)}` } + } + }, + } +} diff --git a/packages/core/src/builtin-tools/memory-forget-tool.ts b/packages/core/src/builtin-tools/memory-forget-tool.ts deleted file mode 100644 index b552684..0000000 --- a/packages/core/src/builtin-tools/memory-forget-tool.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ToolRegistryEntry } from '../tool/tool-registry' - -const DESCRIPTION = `删除一条共享 memory entry。 - -参数: -- slug(必填): 要删除的 entry slug - -何时使用:原 entry 已过时 / 错误 / 不再相关时调用。slug 不存在不报错(no-op)。` - -const PARAMETERS = { - type: 'object', - properties: { - slug: { type: 'string', description: '要删除的 entry slug' }, - }, - required: ['slug'], -} - -export function memoryForgetTool(): ToolRegistryEntry { - return { - name: 'stello_memory_forget', - description: DESCRIPTION, - parameters: PARAMETERS, - execute: async (args, ctx) => { - const slug = (args.slug as string | undefined)?.trim() - if (!slug) return { success: false, error: 'slug is required and must be non-empty' } - const store = ctx.agent.sharedMemory - if (!store) return { success: false, error: 'sharedMemory not configured' } - try { - await store.remove(slug) - return { success: true, data: { slug } } - } catch (e) { - return { success: false, error: `failed: ${e instanceof Error ? e.message : String(e)}` } - } - }, - } -} diff --git a/packages/core/src/builtin-tools/memory-recall-tool.ts b/packages/core/src/builtin-tools/memory-recall-tool.ts deleted file mode 100644 index 983c35d..0000000 --- a/packages/core/src/builtin-tools/memory-recall-tool.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ToolRegistryEntry } from '../tool/tool-registry' - -const DESCRIPTION = `按 slug 读取一条共享 memory 的完整内容。 - -参数: -- slug(必填): 索引中列出的某条 entry 的 slug - -何时使用:上下文里 出现了你需要详读的 slug 时调用。` - -const PARAMETERS = { - type: 'object', - properties: { - slug: { type: 'string', description: '索引中的 entry slug' }, - }, - required: ['slug'], -} - -export function memoryRecallTool(): ToolRegistryEntry { - return { - name: 'stello_memory_recall', - description: DESCRIPTION, - parameters: PARAMETERS, - execute: async (args, ctx) => { - const slug = (args.slug as string | undefined)?.trim() - if (!slug) return { success: false, error: 'slug is required and must be non-empty' } - const store = ctx.agent.sharedMemory - if (!store) return { success: false, error: 'sharedMemory not configured' } - try { - const entry = await store.get(slug) - if (!entry) return { success: false, error: `slug "${slug}" not found` } - return { success: true, data: { body: entry.body } } - } catch (e) { - return { success: false, error: `failed: ${e instanceof Error ? e.message : String(e)}` } - } - }, - } -} diff --git a/packages/core/src/builtin-tools/memory-remember-tool.ts b/packages/core/src/builtin-tools/memory-remember-tool.ts deleted file mode 100644 index 9dabaa2..0000000 --- a/packages/core/src/builtin-tools/memory-remember-tool.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ToolRegistryEntry } from '../tool/tool-registry' - -const DESCRIPTION = `写入或覆盖一条共享 memory entry。所有 Session 共享同一份 store。 - -参数: -- slug(必填): kebab-case 主键 -- summary(必填): 索引行展示的一句话 -- body(必填): 详情全文(recall 时返回) - -何时使用:当你判断某个事实 / 偏好 / 背景对整个 agent 都有用,且未来对话需要复用时调用。 -存在则覆盖,不存在则追加;不会改变 entry 的原有插入顺序。` - -const PARAMETERS = { - type: 'object', - properties: { - slug: { type: 'string', description: 'kebab-case 主键' }, - summary: { type: 'string', description: '索引行的一句话' }, - body: { type: 'string', description: '完整内容' }, - }, - required: ['slug', 'summary', 'body'], -} - -export function memoryRememberTool(): ToolRegistryEntry { - return { - name: 'stello_memory_remember', - description: DESCRIPTION, - parameters: PARAMETERS, - execute: async (args, ctx) => { - const slug = (args.slug as string | undefined)?.trim() - if (!slug) return { success: false, error: 'slug is required and must be non-empty' } - const summary = args.summary as string | undefined - if (summary === undefined || summary === null) { - return { success: false, error: 'summary is required' } - } - const body = args.body as string | undefined - if (body === undefined || body === null) { - return { success: false, error: 'body is required' } - } - const store = ctx.agent.sharedMemory - if (!store) return { success: false, error: 'sharedMemory not configured' } - try { - await store.upsert(slug, summary, body) - return { success: true, data: { slug } } - } catch (e) { - return { success: false, error: `failed: ${e instanceof Error ? e.message : String(e)}` } - } - }, - } -} From 8d7a6f547e6a1e861ef2dedf6365ce86d7b68cae Mon Sep 17 00:00:00 2001 From: uchouT Date: Fri, 22 May 2026 18:57:28 +0800 Subject: [PATCH 30/40] refactor(core): StelloAgent.upsertSharedMemoryEntry drops summary param --- .../agent/__tests__/shared-memory-sdk.test.ts | 16 ++++++------ packages/core/src/agent/stello-agent.ts | 25 +++++++++++-------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/core/src/agent/__tests__/shared-memory-sdk.test.ts b/packages/core/src/agent/__tests__/shared-memory-sdk.test.ts index a901419..6e2e11f 100644 --- a/packages/core/src/agent/__tests__/shared-memory-sdk.test.ts +++ b/packages/core/src/agent/__tests__/shared-memory-sdk.test.ts @@ -51,24 +51,24 @@ describe('StelloAgent shared memory SDK', () => { it('upsertSharedMemoryEntry + listSharedMemory round-trip', async () => { const agent = makeAgent(new InMemorySharedMemoryStore()) - await agent.upsertSharedMemoryEntry('a', 'sa', 'ba') - await agent.upsertSharedMemoryEntry('b', 'sb', 'bb') + await agent.upsertSharedMemoryEntry('a', 'ba') + await agent.upsertSharedMemoryEntry('b', 'bb') expect(await agent.listSharedMemory()).toEqual([ - { slug: 'a', summary: 'sa', body: 'ba' }, - { slug: 'b', summary: 'sb', body: 'bb' }, + { slug: 'a', body: 'ba' }, + { slug: 'b', body: 'bb' }, ]) }) it('getSharedMemoryEntry returns null when missing, entry when present', async () => { const agent = makeAgent(new InMemorySharedMemoryStore()) expect(await agent.getSharedMemoryEntry('a')).toBeNull() - await agent.upsertSharedMemoryEntry('a', 'sa', 'ba') - expect(await agent.getSharedMemoryEntry('a')).toEqual({ slug: 'a', summary: 'sa', body: 'ba' }) + await agent.upsertSharedMemoryEntry('a', 'ba') + expect(await agent.getSharedMemoryEntry('a')).toEqual({ slug: 'a', body: 'ba' }) }) it('removeSharedMemoryEntry deletes the entry', async () => { const agent = makeAgent(new InMemorySharedMemoryStore()) - await agent.upsertSharedMemoryEntry('a', 'sa', 'ba') + await agent.upsertSharedMemoryEntry('a', 'ba') await agent.removeSharedMemoryEntry('a') expect(await agent.getSharedMemoryEntry('a')).toBeNull() }) @@ -77,7 +77,7 @@ describe('StelloAgent shared memory SDK', () => { const agent = makeAgent(undefined) await expect(agent.listSharedMemory()).rejects.toThrow(/sharedMemory not configured/) await expect(agent.getSharedMemoryEntry('a')).rejects.toThrow(/sharedMemory not configured/) - await expect(agent.upsertSharedMemoryEntry('a', 's', 'b')).rejects.toThrow(/sharedMemory not configured/) + await expect(agent.upsertSharedMemoryEntry('a', 'b')).rejects.toThrow(/sharedMemory not configured/) await expect(agent.removeSharedMemoryEntry('a')).rejects.toThrow(/sharedMemory not configured/) }) }) diff --git a/packages/core/src/agent/stello-agent.ts b/packages/core/src/agent/stello-agent.ts index 17da511..afefc29 100644 --- a/packages/core/src/agent/stello-agent.ts +++ b/packages/core/src/agent/stello-agent.ts @@ -34,7 +34,7 @@ import type { SessionStorage, ListRecordsOptions, Message, } from '@stello-ai/session'; import type { SharedMemoryEntry, SharedMemoryStore } from '../shared-memory/types'; -import { renderSharedMemoryIndex } from '../shared-memory/render-index'; +import { renderSharedMemoryContext } from '../shared-memory/render-shared-memory'; /** Session 能力相关配置 */ export interface StelloAgentCapabilitiesConfig { @@ -100,15 +100,18 @@ export interface StelloAgentConfig { */ storage?: SessionStorage; /** - * Agent 级共享 memory 存储。 + * Agent 级共享 memory 存储。注入后: + * - SDK 方法 (listSharedMemory / getSharedMemoryEntry / upsertSharedMemoryEntry / + * removeSharedMemoryEntry) 可用 + * - 内置 tool `stello_memory_edit` 可用 + * - 当 agent 走默认 session.sessionLoader 路径时, 全量段每次 + * send 前由内置 adapter 自动渲染并注入到上下文。 * - * 注入后:四个 SDK 方法可用;当 agent 走默认 `session.sessionLoader` 路径时, - * 索引段每次 send 前由内置 adapter 自动渲染并注入到上下文。 + * 未注入:SDK 方法和内置 tool 抛 "sharedMemory not configured"; + * 段不进入上下文。 * - * 未注入:四个 SDK 方法和三个内置 tool 抛 "sharedMemory not configured",索引段不进入上下文。 - * - * 注意:如果调用方提供自定义 `runtime.resolver` 而非 `session.sessionLoader`, - * 自动注入不会发生 —— 调用方需要自行把 `renderSharedMemoryIndex(agent.sharedMemory)` + * 注意:如果调用方提供自定义 runtime.resolver 而非 session.sessionLoader, + * 自动注入不会发生 —— 调用方需要自行把 renderSharedMemoryContext(agent.sharedMemory) * 接入到自己构造的 EngineRuntimeSession 的 send/stream 调用上。 */ sharedMemory?: SharedMemoryStore; @@ -146,7 +149,7 @@ function resolveRuntimeResolver(config: StelloAgentConfig, agent: StelloAgent): // TODO(unified-session-config): 接入 fork 合成链后,compressFn 应来自合成配置而非 sessionDefaults compressFn: config.sessionDefaults?.compressFn, serializeResult: config.session!.serializeSendResult ?? serializeSessionSendResult, - sharedMemoryIndexProvider: () => renderSharedMemoryIndex(agent.sharedMemory), + sharedMemoryContextProvider: () => renderSharedMemoryContext(agent.sharedMemory), }; return { resolve: async (sessionId: string) => { @@ -377,8 +380,8 @@ export class StelloAgent { } /** 写入或覆盖一条共享 memory entry */ - async upsertSharedMemoryEntry(slug: string, summary: string, body: string): Promise { - return this.requireSharedMemory('upsertSharedMemoryEntry').upsert(slug, summary, body); + async upsertSharedMemoryEntry(slug: string, body: string): Promise { + return this.requireSharedMemory('upsertSharedMemoryEntry').upsert(slug, body); } /** 删除一条共享 memory entry;slug 不存在为 no-op */ From 27324f57390b892521c8bb13bf328ee0bdaa259c Mon Sep 17 00:00:00 2001 From: uchouT Date: Fri, 22 May 2026 19:00:05 +0800 Subject: [PATCH 31/40] refactor(core): rename sharedMemoryIndex to sharedMemoryContext in adapter --- .../src/__tests__/shared-memory-e2e.test.ts | 23 ++++++++++--------- packages/core/src/adapters/session-runtime.ts | 18 +++++++-------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/core/src/__tests__/shared-memory-e2e.test.ts b/packages/core/src/__tests__/shared-memory-e2e.test.ts index 363ef63..92af4c6 100644 --- a/packages/core/src/__tests__/shared-memory-e2e.test.ts +++ b/packages/core/src/__tests__/shared-memory-e2e.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import { adaptSessionToEngineRuntime } from '../adapters/session-runtime' import { InMemorySharedMemoryStore } from '../shared-memory/in-memory-shared-memory-store' -import { renderSharedMemoryIndex } from '../shared-memory/render-index' +import { renderSharedMemoryContext } from '../shared-memory/render-shared-memory' import type { SessionCompatible, SessionCompatibleSendOptions, @@ -34,31 +34,32 @@ function makeFakeSession(): { } describe('shared memory end-to-end', () => { - it('adapter injects current index on every send', async () => { + it('adapter injects current context on every send', async () => { const store = new InMemorySharedMemoryStore() const { session, capturedOptions } = makeFakeSession() const runtime = await adaptSessionToEngineRuntime(session, { - sharedMemoryIndexProvider: () => renderSharedMemoryIndex(store), + sharedMemoryContextProvider: () => renderSharedMemoryContext(store), }) - // first send — store empty, no index + // first send — store empty, no context await runtime.send('hi', {}) - expect(capturedOptions[0]!.sharedMemoryIndex).toBeUndefined() + expect(capturedOptions[0]!.sharedMemoryContext).toBeUndefined() // write one entry - await store.upsert('a', 'sa', 'BODY') + await store.upsert('a', 'BODY') - // second send — index present + // second send — context present await runtime.send('hi again', {}) - expect(capturedOptions[1]!.sharedMemoryIndex).toBeDefined() - expect(capturedOptions[1]!.sharedMemoryIndex).toContain('') - expect(capturedOptions[1]!.sharedMemoryIndex).toContain('- a: sa') + expect(capturedOptions[1]!.sharedMemoryContext).toBeDefined() + expect(capturedOptions[1]!.sharedMemoryContext).toContain('') + expect(capturedOptions[1]!.sharedMemoryContext).toContain('## a') + expect(capturedOptions[1]!.sharedMemoryContext).toContain('BODY') // delete the entry await store.remove('a') // third send — back to undefined await runtime.send('hi once more', {}) - expect(capturedOptions[2]!.sharedMemoryIndex).toBeUndefined() + expect(capturedOptions[2]!.sharedMemoryContext).toBeUndefined() }) }) diff --git a/packages/core/src/adapters/session-runtime.ts b/packages/core/src/adapters/session-runtime.ts index 2c3df3e..2fad117 100644 --- a/packages/core/src/adapters/session-runtime.ts +++ b/packages/core/src/adapters/session-runtime.ts @@ -55,7 +55,7 @@ export interface SessionCompatibleSendOptions { /** AbortSignal — abort 时底层 LLM 调用应被取消 */ signal?: AbortSignal; /** Agent 级共享 memory 索引段(已由编排层渲染) */ - sharedMemoryIndex?: string; + sharedMemoryContext?: string; } /** 结构兼容 @stello-ai/session 的 Session */ @@ -89,10 +89,10 @@ export interface SessionRuntimeAdapterOptions { /** 自定义 send() 结果序列化方式,默认转成 JSON 字符串 */ serializeResult?: (result: SessionCompatibleSendResult) => string; /** - * 每次 send/stream 前调用,返回当前 agent 的共享 memory 索引段。 - * 返回 undefined / 空字符串则不注入。adapter 把结果合并进 sendOptions.sharedMemoryIndex。 + * 每次 send/stream 前调用,返回当前 agent 的共享 memory 全量上下文段。 + * 返回 undefined / 空字符串则不注入。adapter 把结果合并进 sendOptions.sharedMemoryContext。 */ - sharedMemoryIndexProvider?: () => Promise; + sharedMemoryContextProvider?: () => Promise; } /** 默认的 Session send() 结果序列化 */ @@ -163,10 +163,10 @@ export async function adaptSessionToEngineRuntime( return turnCount; }, async send(input: string, sendOptions?: SessionCompatibleSendOptions): Promise { - const sharedMemoryIndex = await options.sharedMemoryIndexProvider?.(); + const sharedMemoryContext = await options.sharedMemoryContextProvider?.(); const mergedOptions: SessionCompatibleSendOptions = { ...sendOptions, - ...(sharedMemoryIndex ? { sharedMemoryIndex } : {}), + ...(sharedMemoryContext ? { sharedMemoryContext } : {}), }; const result = await session.send(input, mergedOptions); turnCount += 1; @@ -184,12 +184,12 @@ export async function adaptSessionToEngineRuntime( ...(session.stream ? { stream(input: string, sendOptions?: SessionCompatibleSendOptions) { - const indexPromise = options.sharedMemoryIndexProvider?.() ?? Promise.resolve(undefined); + const contextPromise = options.sharedMemoryContextProvider?.() ?? Promise.resolve(undefined); const source = (async () => { - const sharedMemoryIndex = await indexPromise; + const sharedMemoryContext = await contextPromise; const mergedOptions: SessionCompatibleSendOptions = { ...sendOptions, - ...(sharedMemoryIndex ? { sharedMemoryIndex } : {}), + ...(sharedMemoryContext ? { sharedMemoryContext } : {}), }; return session.stream!(input, mergedOptions); })(); From f37a4ce8175180e18faecf19060395d05fa3b0de Mon Sep 17 00:00:00 2001 From: uchouT Date: Fri, 22 May 2026 19:01:30 +0800 Subject: [PATCH 32/40] refactor(core): update exports for shared memory simplification --- packages/core/src/index.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0674836..c581f7d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -122,16 +122,14 @@ export type { // 共享 memory export { InMemorySharedMemoryStore } from './shared-memory/in-memory-shared-memory-store'; -export { renderSharedMemoryIndex } from './shared-memory/render-index'; +export { renderSharedMemoryContext } from './shared-memory/render-shared-memory'; export type { SharedMemoryEntry, SharedMemoryStore } from './shared-memory/types'; -// 内置 tool 工厂(builtin-tools redesign) +// 内置 tool 工厂 export { createSessionTool, activateSkillTool, - memoryRecallTool, - memoryRememberTool, - memoryForgetTool, + memoryEditTool, } from './builtin-tools'; // 导出 LLM 默认实现 From 8beb7e07e1499858f27054663efab82a8cc8ff75 Mon Sep 17 00:00:00 2001 From: uchouT Date: Fri, 22 May 2026 19:04:56 +0800 Subject: [PATCH 33/40] refactor(session): rename sharedMemoryIndex to sharedMemoryContext --- ....test.ts => shared-memory-context.test.ts} | 23 +++++++++++-------- packages/session/src/context-utils.ts | 8 +++---- packages/session/src/create-session.ts | 14 +++++------ packages/session/src/types/session-api.ts | 4 ++-- 4 files changed, 26 insertions(+), 23 deletions(-) rename packages/session/src/__tests__/{shared-memory-index.test.ts => shared-memory-context.test.ts} (70%) diff --git a/packages/session/src/__tests__/shared-memory-index.test.ts b/packages/session/src/__tests__/shared-memory-context.test.ts similarity index 70% rename from packages/session/src/__tests__/shared-memory-index.test.ts rename to packages/session/src/__tests__/shared-memory-context.test.ts index b62b060..6ef0a45 100644 --- a/packages/session/src/__tests__/shared-memory-index.test.ts +++ b/packages/session/src/__tests__/shared-memory-context.test.ts @@ -10,12 +10,13 @@ function makeLLM(): { adapter: LLMAdapter; lastMessages: () => Message[] } { captured = messages return { content: 'ok' } }, + maxContextTokens: 1_000_000, } return { adapter, lastMessages: () => captured } } -describe('shared memory index injection', () => { - it('inserts shared memory index between systemPrompt and session_identity', async () => { +describe('shared memory context injection', () => { + it('inserts shared memory context between systemPrompt and session_identity', async () => { const storage = new InMemoryStorageAdapter() const { adapter, lastMessages } = makeLLM() const session = await createSession({ @@ -25,11 +26,13 @@ describe('shared memory index injection', () => { llm: adapter, }) await session.setSystemPrompt('SYS') - await session.send('hello', { sharedMemoryIndex: '\n- a: x\n' }) + await session.send('hello', { + sharedMemoryContext: '\n## a\nbody-a\n', + }) const msgs = lastMessages() const sysIdx = msgs.findIndex(m => m.role === 'system' && m.content === 'SYS') - const memIdx = msgs.findIndex(m => m.role === 'system' && m.content.includes('')) + const memIdx = msgs.findIndex(m => m.role === 'system' && m.content.includes('')) const idIdx = msgs.findIndex(m => m.role === 'system' && m.content.includes('')) expect(sysIdx).toBeGreaterThanOrEqual(0) @@ -37,7 +40,7 @@ describe('shared memory index injection', () => { expect(idIdx).toBeGreaterThan(memIdx) }) - it('omits the slot when sharedMemoryIndex is undefined', async () => { + it('omits the slot when sharedMemoryContext is undefined', async () => { const storage = new InMemoryStorageAdapter() const { adapter, lastMessages } = makeLLM() const session = await createSession({ @@ -47,13 +50,13 @@ describe('shared memory index injection', () => { llm: adapter, }) await session.setSystemPrompt('SYS') - await session.send('hello') // no options + await session.send('hello') const msgs = lastMessages() - expect(msgs.find(m => m.content.includes(''))).toBeUndefined() + expect(msgs.find(m => m.content.includes(''))).toBeUndefined() }) - it('omits the slot when sharedMemoryIndex is empty string', async () => { + it('omits the slot when sharedMemoryContext is empty string', async () => { const storage = new InMemoryStorageAdapter() const { adapter, lastMessages } = makeLLM() const session = await createSession({ @@ -62,9 +65,9 @@ describe('shared memory index injection', () => { storage, llm: adapter, }) - await session.send('hi', { sharedMemoryIndex: '' }) + await session.send('hi', { sharedMemoryContext: '' }) const msgs = lastMessages() - expect(msgs.find(m => m.content.includes(''))).toBeUndefined() + expect(msgs.find(m => m.content.includes(''))).toBeUndefined() }) }) diff --git a/packages/session/src/context-utils.ts b/packages/session/src/context-utils.ts index c963185..2064f19 100644 --- a/packages/session/src/context-utils.ts +++ b/packages/session/src/context-utils.ts @@ -175,7 +175,7 @@ export async function assembleSessionContext( userContent: string, compress: CompressContext, label?: string, - sharedMemoryIndex?: string, + sharedMemoryContext?: string, ): Promise { const prefixMessages: Message[] = [] let insightConsumed = false @@ -186,9 +186,9 @@ export async function assembleSessionContext( prefixMessages.push({ role: 'system', content: sysPrompt }) } - // 2. shared memory index (agent-level) - if (sharedMemoryIndex) { - prefixMessages.push({ role: 'system', content: sharedMemoryIndex }) + // 2. shared memory full context (agent-level) + if (sharedMemoryContext) { + prefixMessages.push({ role: 'system', content: sharedMemoryContext }) } // 3. session identity (label) diff --git a/packages/session/src/create-session.ts b/packages/session/src/create-session.ts index 363f196..66e8396 100644 --- a/packages/session/src/create-session.ts +++ b/packages/session/src/create-session.ts @@ -52,7 +52,7 @@ async function assembleSessionReplayContext( sessionId: string, storage: CreateSessionOptions['storage'] | LoadSessionOptions['storage'], label?: string, - sharedMemoryIndex?: string, + sharedMemoryContext?: string, ): Promise<{ messages: Message[]; insightConsumed: boolean }> { const messages: Message[] = [] let insightConsumed = false @@ -62,8 +62,8 @@ async function assembleSessionReplayContext( messages.push({ role: 'system', content: sysPrompt }) } - if (sharedMemoryIndex) { - messages.push({ role: 'system', content: sharedMemoryIndex }) + if (sharedMemoryContext) { + messages.push({ role: 'system', content: sharedMemoryContext }) } messages.push(...buildSessionIdentityMessages(label)) @@ -202,7 +202,7 @@ function buildSession( currentMeta.id, storage, content, { maxContextTokens: options.llm.maxContextTokens, lastPromptTokens, compressFn: resolveCompressFn(), compressionCache }, currentMeta.label, - sendOptions?.sharedMemoryIndex, + sendOptions?.sharedMemoryContext, ) persistAndApplyCompressionCache(assembled.compressionCache) @@ -215,7 +215,7 @@ function buildSession( let recordsToPersist: Message[] = [{ role: 'user', content, timestamp: assembled.userTimestamp }] const toolEnvelope = parseToolResultEnvelope(content) if (toolEnvelope) { - const replayContext = await assembleSessionReplayContext(currentMeta.id, storage, currentMeta.label, sendOptions?.sharedMemoryIndex) + const replayContext = await assembleSessionReplayContext(currentMeta.id, storage, currentMeta.label, sendOptions?.sharedMemoryContext) promptMessages = [ ...replayContext.messages, ...toolEnvelope.toolResults.map((result) => ({ @@ -276,7 +276,7 @@ function buildSession( currentMeta.id, storage, content, { maxContextTokens: options.llm!.maxContextTokens, lastPromptTokens, compressFn: resolveCompressFn(), compressionCache }, currentMeta.label, - sendOptions?.sharedMemoryIndex, + sendOptions?.sharedMemoryContext, ) persistAndApplyCompressionCache(assembled.compressionCache) @@ -289,7 +289,7 @@ function buildSession( let recordsToPersist: Message[] = [{ role: 'user', content, timestamp: assembled.userTimestamp }] const toolEnvelope = parseToolResultEnvelope(content) if (toolEnvelope) { - const replayContext = await assembleSessionReplayContext(currentMeta.id, storage, currentMeta.label, sendOptions?.sharedMemoryIndex) + const replayContext = await assembleSessionReplayContext(currentMeta.id, storage, currentMeta.label, sendOptions?.sharedMemoryContext) promptMessages = [ ...replayContext.messages, ...toolEnvelope.toolResults.map((result) => ({ diff --git a/packages/session/src/types/session-api.ts b/packages/session/src/types/session-api.ts index 708d3b7..0c2ba66 100644 --- a/packages/session/src/types/session-api.ts +++ b/packages/session/src/types/session-api.ts @@ -20,10 +20,10 @@ export interface SessionSendOptions { /** AbortSignal — abort 后中断 LLM 调用并 reject 为 AbortError */ signal?: AbortSignal /** - * Agent 级共享 memory 索引段(已由编排层渲染好)。 + * Agent 级共享 memory 全量内容段(已由编排层渲染好)。 * 非空时插入到 systemPrompt 之后、session_identity 之前;为空 / undefined 时不注入。 */ - sharedMemoryIndex?: string + sharedMemoryContext?: string } /** Session 错误:操作归档中的 Session */ From 58c3140763e71a3ec8f7b3a71e059857c75bfcba Mon Sep 17 00:00:00 2001 From: uchouT Date: Thu, 28 May 2026 03:24:42 +0800 Subject: [PATCH 34/40] Feat/topology render lift (#66) * feat(core): add renderTopologyMarkdown with you-are-here marker * feat(session): add topologyContext to SessionSendOptions and assembleSessionContext * fix(session): also inject topologyContext in tool-result replay path * feat(core): add topologyContextProvider to session adapter * feat(core): topologyContextDecorator and default topology provider in StelloAgent * feat(core): add label option to DefaultFnOptions for compress/consolidate --- .../__tests__/session-runtime.test.ts | 86 ++++++++ packages/core/src/adapters/session-runtime.ts | 13 ++ .../src/agent/__tests__/stello-agent.test.ts | 142 +++++++++++++ packages/core/src/agent/stello-agent.ts | 50 +++++ .../engine/__tests__/topology-render.test.ts | 42 ++++ packages/core/src/engine/topology-render.ts | 20 ++ packages/core/src/index.ts | 1 + .../core/src/llm/__tests__/defaults.test.ts | 50 +++++ packages/core/src/llm/defaults.ts | 20 +- .../src/__tests__/topology-context.test.ts | 192 ++++++++++++++++++ packages/session/src/context-utils.ts | 14 +- packages/session/src/create-session.ts | 11 +- packages/session/src/types/session-api.ts | 7 +- 13 files changed, 638 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/engine/__tests__/topology-render.test.ts create mode 100644 packages/core/src/engine/topology-render.ts create mode 100644 packages/session/src/__tests__/topology-context.test.ts diff --git a/packages/core/src/adapters/__tests__/session-runtime.test.ts b/packages/core/src/adapters/__tests__/session-runtime.test.ts index bb00935..647f014 100644 --- a/packages/core/src/adapters/__tests__/session-runtime.test.ts +++ b/packages/core/src/adapters/__tests__/session-runtime.test.ts @@ -190,6 +190,92 @@ describe('session-runtime adapters', () => { expect(session.stream).toHaveBeenCalledWith('hi', { signal: controller.signal }); }); + describe('topologyContextProvider', () => { + it('calls topologyContextProvider with sessionId and merges result into send options', async () => { + const session = { + meta: { id: 'sX', status: 'active' as const }, + send: vi.fn().mockResolvedValue({ content: 'ok', toolCalls: [] }), + messages: vi.fn().mockResolvedValue([]), + consolidate: vi.fn(), + setTools: vi.fn(), + }; + const provider = vi.fn().mockResolvedValue('T'); + const runtime = await adaptSessionToEngineRuntime(session, { + topologyContextProvider: provider, + }); + await runtime.send('hi'); + expect(provider).toHaveBeenCalledWith('sX'); + expect(session.send).toHaveBeenCalledWith( + 'hi', + expect.objectContaining({ topologyContext: 'T' }), + ); + }); + + it('omits topologyContext when provider returns undefined', async () => { + const session = { + meta: { id: 'sX', status: 'active' as const }, + send: vi.fn().mockResolvedValue({ content: 'ok', toolCalls: [] }), + messages: vi.fn().mockResolvedValue([]), + consolidate: vi.fn(), + setTools: vi.fn(), + }; + const provider = vi.fn().mockResolvedValue(undefined); + const runtime = await adaptSessionToEngineRuntime(session, { + topologyContextProvider: provider, + }); + await runtime.send('hi'); + const [, opts] = session.send.mock.calls[0]; + expect(opts).not.toHaveProperty('topologyContext'); + }); + + it('omits topologyContext when provider returns empty string', async () => { + const session = { + meta: { id: 'sX', status: 'active' as const }, + send: vi.fn().mockResolvedValue({ content: 'ok', toolCalls: [] }), + messages: vi.fn().mockResolvedValue([]), + consolidate: vi.fn(), + setTools: vi.fn(), + }; + const runtime = await adaptSessionToEngineRuntime(session, { + topologyContextProvider: async () => '', + }); + await runtime.send('hi'); + const [, opts] = session.send.mock.calls[0]; + expect(opts).not.toHaveProperty('topologyContext'); + }); + + it('calls topologyContextProvider in stream wrapper and merges into stream options', async () => { + const streamSource = { + result: Promise.resolve({ content: 'ok', toolCalls: [] }), + async *[Symbol.asyncIterator]() { + yield 'a'; + }, + }; + const session = { + meta: { id: 'sX', status: 'active' as const }, + send: vi.fn(), + stream: vi.fn(() => streamSource), + messages: vi.fn().mockResolvedValue([]), + consolidate: vi.fn(), + setTools: vi.fn(), + }; + const provider = vi.fn().mockResolvedValue('S'); + const runtime = await adaptSessionToEngineRuntime(session, { + topologyContextProvider: provider, + }); + const stream = runtime.stream!('hi'); + for await (const _ of stream) { + // drain + } + await stream.result; + expect(provider).toHaveBeenCalledWith('sX'); + expect(session.stream).toHaveBeenCalledWith( + 'hi', + expect.objectContaining({ topologyContext: 'S' }), + ); + }); + }); + it('adapter exposes tools getter and forwards setTools to underlying Session', async () => { const sessionTools: Array<{ name: string; description: string; inputSchema: object }> = [{ name: 'a', description: 'd', inputSchema: {} }]; const setToolsSpy = vi.fn((t) => { diff --git a/packages/core/src/adapters/session-runtime.ts b/packages/core/src/adapters/session-runtime.ts index 2fad117..a104ef1 100644 --- a/packages/core/src/adapters/session-runtime.ts +++ b/packages/core/src/adapters/session-runtime.ts @@ -56,6 +56,8 @@ export interface SessionCompatibleSendOptions { signal?: AbortSignal; /** Agent 级共享 memory 索引段(已由编排层渲染) */ sharedMemoryContext?: string; + /** Per-session topology 上下文段(已由编排层渲染) */ + topologyContext?: string; } /** 结构兼容 @stello-ai/session 的 Session */ @@ -93,6 +95,12 @@ export interface SessionRuntimeAdapterOptions { * 返回 undefined / 空字符串则不注入。adapter 把结果合并进 sendOptions.sharedMemoryContext。 */ sharedMemoryContextProvider?: () => Promise; + /** + * Per-session topology context provider, called before each send/stream with + * the session's own id. Result is merged into sendOptions.topologyContext. + * Returning undefined / empty string omits injection. + */ + topologyContextProvider?: (sessionId: string) => Promise; } /** 默认的 Session send() 结果序列化 */ @@ -164,9 +172,11 @@ export async function adaptSessionToEngineRuntime( }, async send(input: string, sendOptions?: SessionCompatibleSendOptions): Promise { const sharedMemoryContext = await options.sharedMemoryContextProvider?.(); + const topologyContext = await options.topologyContextProvider?.(session.meta.id); const mergedOptions: SessionCompatibleSendOptions = { ...sendOptions, ...(sharedMemoryContext ? { sharedMemoryContext } : {}), + ...(topologyContext ? { topologyContext } : {}), }; const result = await session.send(input, mergedOptions); turnCount += 1; @@ -185,11 +195,14 @@ export async function adaptSessionToEngineRuntime( ? { stream(input: string, sendOptions?: SessionCompatibleSendOptions) { const contextPromise = options.sharedMemoryContextProvider?.() ?? Promise.resolve(undefined); + const topologyPromise = options.topologyContextProvider?.(session.meta.id) ?? Promise.resolve(undefined); const source = (async () => { const sharedMemoryContext = await contextPromise; + const topologyContext = await topologyPromise; const mergedOptions: SessionCompatibleSendOptions = { ...sendOptions, ...(sharedMemoryContext ? { sharedMemoryContext } : {}), + ...(topologyContext ? { topologyContext } : {}), }; return session.stream!(input, mergedOptions); })(); diff --git a/packages/core/src/agent/__tests__/stello-agent.test.ts b/packages/core/src/agent/__tests__/stello-agent.test.ts index 223ece9..2a9ec0e 100644 --- a/packages/core/src/agent/__tests__/stello-agent.test.ts +++ b/packages/core/src/agent/__tests__/stello-agent.test.ts @@ -533,6 +533,148 @@ describe('StelloAgent', () => { }); }); + describe('topologyContextDecorator integration', () => { + /** + * Build a sessionLoader-style agent fixture where: + * - sessions exposes getNode (parent walk) + getTree (subtree) + * - the underlying session.send captures the merged sendOptions + */ + function buildTopologyFixture(opts: { + sessions: Partial; + topologyContextDecorator?: StelloAgentConfig['topologyContextDecorator']; + }) { + const captured: { sendOptions?: Record } = {}; + const session = { + meta: { id: 'child', status: 'active' as const }, + messages: vi.fn().mockResolvedValue([]), + send: vi.fn().mockImplementation(async (_input: string, sendOptions?: Record) => { + captured.sendOptions = sendOptions; + return { content: 'done', toolCalls: [] }; + }), + consolidate: vi.fn().mockResolvedValue(undefined), + setTools: vi.fn(), + }; + + const config: StelloAgentConfig = { + sessions: { + get: vi.fn().mockResolvedValue(rootSession), + archive: vi.fn(), + ...opts.sessions, + } as unknown as SessionTree, + session: { + sessionLoader: vi.fn().mockResolvedValue({ session, config: null }), + }, + capabilities: { + lifecycle: { + bootstrap: vi.fn().mockResolvedValue({ + context: { core: {}, memories: [], currentMemory: null, scope: null }, + session: rootSession, + }), + afterTurn: vi.fn(), + }, + tools: { + getToolDefinitions: vi.fn().mockReturnValue([]), + executeTool: vi.fn().mockResolvedValue({ success: true, data: {} }), + }, + skills: { + get: vi.fn().mockReturnValue(undefined), + register: vi.fn(), + getAll: vi.fn().mockReturnValue([]), + } as unknown as SkillRouter, + confirm: {} as ConfirmProtocol, + }, + }; + if (opts.topologyContextDecorator) { + config.topologyContextDecorator = opts.topologyContextDecorator; + } + const agent = createStelloAgent(config); + return { agent, captured, session }; + } + + // 2-node tree: root -> child. getTree returns the recursive SessionTreeNode + // structure renderTopologyMarkdown consumes. + const twoNodeTree = { + getNode: vi.fn().mockImplementation(async (id: string) => { + if (id === 'child') { + return { id: 'child', parentId: 'root', children: [], refs: [], depth: 1, index: 0, label: 'Child' }; + } + if (id === 'root') { + return { id: 'root', parentId: null, children: ['child'], refs: [], depth: 0, index: 0, label: 'Root' }; + } + return null; + }), + getTree: vi.fn().mockResolvedValue([ + { + id: 'root', + label: 'Root', + status: 'active' as const, + turnCount: 0, + children: [ + { id: 'child', label: 'Child', status: 'active' as const, turnCount: 0, children: [] }, + ], + }, + ]), + }; + + it('applies configured topologyContextDecorator to rendered topology before send', async () => { + const { agent, captured } = buildTopologyFixture({ + sessions: twoNodeTree, + topologyContextDecorator: (raw, ctx) => `S:${ctx.sessionId}\n${raw}`, + }); + + await agent.turn('child', 'hello'); + + expect(captured.sendOptions).toBeDefined(); + const topologyContext = captured.sendOptions?.topologyContext as string | undefined; + expect(topologyContext).toBeDefined(); + expect(topologyContext).toContain('S:child'); + expect(topologyContext).toContain(''); + expect(topologyContext).toContain(''); + expect(topologyContext).toContain('← YOU ARE HERE'); + // root + child both present in the rendered tree + expect(topologyContext).toContain('[sessionId=root]'); + expect(topologyContext).toContain('[sessionId=child]'); + }); + + it('falls back to raw topology when topologyContextDecorator throws', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { agent, captured } = buildTopologyFixture({ + sessions: twoNodeTree, + topologyContextDecorator: () => { + throw new Error('boom'); + }, + }); + + await agent.turn('child', 'hello'); + + const topologyContext = captured.sendOptions?.topologyContext as string | undefined; + expect(topologyContext).toBeDefined(); + expect(topologyContext).toContain(''); + expect(topologyContext).not.toContain(''); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it('omits topologyContext when SessionTree query fails', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { agent, captured } = buildTopologyFixture({ + sessions: { + getNode: vi.fn().mockRejectedValue(new Error('tree gone')), + getTree: vi.fn().mockRejectedValue(new Error('tree gone')), + }, + }); + + await agent.turn('child', 'hello'); + + expect(captured.sendOptions).toBeDefined(); + expect(captured.sendOptions?.topologyContext).toBeUndefined(); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + }); + describe('orchestrator-facing data-IO SDK', () => { function storageMock() { return { diff --git a/packages/core/src/agent/stello-agent.ts b/packages/core/src/agent/stello-agent.ts index afefc29..349f165 100644 --- a/packages/core/src/agent/stello-agent.ts +++ b/packages/core/src/agent/stello-agent.ts @@ -35,6 +35,7 @@ import type { } from '@stello-ai/session'; import type { SharedMemoryEntry, SharedMemoryStore } from '../shared-memory/types'; import { renderSharedMemoryContext } from '../shared-memory/render-shared-memory'; +import { renderTopologyMarkdown } from '../engine/topology-render'; /** Session 能力相关配置 */ export interface StelloAgentCapabilitiesConfig { @@ -121,6 +122,19 @@ export interface StelloAgentConfig { capabilities: StelloAgentCapabilitiesConfig; runtime?: StelloAgentRuntimeConfig; orchestration?: StelloAgentOrchestrationConfig; + /** + * Optional decorator applied to the rendered topology context string before + * it's passed to session.send. Receives `(raw, ctx)` where `raw` is the + * `...` block and `ctx = { sessionId }`. If the + * decorator throws, the raw rendered topology is used and a warning is + * logged. Intended for product layers (e.g. KitKit) to prepend their own + * concepts (like ``) without stello knowing about them. + * + * Only effective when the agent is constructed via `session.sessionLoader` + * (default adapter path). Callers that provide their own `runtime.resolver` + * must wire `topologyContextProvider` themselves on their adapter. + */ + topologyContextDecorator?: (raw: string, ctx: { sessionId: string }) => string; } /** 单 Session 的外部数据视图(memory + insight 聚合) */ @@ -150,6 +164,42 @@ function resolveRuntimeResolver(config: StelloAgentConfig, agent: StelloAgent): compressFn: config.sessionDefaults?.compressFn, serializeResult: config.session!.serializeSendResult ?? serializeSessionSendResult, sharedMemoryContextProvider: () => renderSharedMemoryContext(agent.sharedMemory), + topologyContextProvider: async (sessionId: string): Promise => { + let rootNode: SessionTreeNode | undefined; + try { + // Walk parentId chain to find the root id this session belongs to. + let cursor = await config.sessions.getNode(sessionId); + if (!cursor) return undefined; + while (cursor.parentId) { + const parent = await config.sessions.getNode(cursor.parentId); + if (!parent) break; + cursor = parent; + } + const rootId = cursor.id; + // Fetch the full forest and pick the matching root subtree. + const forest = await config.sessions.getTree(); + rootNode = forest.find((n) => n.id === rootId); + if (!rootNode) return undefined; + } catch (err) { + console.warn( + `[stello] topologyContextProvider: failed to load tree for ${sessionId}; skipping`, + err, + ); + return undefined; + } + const markdown = renderTopologyMarkdown(rootNode, sessionId); + const raw = `\n${markdown}\n`; + if (!config.topologyContextDecorator) return raw; + try { + return config.topologyContextDecorator(raw, { sessionId }); + } catch (err) { + console.warn( + `[stello] topologyContextDecorator threw; falling back to raw topology`, + err, + ); + return raw; + } + }, }; return { resolve: async (sessionId: string) => { diff --git a/packages/core/src/engine/__tests__/topology-render.test.ts b/packages/core/src/engine/__tests__/topology-render.test.ts new file mode 100644 index 0000000..b6ad5f0 --- /dev/null +++ b/packages/core/src/engine/__tests__/topology-render.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import type { SessionTreeNode } from '../../types/session.js'; +import { renderTopologyMarkdown } from '../topology-render.js'; + +function node(id: string, label: string, children: SessionTreeNode[] = [], status: 'active' | 'archived' = 'active'): SessionTreeNode { + return { id, label, parentId: null, status, children } as SessionTreeNode; +} + +describe('renderTopologyMarkdown', () => { + it('renders a single root node', () => { + const root = node('r1', 'Root'); + expect(renderTopologyMarkdown(root)).toBe('- [sessionId=r1] Root'); + }); + + it('renders nested children with indentation', () => { + const root = node('r1', 'Root', [node('c1', 'Child', [node('g1', 'Grand')])]); + expect(renderTopologyMarkdown(root)).toBe( + '- [sessionId=r1] Root\n - [sessionId=c1] Child\n - [sessionId=g1] Grand', + ); + }); + + it('marks archived nodes', () => { + const root = node('r1', 'Root', [node('c1', 'Old', [], 'archived')]); + expect(renderTopologyMarkdown(root)).toBe( + '- [sessionId=r1] Root\n - [sessionId=c1] Old (archived)', + ); + }); + + it('appends YOU ARE HERE marker when currentSessionId matches a node', () => { + const root = node('r1', 'Root', [node('c1', 'Child')]); + expect(renderTopologyMarkdown(root, 'c1')).toBe( + '- [sessionId=r1] Root\n - [sessionId=c1] Child ← YOU ARE HERE', + ); + }); + + it('does not mark when currentSessionId does not match any node', () => { + const root = node('r1', 'Root', [node('c1', 'Child')]); + expect(renderTopologyMarkdown(root, 'absent')).toBe( + '- [sessionId=r1] Root\n - [sessionId=c1] Child', + ); + }); +}); diff --git a/packages/core/src/engine/topology-render.ts b/packages/core/src/engine/topology-render.ts new file mode 100644 index 0000000..5d42634 --- /dev/null +++ b/packages/core/src/engine/topology-render.ts @@ -0,0 +1,20 @@ +import type { SessionTreeNode } from '../types/session.js'; + +/** + * Render a SessionTree subtree as a markdown bullet list. + * + * Each node is rendered as `- [sessionId={id}] {label}`. Archived nodes get + * a ` (archived)` suffix. If `currentSessionId` matches a node, that node + * gets a ` ← YOU ARE HERE` suffix so the LLM can self-locate. + */ +export function renderTopologyMarkdown(root: SessionTreeNode, currentSessionId?: string): string { + const lines: string[] = []; + function walk(node: SessionTreeNode, indent: string): void { + const archived = node.status === 'archived' ? ' (archived)' : ''; + const here = node.id === currentSessionId ? ' ← YOU ARE HERE' : ''; + lines.push(`${indent}- [sessionId=${node.id}] ${node.label}${archived}${here}`); + for (const child of node.children) walk(child, indent + ' '); + } + walk(root, ''); + return lines.join('\n'); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c581f7d..80de8bf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -70,6 +70,7 @@ export { ForkProfileRegistryImpl } from './engine/fork-profile'; export type { ForkProfile, ForkProfileRegistry } from './engine/fork-profile'; export { ToolRegistryImpl, buildSessionToolList } from './tool/tool-registry'; export type { ToolRegistry, ToolRegistryEntry } from './tool/tool-registry'; +export { renderTopologyMarkdown } from './engine/topology-render'; export { TurnRunner } from './engine/turn-runner'; export type { ToolCall, diff --git a/packages/core/src/llm/__tests__/defaults.test.ts b/packages/core/src/llm/__tests__/defaults.test.ts index 39cc7dd..654b61e 100644 --- a/packages/core/src/llm/__tests__/defaults.test.ts +++ b/packages/core/src/llm/__tests__/defaults.test.ts @@ -72,6 +72,56 @@ describe('createDefaultCompressFn', () => { }) }) +describe('label option in DefaultFnOptions', () => { + it('prepends [session: {label}] to compress user prompt when label set', async () => { + let captured: any[] = []; + const llm = async (msgs: any[]) => { captured = msgs; return 'summary'; }; + const fn = createDefaultCompressFn('PROMPT', llm, { label: 'Alpha' }); + await fn([{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' }]); + const userMsg = captured.find(m => m.role === 'user'); + expect(userMsg.content.startsWith('[session: Alpha]\n\n')).toBe(true); + expect(userMsg.content).toContain('对话记录:'); + }); + + it('omits prefix when label undefined', async () => { + let captured: any[] = []; + const llm = async (msgs: any[]) => { captured = msgs; return 'summary'; }; + const fn = createDefaultCompressFn('PROMPT', llm); + await fn([{ role: 'user', content: 'hi' }]); + const userMsg = captured.find(m => m.role === 'user'); + expect(userMsg.content.startsWith('[session:')).toBe(false); + }); + + it('prepends [session: {label}] to consolidate user prompt before "当前摘要"', async () => { + let captured: any[] = []; + const llm = async (msgs: any[]) => { captured = msgs; return 'new memory'; }; + const fn = createDefaultConsolidateFn('PROMPT', llm, { label: 'Beta' }); + await fn('old memory', [{ role: 'user', content: 'x' }]); + const userMsg = captured.find(m => m.role === 'user'); + expect(userMsg.content.startsWith('[session: Beta]\n\n当前摘要:')).toBe(true); + }); + + it('omits prefix in consolidate when label undefined', async () => { + let captured: any[] = []; + const llm = async (msgs: any[]) => { captured = msgs; return 'new'; }; + const fn = createDefaultConsolidateFn('PROMPT', llm); + await fn(null, [{ role: 'user', content: 'x' }]); + const userMsg = captured.find(m => m.role === 'user'); + expect(userMsg.content.startsWith('[session:')).toBe(false); + }); + + it('coexists with roleContext (both injected)', async () => { + let captured: any[] = []; + const llm = async (msgs: any[]) => { captured = msgs; return 's'; }; + const fn = createDefaultCompressFn('PROMPT', llm, { label: 'L', roleContext: 'RC' }); + await fn([{ role: 'user', content: 'x' }]); + const systemContents = captured.filter(m => m.role === 'system').map(m => m.content); + expect(systemContents.some(c => c.includes('\nRC\n'))).toBe(true); + const userMsg = captured.find(m => m.role === 'user'); + expect(userMsg.content.startsWith('[session: L]')).toBe(true); + }); +}); + describe('llmCallFnFromAdapter', () => { it('forwards messages to adapter.complete and returns content', async () => { const adapter = { diff --git a/packages/core/src/llm/defaults.ts b/packages/core/src/llm/defaults.ts index 38bdab4..f33130a 100644 --- a/packages/core/src/llm/defaults.ts +++ b/packages/core/src/llm/defaults.ts @@ -36,12 +36,13 @@ export const DEFAULT_CONSOLIDATE_PROMPT = `你是对话摘要助手。请将对 * 默认 fn 的可选参数。 * * - `roleContext`:被处理会话的角色 system prompt。非空字符串时会作为 - * 独立的 `...` system 消息插入到任务 prompt - * 之后、user content 之前,让摘要/压缩/整合的 LLM 感知被处理会话的角色。 - * 空字符串或 undefined 视为未传,不注入。 + * 独立的 `...` system 消息插入。 + * - `label`:被处理会话的 label。非空字符串时会作为 `[session: {label}]` + * 前缀注入到 user prompt 头部,让摘要/压缩/整合的 LLM 明确知道处理的是哪个 session。 */ export interface DefaultFnOptions { roleContext?: string + label?: string } /** 若 roleContext 非空,返回一条 role_context system 消息;否则返回空数组 */ @@ -53,6 +54,13 @@ function roleContextMessages( return [{ role: 'system', content: `\n${ctx}\n` }] } +/** 若 label 非空,返回 `[session: {label}]\n\n` 前缀;否则返回空字符串 */ +function labelPrefix(options: DefaultFnOptions | undefined): string { + const label = options?.label + if (!label) return '' + return `[session: ${label}]\n\n` +} + /** 根据 prompt 创建默认 consolidateFn:prompt + L3 历史 → L2 */ export function createDefaultConsolidateFn( prompt: string, @@ -67,10 +75,11 @@ export function createDefaultConsolidateFn( parts.push( `对话记录:\n${messages.map((m) => `${m.role}: ${m.content}`).join('\n')}`, ) + const userContent = labelPrefix(options) + parts.join('\n\n') const raw = await llm([ { role: 'system', content: prompt }, ...roleContextMessages(options), - { role: 'user', content: parts.join('\n\n') }, + { role: 'user', content: userContent }, ]) /* 清除 标签,只保留正文 */ return raw.replace(/[\s\S]*?<\/think>\s*/g, '').trim() @@ -102,10 +111,11 @@ export function createDefaultCompressFn( ): SessionCompatibleCompressFn { return async (messages) => { const content = messages.map((m) => `${m.role}: ${m.content}`).join('\n') + const userContent = labelPrefix(options) + `对话记录:\n${content}` const raw = await llm([ { role: 'system', content: prompt }, ...roleContextMessages(options), - { role: 'user', content: `对话记录:\n${content}` }, + { role: 'user', content: userContent }, ]) return raw.replace(/[\s\S]*?<\/think>\s*/g, '').trim() } diff --git a/packages/session/src/__tests__/topology-context.test.ts b/packages/session/src/__tests__/topology-context.test.ts new file mode 100644 index 0000000..fbd8e2b --- /dev/null +++ b/packages/session/src/__tests__/topology-context.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from 'vitest' +import { assembleSessionContext } from '../context-utils.js' +import { createSession } from '../create-session' +import { InMemoryStorageAdapter } from '../mocks/in-memory-storage' +import type { SessionStorage } from '../types/storage.js' +import type { LLMAdapter, Message } from '../types/llm' + +function makeStorage(overrides: Partial = {}): SessionStorage { + return { + getSystemPrompt: async () => 'SP', + getInsight: async () => null, + listRecords: async () => [], + putRecords: async () => {}, + putSystemPrompt: async () => {}, + putInsight: async () => {}, + clearInsight: async () => {}, + getMemory: async () => null, + putMemory: async () => {}, + ...overrides, + } as unknown as SessionStorage +} + +describe('assembleSessionContext with topologyContext', () => { + it('injects topologyContext as a system message after sharedMemoryContext', async () => { + const storage = makeStorage() + const result = await assembleSessionContext( + 's1', + storage, + 'hello', + { compressFn: async () => '', maxContextTokens: 100000, compressionCache: null, lastPromptTokens: null }, + undefined, + 'SHARED', + 'TOP', + ) + const systemContents = result.messages.filter(m => m.role === 'system').map(m => m.content) + expect(systemContents).toContain('TOP') + const idxShared = systemContents.indexOf('SHARED') + const idxTopo = systemContents.indexOf('TOP') + expect(idxShared).toBeGreaterThanOrEqual(0) + expect(idxTopo).toBeGreaterThan(idxShared) + }) + + it('skips topologyContext system message when undefined', async () => { + const storage = makeStorage() + const result = await assembleSessionContext( + 's1', + storage, + 'hello', + { compressFn: async () => '', maxContextTokens: 100000, compressionCache: null, lastPromptTokens: null }, + ) + const contents = result.messages.filter(m => m.role === 'system').map(m => m.content) + expect(contents.every(c => !c.includes(''))).toBe(true) + }) + + it('skips topologyContext system message when empty string', async () => { + const storage = makeStorage() + const result = await assembleSessionContext( + 's1', storage, 'hello', + { compressFn: async () => '', maxContextTokens: 100000, compressionCache: null, lastPromptTokens: null }, + undefined, undefined, '', + ) + const contents = result.messages.filter(m => m.role === 'system').map(m => m.content) + expect(contents.every(c => !c.includes(''))).toBe(true) + }) +}) + +function makeCapturingLLM(responses: Array<{ content: string; toolCalls?: Array<{ id: string; name: string; input: Record }> }>): { adapter: LLMAdapter; calls: () => Message[][] } { + const captured: Message[][] = [] + let i = 0 + const adapter: LLMAdapter = { + async complete(messages) { + captured.push(messages.map((m) => ({ ...m }))) + const r = responses[i++] ?? { content: 'ok' } + return { content: r.content, toolCalls: r.toolCalls } + }, + maxContextTokens: 1_000_000, + } + return { adapter, calls: () => captured } +} + +describe('topologyContext in tool-result replay path', () => { + it('injects topologyContext as a system message in the replay continuation', async () => { + const { adapter, calls } = makeCapturingLLM([ + { content: '', toolCalls: [{ id: 'tc_1', name: 'search', input: { q: 'x' } }] }, + { content: 'final' }, + ]) + const storage = new InMemoryStorageAdapter() + const session = await createSession({ + id: 's1', + label: 'child', + storage, + llm: adapter, + }) + await session.setSystemPrompt('SYS') + + // 1st turn: user message + assistant tool call + await session.send('do search', { + sharedMemoryContext: 'mem', + topologyContext: 'TOP', + }) + + // 2nd turn: tool-result envelope — triggers replay path + await session.send(JSON.stringify({ + toolResults: [{ + toolCallId: 'tc_1', + toolName: 'search', + args: { q: 'x' }, + success: true, + data: { hits: 1 }, + error: null, + }], + }), { + sharedMemoryContext: 'mem', + topologyContext: 'TOP', + }) + + const replayCall = calls()[1]! + const systemContents = replayCall.filter(m => m.role === 'system').map(m => m.content) + + // topologyContext must appear + expect(systemContents).toContain('TOP') + + // Slot order: sysPrompt → sharedMemory → topology → session_identity + const idxSys = systemContents.indexOf('SYS') + const idxShared = systemContents.findIndex(c => c.includes('')) + const idxTopo = systemContents.indexOf('TOP') + const idxIdent = systemContents.findIndex(c => c.includes('')) + + expect(idxSys).toBeGreaterThanOrEqual(0) + expect(idxShared).toBeGreaterThan(idxSys) + expect(idxTopo).toBeGreaterThan(idxShared) + expect(idxIdent).toBeGreaterThan(idxTopo) + }) + + it('omits the topology slot in replay when topologyContext is undefined', async () => { + const { adapter, calls } = makeCapturingLLM([ + { content: '', toolCalls: [{ id: 'tc_1', name: 'search', input: {} }] }, + { content: 'final' }, + ]) + const storage = new InMemoryStorageAdapter() + const session = await createSession({ + id: 's1', + label: 'child', + storage, + llm: adapter, + }) + + await session.send('do search') + await session.send(JSON.stringify({ + toolResults: [{ + toolCallId: 'tc_1', + toolName: 'search', + args: {}, + success: true, + data: null, + error: null, + }], + })) + + const replayCall = calls()[1]! + expect(replayCall.find(m => m.role === 'system' && m.content.includes(''))).toBeUndefined() + }) + + it('omits the topology slot in replay when topologyContext is empty string', async () => { + const { adapter, calls } = makeCapturingLLM([ + { content: '', toolCalls: [{ id: 'tc_1', name: 'search', input: {} }] }, + { content: 'final' }, + ]) + const storage = new InMemoryStorageAdapter() + const session = await createSession({ + id: 's1', + label: 'child', + storage, + llm: adapter, + }) + + await session.send('do search', { topologyContext: '' }) + await session.send(JSON.stringify({ + toolResults: [{ + toolCallId: 'tc_1', + toolName: 'search', + args: {}, + success: true, + data: null, + error: null, + }], + }), { topologyContext: '' }) + + const replayCall = calls()[1]! + expect(replayCall.find(m => m.role === 'system' && m.content.includes(''))).toBeUndefined() + }) +}) diff --git a/packages/session/src/context-utils.ts b/packages/session/src/context-utils.ts index 2064f19..b7eb86b 100644 --- a/packages/session/src/context-utils.ts +++ b/packages/session/src/context-utils.ts @@ -168,6 +168,10 @@ export function buildSessionIdentityMessages(label: string | undefined): Message * * 若传入 `label`(非空)则在 systemPrompt 之后插入 `` 系统消息, * 让子 session 感知自己的身份标签。 + * + * 注入顺序:systemPrompt → sharedMemoryContext → topologyContext → session_identity → insight → history → user。 + * sharedMemoryContext / topologyContext 由编排层渲染好后通过 SessionSendOptions 传入; + * 为空 / undefined 时对应槽位不注入。 */ export async function assembleSessionContext( sessionId: string, @@ -176,6 +180,7 @@ export async function assembleSessionContext( compress: CompressContext, label?: string, sharedMemoryContext?: string, + topologyContext?: string, ): Promise { const prefixMessages: Message[] = [] let insightConsumed = false @@ -191,10 +196,15 @@ export async function assembleSessionContext( prefixMessages.push({ role: 'system', content: sharedMemoryContext }) } - // 3. session identity (label) + // 3. topology context (agent-level, externally rendered with you-are-here marker) + if (topologyContext) { + prefixMessages.push({ role: 'system', content: topologyContext }) + } + + // 4. session identity (label) prefixMessages.push(...buildSessionIdentityMessages(label)) - // 4. insight + // 5. insight const insightContent = await storage.getInsight(sessionId) if (insightContent) { prefixMessages.push({ role: 'system', content: insightContent }) diff --git a/packages/session/src/create-session.ts b/packages/session/src/create-session.ts index 66e8396..fb4d2ea 100644 --- a/packages/session/src/create-session.ts +++ b/packages/session/src/create-session.ts @@ -53,6 +53,7 @@ async function assembleSessionReplayContext( storage: CreateSessionOptions['storage'] | LoadSessionOptions['storage'], label?: string, sharedMemoryContext?: string, + topologyContext?: string, ): Promise<{ messages: Message[]; insightConsumed: boolean }> { const messages: Message[] = [] let insightConsumed = false @@ -66,6 +67,10 @@ async function assembleSessionReplayContext( messages.push({ role: 'system', content: sharedMemoryContext }) } + if (topologyContext) { + messages.push({ role: 'system', content: topologyContext }) + } + messages.push(...buildSessionIdentityMessages(label)) const insightContent = await storage.getInsight(sessionId) @@ -203,6 +208,7 @@ function buildSession( { maxContextTokens: options.llm.maxContextTokens, lastPromptTokens, compressFn: resolveCompressFn(), compressionCache }, currentMeta.label, sendOptions?.sharedMemoryContext, + sendOptions?.topologyContext, ) persistAndApplyCompressionCache(assembled.compressionCache) @@ -215,7 +221,7 @@ function buildSession( let recordsToPersist: Message[] = [{ role: 'user', content, timestamp: assembled.userTimestamp }] const toolEnvelope = parseToolResultEnvelope(content) if (toolEnvelope) { - const replayContext = await assembleSessionReplayContext(currentMeta.id, storage, currentMeta.label, sendOptions?.sharedMemoryContext) + const replayContext = await assembleSessionReplayContext(currentMeta.id, storage, currentMeta.label, sendOptions?.sharedMemoryContext, sendOptions?.topologyContext) promptMessages = [ ...replayContext.messages, ...toolEnvelope.toolResults.map((result) => ({ @@ -277,6 +283,7 @@ function buildSession( { maxContextTokens: options.llm!.maxContextTokens, lastPromptTokens, compressFn: resolveCompressFn(), compressionCache }, currentMeta.label, sendOptions?.sharedMemoryContext, + sendOptions?.topologyContext, ) persistAndApplyCompressionCache(assembled.compressionCache) @@ -289,7 +296,7 @@ function buildSession( let recordsToPersist: Message[] = [{ role: 'user', content, timestamp: assembled.userTimestamp }] const toolEnvelope = parseToolResultEnvelope(content) if (toolEnvelope) { - const replayContext = await assembleSessionReplayContext(currentMeta.id, storage, currentMeta.label, sendOptions?.sharedMemoryContext) + const replayContext = await assembleSessionReplayContext(currentMeta.id, storage, currentMeta.label, sendOptions?.sharedMemoryContext, sendOptions?.topologyContext) promptMessages = [ ...replayContext.messages, ...toolEnvelope.toolResults.map((result) => ({ diff --git a/packages/session/src/types/session-api.ts b/packages/session/src/types/session-api.ts index 0c2ba66..7713b2a 100644 --- a/packages/session/src/types/session-api.ts +++ b/packages/session/src/types/session-api.ts @@ -21,9 +21,14 @@ export interface SessionSendOptions { signal?: AbortSignal /** * Agent 级共享 memory 全量内容段(已由编排层渲染好)。 - * 非空时插入到 systemPrompt 之后、session_identity 之前;为空 / undefined 时不注入。 + * 非空时插入到 systemPrompt 之后、topology/identity 之前;为空 / undefined 时不注入。 */ sharedMemoryContext?: string + /** + * Agent 级 topology 上下文段(已由编排层渲染好,含 you-are-here 标记和可选 decorator 前缀)。 + * 非空时插入到 sharedMemoryContext 之后、session_identity 之前;为空 / undefined 时不注入。 + */ + topologyContext?: string } /** Session 错误:操作归档中的 Session */ From 555dd60dc67b25336d509acb36960ebc3582f937 Mon Sep 17 00:00:00 2001 From: "wangziming.ec3o" Date: Thu, 28 May 2026 18:17:38 +0800 Subject: [PATCH 35/40] =?UTF-8?q?feat(session):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=8E=A8=E7=90=86=E6=A8=A1=E5=9E=8B=20reasoning=5Fcontent=20?= =?UTF-8?q?=E5=A4=9A=E8=BD=AE=E5=AF=B9=E8=AF=9D=E5=9B=9E=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stepFun/DeepSeek 等推理模型在 tool call 场景的多轮对话中, 要求历史 assistant 消息必须包含原始的 reasoning_content, 否则 API 返回 400。 - Message/LLMResult/LLMChunk 增加 reasoningContent 字段 - OpenAI-compatible adapter 提取和回传 reasoning_content - Session 层持久化 reasoningContent 到 assistant 记录 - 新增 3 个 adapter 测试覆盖 round-trip Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/__tests__/openai-compatible.test.ts | 60 +++++++++++++++++++ .../session/src/adapters/openai-compatible.ts | 18 +++++- packages/session/src/create-session.ts | 12 +++- packages/session/src/types/functions.ts | 2 + packages/session/src/types/llm.ts | 6 ++ 5 files changed, 95 insertions(+), 3 deletions(-) diff --git a/packages/session/src/__tests__/openai-compatible.test.ts b/packages/session/src/__tests__/openai-compatible.test.ts index dfb29c5..4355a38 100644 --- a/packages/session/src/__tests__/openai-compatible.test.ts +++ b/packages/session/src/__tests__/openai-compatible.test.ts @@ -106,6 +106,66 @@ describe('createOpenAICompatibleAdapter', () => { ) }) + it('complete() 提取响应中的 reasoning_content', async () => { + createCompletion.mockResolvedValueOnce({ + choices: [{ message: { content: 'answer', reasoning_content: 'thinking...' } }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + }) + + const adapter = createOpenAICompatibleAdapter({ + apiKey: 'test-key', + baseURL: 'https://api.example.com/v1', + model: 'test-model', + maxContextTokens: 128_000, + }) + + const result = await adapter.complete([{ role: 'user', content: 'hello' }]) + expect(result.reasoningContent).toBe('thinking...') + expect(result.content).toBe('answer') + }) + + it('assistant 消息的 reasoningContent 回传为 reasoning_content', async () => { + const adapter = createOpenAICompatibleAdapter({ + apiKey: 'test-key', + baseURL: 'https://api.example.com/v1', + model: 'test-model', + maxContextTokens: 128_000, + }) + + const messages: Message[] = [ + { role: 'user', content: 'hello' }, + { + role: 'assistant', + content: 'let me think', + reasoningContent: 'step 1: ...', + toolCalls: [{ id: 'tc_1', name: 'foo', input: {} }], + }, + { role: 'tool', content: 'result', toolCallId: 'tc_1' }, + ] + + await adapter.complete(messages) + + const sentMessages = createCompletion.mock.calls[0]![0].messages + expect(sentMessages[1]).toMatchObject({ + role: 'assistant', + content: 'let me think', + reasoning_content: 'step 1: ...', + tool_calls: [{ id: 'tc_1', type: 'function', function: { name: 'foo', arguments: '{}' } }], + }) + }) + + it('非推理模型响应不含 reasoningContent', async () => { + const adapter = createOpenAICompatibleAdapter({ + apiKey: 'test-key', + baseURL: 'https://api.example.com/v1', + model: 'test-model', + maxContextTokens: 128_000, + }) + + const result = await adapter.complete([{ role: 'user', content: 'hello' }]) + expect(result.reasoningContent).toBeUndefined() + }) + it('signal 透传到 SDK request options', async () => { const adapter = createOpenAICompatibleAdapter({ apiKey: 'test-key', diff --git a/packages/session/src/adapters/openai-compatible.ts b/packages/session/src/adapters/openai-compatible.ts index 42d4f27..22ad1ee 100644 --- a/packages/session/src/adapters/openai-compatible.ts +++ b/packages/session/src/adapters/openai-compatible.ts @@ -71,6 +71,9 @@ export function createOpenAICompatibleAdapter(options: OpenAICompatibleOptions): role: m.role as 'system' | 'user' | 'assistant' | 'tool', content: m.content, ...(m.role === 'tool' && m.toolCallId ? { tool_call_id: m.toolCallId } : {}), + ...(m.role === 'assistant' && m.reasoningContent + ? { reasoning_content: m.reasoningContent } + : {}), ...(m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0 ? { tool_calls: m.toolCalls.map((toolCall) => ({ @@ -100,9 +103,15 @@ export function createOpenAICompatibleAdapter(options: OpenAICompatibleOptions): ) as ChatCompletion const choice = response.choices[0] + // 提取推理模型的思考内容(stepFun/DeepSeek 等使用 reasoning_content 字段) + const rawMessage = choice?.message as Record | undefined + const reasoningContent = typeof rawMessage?.reasoning_content === 'string' + ? rawMessage.reasoning_content + : null return { content: choice?.message?.content ?? null, + ...(reasoningContent ? { reasoningContent } : {}), toolCalls: (choice?.message?.tool_calls ?? []).flatMap((call) => { if (!('function' in call) || !call.function) return [] return [{ @@ -131,14 +140,19 @@ export function createOpenAICompatibleAdapter(options: OpenAICompatibleOptions): for await (const chunk of stream) { const delta = chunk.choices[0]?.delta?.content ?? '' + // 提取推理模型的思考内容增量(stepFun/DeepSeek 等使用 reasoning_content 字段) + const rawDelta = chunk.choices[0]?.delta as Record | undefined + const reasoningDelta = typeof rawDelta?.reasoning_content === 'string' + ? rawDelta.reasoning_content + : undefined const toolCallDeltas = (chunk.choices[0]?.delta?.tool_calls ?? []).map((call: ChatToolCallDelta) => ({ index: call.index ?? 0, id: call.id, name: call.function?.name, input: call.function?.arguments, })) - if (delta || toolCallDeltas.length > 0) { - yield { delta, toolCallDeltas } + if (delta || reasoningDelta || toolCallDeltas.length > 0) { + yield { delta, ...(reasoningDelta ? { reasoningDelta } : {}), toolCallDeltas } } } }, diff --git a/packages/session/src/create-session.ts b/packages/session/src/create-session.ts index 66e8396..4f705d2 100644 --- a/packages/session/src/create-session.ts +++ b/packages/session/src/create-session.ts @@ -244,6 +244,7 @@ function buildSession( const assistantRecord: Message = { role: 'assistant', content: result.content ?? '', + ...(result.reasoningContent ? { reasoningContent: result.reasoningContent } : {}), ...(result.toolCalls && result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {}), timestamp: new Date().toISOString(), } @@ -254,6 +255,7 @@ function buildSession( return { content: result.content, + reasoningContent: result.reasoningContent, toolCalls: result.toolCalls, usage: result.usage, } @@ -314,11 +316,13 @@ function buildSession( let result: SendResult if (options.llm.stream) { let accumulated = '' + let accumulatedReasoning = '' const toolCallsByIndex = new Map() // adapter 在 abort 时抛 AbortError,这里直接向上传播给 result promise; // 下方 L3 写入分支不会执行(policy: drop entirely),与非流式 send() 对称。 for await (const chunk of options.llm.stream(promptMessages, { tools, signal: sendOptions?.signal })) { accumulated += chunk.delta + if (chunk.reasoningDelta) accumulatedReasoning += chunk.reasoningDelta push(chunk.delta) for (const delta of chunk.toolCallDeltas ?? []) { const current = toolCallsByIndex.get(delta.index) ?? { input: '' } @@ -333,7 +337,11 @@ function buildSession( name: call.name ?? 'unknown_tool', input: call.input ? JSON.parse(call.input) as Record : {}, })) - result = { content: accumulated, toolCalls } + result = { + content: accumulated, + ...(accumulatedReasoning ? { reasoningContent: accumulatedReasoning } : {}), + toolCalls, + } } else { result = await options.llm.complete(promptMessages, { tools, signal: sendOptions?.signal }) if (result.content) { @@ -344,6 +352,7 @@ function buildSession( const assistantRecord: Message = { role: 'assistant', content: result.content ?? '', + ...(result.reasoningContent ? { reasoningContent: result.reasoningContent } : {}), ...(result.toolCalls && result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {}), timestamp: new Date().toISOString(), } @@ -359,6 +368,7 @@ function buildSession( return { content: result.content, + reasoningContent: result.reasoningContent, toolCalls: result.toolCalls, usage: result.usage, } diff --git a/packages/session/src/types/functions.ts b/packages/session/src/types/functions.ts index 3d538c2..f53d94f 100644 --- a/packages/session/src/types/functions.ts +++ b/packages/session/src/types/functions.ts @@ -49,6 +49,8 @@ export interface LoadSessionOptions { export interface SendResult { /** LLM 文本响应 */ content: string | null + /** 推理模型的思考内容,多轮对话时需回传给 API */ + reasoningContent?: string | null /** LLM 返回的工具调用(由上层决定是否执行) */ toolCalls?: ToolCall[] /** token 用量统计 */ diff --git a/packages/session/src/types/llm.ts b/packages/session/src/types/llm.ts index a2d9f87..0449182 100644 --- a/packages/session/src/types/llm.ts +++ b/packages/session/src/types/llm.ts @@ -2,6 +2,8 @@ export interface Message { role: 'system' | 'user' | 'assistant' | 'tool' content: string + /** 推理模型的思考内容(stepFun/DeepSeek 等),仅 role=assistant 时有效 */ + reasoningContent?: string /** assistant 发起的工具调用列表,仅 role=assistant 时有效 */ toolCalls?: ToolCall[] /** 关联的工具调用 ID,仅 role=tool 时有效 */ @@ -35,6 +37,8 @@ export interface LLMCompleteOptions { /** LLM 完成后的返回结果 */ export interface LLMResult { content: string | null + /** 推理模型的思考内容,多轮对话时需回传给 API */ + reasoningContent?: string | null toolCalls?: ToolCall[] usage?: { promptTokens: number @@ -46,6 +50,8 @@ export interface LLMResult { export interface LLMChunk { /** 文本增量片段 */ delta: string + /** 推理内容增量片段(stepFun/DeepSeek 等推理模型) */ + reasoningDelta?: string /** 工具调用增量片段(用于流式拼接 tool call) */ toolCallDeltas?: Array<{ index: number From d4d215fd5b8102a31fcca096a1b932f48bfbee70 Mon Sep 17 00:00:00 2001 From: "wangziming.ec3o" Date: Sat, 30 May 2026 01:53:16 +0800 Subject: [PATCH 36/40] feat(session): attach turn metadata --- packages/session/src/create-session.ts | 20 ++++++++++++++++---- packages/session/src/types/llm.ts | 2 ++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/session/src/create-session.ts b/packages/session/src/create-session.ts index bbb75e7..0fd5a55 100644 --- a/packages/session/src/create-session.ts +++ b/packages/session/src/create-session.ts @@ -37,6 +37,18 @@ function parseToolResultEnvelope(content: string): ToolResultEnvelope | null { } } +function attachTurnMetadata(records: Message[], turnId: string): Message[] { + let seq = 0 + return records.map((record) => ({ + ...record, + metadata: { + ...(record.metadata ?? {}), + turnId, + turnSeq: seq++, + }, + })) +} + /** 把 tool 执行结果序列化为 tool message content,对齐 OpenAI/Anthropic 标准(只含结果数据)。 */ function serializeToolResultContent(result: ToolResultEnvelope['toolResults'][number]): string { if (!result.success) { @@ -254,10 +266,10 @@ function buildSession( ...(result.toolCalls && result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {}), timestamp: new Date().toISOString(), } - for (const record of recordsToPersist) { + const turnId = randomUUID() + for (const record of attachTurnMetadata([...recordsToPersist, assistantRecord], turnId)) { await storage.appendRecord(currentMeta.id, record) } - await storage.appendRecord(currentMeta.id, assistantRecord) return { content: result.content, @@ -363,10 +375,10 @@ function buildSession( ...(result.toolCalls && result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {}), timestamp: new Date().toISOString(), } - for (const record of recordsToPersist) { + const turnId = randomUUID() + for (const record of attachTurnMetadata([...recordsToPersist, assistantRecord], turnId)) { await storage.appendRecord(currentMeta.id, record) } - await storage.appendRecord(currentMeta.id, assistantRecord) // 更新 promptTokens 基线 if (result.usage?.promptTokens) { diff --git a/packages/session/src/types/llm.ts b/packages/session/src/types/llm.ts index 0449182..8871fea 100644 --- a/packages/session/src/types/llm.ts +++ b/packages/session/src/types/llm.ts @@ -10,6 +10,8 @@ export interface Message { toolCallId?: string /** 消息写入时间(ISO 字符串) */ timestamp?: string + /** 持久化层附加元数据(例如 KitKit 的 turnId/turnSeq)。LLM adapter 应忽略该字段。 */ + metadata?: Record } /** LLM 返回的工具调用请求 */ From 1ccbafaa0c9f11527ef8e2532f611992081883dc Mon Sep 17 00:00:00 2001 From: "wangziming.ec3o" Date: Sun, 31 May 2026 17:02:30 +0800 Subject: [PATCH 37/40] feat: support multimodal content parts --- .../__tests__/session-runtime.test.ts | 4 +- packages/core/src/adapters/session-runtime.ts | 13 ++- packages/core/src/agent/stello-agent.ts | 6 +- .../engine/__tests__/topology-render.test.ts | 2 +- packages/core/src/engine/stello-engine.ts | 32 ++++-- packages/core/src/engine/turn-runner.ts | 13 ++- .../src/orchestrator/session-orchestrator.ts | 10 +- packages/core/src/types/engine.ts | 6 +- .../src/__tests__/openai-compatible.test.ts | 107 ++++++++++++++++++ packages/session/src/__tests__/turn.test.ts | 31 +++++ .../session/src/adapters/openai-compatible.ts | 100 ++++++++++++++-- packages/session/src/context-utils.ts | 18 ++- packages/session/src/create-session.ts | 44 ++++++- packages/session/src/index.ts | 4 +- packages/session/src/types/llm.ts | 61 ++++++++++ packages/session/src/types/session-api.ts | 12 +- 16 files changed, 407 insertions(+), 56 deletions(-) diff --git a/packages/core/src/adapters/__tests__/session-runtime.test.ts b/packages/core/src/adapters/__tests__/session-runtime.test.ts index 647f014..b5d962b 100644 --- a/packages/core/src/adapters/__tests__/session-runtime.test.ts +++ b/packages/core/src/adapters/__tests__/session-runtime.test.ts @@ -224,7 +224,7 @@ describe('session-runtime adapters', () => { topologyContextProvider: provider, }); await runtime.send('hi'); - const [, opts] = session.send.mock.calls[0]; + const [, opts] = session.send.mock.calls[0]!; expect(opts).not.toHaveProperty('topologyContext'); }); @@ -240,7 +240,7 @@ describe('session-runtime adapters', () => { topologyContextProvider: async () => '', }); await runtime.send('hi'); - const [, opts] = session.send.mock.calls[0]; + const [, opts] = session.send.mock.calls[0]!; expect(opts).not.toHaveProperty('topologyContext'); }); diff --git a/packages/core/src/adapters/session-runtime.ts b/packages/core/src/adapters/session-runtime.ts index a104ef1..e873891 100644 --- a/packages/core/src/adapters/session-runtime.ts +++ b/packages/core/src/adapters/session-runtime.ts @@ -1,4 +1,4 @@ -import type { ForkContextFn, LLMAdapter, LLMCompleteOptions } from '@stello-ai/session'; +import type { ForkContextFn, LLMAdapter, LLMCompleteOptions, SessionInput } from '@stello-ai/session'; import type { EngineRuntimeSession } from '../engine/stello-engine'; import type { ToolCallParser } from '../engine/turn-runner'; @@ -24,6 +24,8 @@ export interface SessionCompatibleSendResult { }; } +export type SessionCompatibleInput = string | SessionInput; + /** 结构兼容 @stello-ai/session 的 consolidate 函数签名 */ export type SessionCompatibleConsolidateFn = ( currentMemory: string | null, @@ -67,11 +69,11 @@ export interface SessionCompatible { status: 'active' | 'archived'; }; send( - content: string, + content: SessionCompatibleInput, options?: SessionCompatibleSendOptions, ): Promise; stream?( - content: string, + content: SessionCompatibleInput, options?: SessionCompatibleSendOptions, ): AsyncIterable & { result: Promise }; messages(): Promise>; @@ -170,7 +172,7 @@ export async function adaptSessionToEngineRuntime( get turnCount() { return turnCount; }, - async send(input: string, sendOptions?: SessionCompatibleSendOptions): Promise { + async send(input: SessionCompatibleInput, sendOptions?: SessionCompatibleSendOptions): Promise { const sharedMemoryContext = await options.sharedMemoryContextProvider?.(); const topologyContext = await options.topologyContextProvider?.(session.meta.id); const mergedOptions: SessionCompatibleSendOptions = { @@ -193,7 +195,7 @@ export async function adaptSessionToEngineRuntime( }, ...(session.stream ? { - stream(input: string, sendOptions?: SessionCompatibleSendOptions) { + stream(input: SessionCompatibleInput, sendOptions?: SessionCompatibleSendOptions) { const contextPromise = options.sharedMemoryContextProvider?.() ?? Promise.resolve(undefined); const topologyPromise = options.topologyContextProvider?.(session.meta.id) ?? Promise.resolve(undefined); const source = (async () => { @@ -234,4 +236,3 @@ export async function adaptSessionToEngineRuntime( : {}), }; } - diff --git a/packages/core/src/agent/stello-agent.ts b/packages/core/src/agent/stello-agent.ts index 349f165..37d27d8 100644 --- a/packages/core/src/agent/stello-agent.ts +++ b/packages/core/src/agent/stello-agent.ts @@ -1,5 +1,5 @@ import type { BootstrapResult } from '../types/lifecycle'; -import { TurnRunner, type ToolCallParser, type TurnRunnerOptions } from '../engine/turn-runner'; +import { TurnRunner, type ToolCallParser, type TurnInput, type TurnRunnerOptions } from '../engine/turn-runner'; import type { EngineTurnResult } from '../engine/stello-engine'; import type { EngineStreamResult } from '../engine/stello-engine'; import { @@ -447,7 +447,7 @@ export class StelloAgent { /** 在指定 session 上运行一轮对话 */ turn( sessionId: string, - input: string, + input: TurnInput, options?: TurnRunnerOptions, ): Promise { return this.orchestrator.turn(sessionId, input, options); @@ -456,7 +456,7 @@ export class StelloAgent { /** 在指定 session 上流式运行一轮对话 */ stream( sessionId: string, - input: string, + input: TurnInput, options?: TurnRunnerOptions, ): Promise { return this.orchestrator.stream(sessionId, input, options); diff --git a/packages/core/src/engine/__tests__/topology-render.test.ts b/packages/core/src/engine/__tests__/topology-render.test.ts index b6ad5f0..4a79aed 100644 --- a/packages/core/src/engine/__tests__/topology-render.test.ts +++ b/packages/core/src/engine/__tests__/topology-render.test.ts @@ -3,7 +3,7 @@ import type { SessionTreeNode } from '../../types/session.js'; import { renderTopologyMarkdown } from '../topology-render.js'; function node(id: string, label: string, children: SessionTreeNode[] = [], status: 'active' | 'archived' = 'active'): SessionTreeNode { - return { id, label, parentId: null, status, children } as SessionTreeNode; + return { id, label, parentId: null, status, children, turnCount: 0 } as SessionTreeNode; } describe('renderTopologyMarkdown', () => { diff --git a/packages/core/src/engine/stello-engine.ts b/packages/core/src/engine/stello-engine.ts index d3ba168..916b176 100644 --- a/packages/core/src/engine/stello-engine.ts +++ b/packages/core/src/engine/stello-engine.ts @@ -33,8 +33,14 @@ import { type TurnRunnerOptions, type TurnRunnerResult, type TurnRunnerStreamResult, + type TurnInput, } from './turn-runner'; + +function turnInputText(input: TurnInput): string { + return typeof input === 'string' ? input : input.text; +} + /** Engine 调用 session.send/stream 时的运行时选项 */ export interface EngineRuntimeSessionCallOptions { /** AbortSignal — 透传给底层 session.send/stream 与 LLM 调用 */ @@ -54,10 +60,10 @@ export interface EngineRuntimeSession { /** 当前已完成轮次 */ turnCount: number; /** 运行一次单条对话 */ - send(input: string, options?: EngineRuntimeSessionCallOptions): Promise; + send(input: TurnInput, options?: EngineRuntimeSessionCallOptions): Promise; /** 可选:流式运行一次单条对话 */ stream?( - input: string, + input: TurnInput, options?: EngineRuntimeSessionCallOptions, ): AsyncIterable & { result: Promise }; /** fork 子 session,返回子 session 的 runtime */ @@ -216,9 +222,10 @@ export class StelloEngineImpl implements StelloEngine { } /** 处理一轮编排:当前 session send + tool loop + 调度 */ - async turn(input: string, options?: TurnRunnerOptions): Promise { - this.fireHook('onMessageReceived', { sessionId: this.session.id, input }); - this.fireHook('onRoundStart', { sessionId: this.session.id, input }); + async turn(input: TurnInput, options?: TurnRunnerOptions): Promise { + const inputText = turnInputText(input); + this.fireHook('onMessageReceived', { sessionId: this.session.id, input: inputText }); + this.fireHook('onRoundStart', { sessionId: this.session.id, input: inputText }); let turn: TurnRunnerResult; try { turn = await this.turnRunner.run(this.session, input, this, { @@ -238,20 +245,21 @@ export class StelloEngineImpl implements StelloEngine { } this.fireHook('onAssistantReply', { sessionId: this.session.id, - input, + input: inputText, content: turn.finalContent, rawResponse: turn.rawResponse, }); this.fireHook('onRoundEnd', { sessionId: this.session.id, - input, + input: inputText, turn, }); return { turn }; } /** 流式处理一轮编排:先输出增量文本,完成后再返回完整 turn */ - stream(input: string, options?: TurnRunnerOptions): EngineStreamResult { + stream(input: TurnInput, options?: TurnRunnerOptions): EngineStreamResult { + const inputText = turnInputText(input); const source: TurnRunnerStreamResult = this.turnRunner.runStream(this.session, input, this, { ...options, onToolCall: (toolCall) => { @@ -265,8 +273,8 @@ export class StelloEngineImpl implements StelloEngine { }); const result = (async () => { - this.fireHook('onMessageReceived', { sessionId: this.session.id, input }); - this.fireHook('onRoundStart', { sessionId: this.session.id, input }); + this.fireHook('onMessageReceived', { sessionId: this.session.id, input: inputText }); + this.fireHook('onRoundStart', { sessionId: this.session.id, input: inputText }); let turn: TurnRunnerResult; try { @@ -278,13 +286,13 @@ export class StelloEngineImpl implements StelloEngine { this.fireHook('onAssistantReply', { sessionId: this.session.id, - input, + input: inputText, content: turn.finalContent, rawResponse: turn.rawResponse, }); this.fireHook('onRoundEnd', { sessionId: this.session.id, - input, + input: inputText, turn, }); return { turn }; diff --git a/packages/core/src/engine/turn-runner.ts b/packages/core/src/engine/turn-runner.ts index 85ac3e3..e085a8d 100644 --- a/packages/core/src/engine/turn-runner.ts +++ b/packages/core/src/engine/turn-runner.ts @@ -1,5 +1,8 @@ +import type { SessionInput } from '@stello-ai/session'; import type { ToolExecutionResult } from '../types/lifecycle'; +export type TurnInput = string | SessionInput; + /** * 在单轮内并行执行所有 tool call,按输入顺序整理结果并按序触发事件。 * @@ -90,10 +93,10 @@ export interface TurnRunnerSession { /** Session 标识 */ id: string; /** 执行一次单条对话 */ - send(input: string, options?: TurnRunnerSessionCallOptions): Promise; + send(input: TurnInput, options?: TurnRunnerSessionCallOptions): Promise; /** 可选:流式执行一次单条对话 */ stream?( - input: string, + input: TurnInput, options?: TurnRunnerSessionCallOptions, ): AsyncIterable & { result: Promise }; } @@ -185,12 +188,12 @@ export class TurnRunner { */ async run( session: TurnRunnerSession, - input: string, + input: TurnInput, tools: TurnRunnerToolExecutor, options: TurnRunnerOptions = {}, ): Promise { const maxToolRounds = options.maxToolRounds ?? 5; - let currentInput = input; + let currentInput: TurnInput = input; let toolRoundCount = 0; let toolCallsExecuted = 0; let lastRawResponse = ''; @@ -231,7 +234,7 @@ export class TurnRunner { */ runStream( session: TurnRunnerSession, - input: string, + input: TurnInput, tools: TurnRunnerToolExecutor, options: TurnRunnerOptions = {}, ): TurnRunnerStreamResult { diff --git a/packages/core/src/orchestrator/session-orchestrator.ts b/packages/core/src/orchestrator/session-orchestrator.ts index b7a7cc0..7b75cda 100644 --- a/packages/core/src/orchestrator/session-orchestrator.ts +++ b/packages/core/src/orchestrator/session-orchestrator.ts @@ -3,15 +3,15 @@ import type { BootstrapResult } from '../types/lifecycle'; import type { StelloEngine, EngineForkOptions } from '../types/engine'; import type { EngineTurnResult } from '../engine/stello-engine'; import type { EngineStreamResult } from '../engine/stello-engine'; -import type { TurnRunnerOptions } from '../engine/turn-runner'; +import type { TurnInput, TurnRunnerOptions } from '../engine/turn-runner'; import type { EngineRuntimeManager } from './engine-runtime-manager'; /** Orchestrator 对 Engine 的最小依赖 */ export interface OrchestratorEngine extends StelloEngine { /** 运行当前 session 的一轮对话 */ - turn(input: string, options?: TurnRunnerOptions): Promise; + turn(input: TurnInput, options?: TurnRunnerOptions): Promise; /** 流式运行当前 session 的一轮对话 */ - stream(input: string, options?: TurnRunnerOptions): EngineStreamResult; + stream(input: TurnInput, options?: TurnRunnerOptions): EngineStreamResult; /** 归档当前绑定 session */ archiveSession(): Promise<{ sessionId: string }>; /** 从当前绑定 session 发起 fork */ @@ -59,7 +59,7 @@ export class SessionOrchestrator { /** 在指定 session 上运行一轮对话 */ async turn( sessionId: string, - input: string, + input: TurnInput, options?: TurnRunnerOptions, ): Promise { return this.runSerial(sessionId, async () => { @@ -71,7 +71,7 @@ export class SessionOrchestrator { /** 在指定 session 上流式运行一轮对话 */ async stream( sessionId: string, - input: string, + input: TurnInput, options?: TurnRunnerOptions, ): Promise { await this.requireSession(sessionId) diff --git a/packages/core/src/types/engine.ts b/packages/core/src/types/engine.ts index d485e00..8919d56 100644 --- a/packages/core/src/types/engine.ts +++ b/packages/core/src/types/engine.ts @@ -13,7 +13,7 @@ import type { ConfirmProtocol, } from './lifecycle'; import type { EngineRuntimeSession, EngineStreamResult, EngineTurnResult } from '../engine/stello-engine'; -import type { TurnRunnerOptions } from '../engine/turn-runner'; +import type { TurnInput, TurnRunnerOptions } from '../engine/turn-runner'; import type { ForkContextFn } from '@stello-ai/session'; import type { SessionConfig } from './session-config'; @@ -85,9 +85,9 @@ export interface StelloEngine { /** 离开当前绑定 Session 的整轮对话 */ leaveSession(): Promise<{ sessionId: string }>; /** 流式处理当前绑定 Session 的一轮对话 */ - stream(input: string, options?: TurnRunnerOptions): EngineStreamResult; + stream(input: TurnInput, options?: TurnRunnerOptions): EngineStreamResult; /** 非流式处理当前绑定 Session 的一轮对话 */ - turn?(input: string, options?: TurnRunnerOptions): Promise; + turn?(input: TurnInput, options?: TurnRunnerOptions): Promise; /** 导出 Agent Tool 定义(兼容 OpenAI / Claude tool use) */ getToolDefinitions(): ToolDefinition[]; /** 执行 Agent Tool */ diff --git a/packages/session/src/__tests__/openai-compatible.test.ts b/packages/session/src/__tests__/openai-compatible.test.ts index 4355a38..6b7644d 100644 --- a/packages/session/src/__tests__/openai-compatible.test.ts +++ b/packages/session/src/__tests__/openai-compatible.test.ts @@ -166,6 +166,113 @@ describe('createOpenAICompatibleAdapter', () => { expect(result.reasoningContent).toBeUndefined() }) + it('StepFun 3.7 将 image/video parts 转成 Chat Completions 多模态 content', async () => { + const adapter = createOpenAICompatibleAdapter({ + apiKey: 'test-key', + baseURL: 'https://api.stepfun.com/v1', + model: 'step-3.7-flash', + maxContextTokens: 128_000, + }) + + await adapter.complete([{ role: 'user', content: '看一下', parts: [ + { kind: 'image', source: { type: 'url', url: 'https://example.com/a.png' }, detail: 'high' }, + { kind: 'video', source: { type: 'url', url: 'https://example.com/a.mp4' } }, + ] }]) + + const sentMessages = createCompletion.mock.calls[0]![0].messages + expect(sentMessages[0]).toEqual({ + role: 'user', + content: [ + { type: 'text', text: '看一下' }, + { type: 'image_url', image_url: { url: 'https://example.com/a.png', detail: 'high' } }, + { type: 'video_url', video_url: { url: 'https://example.com/a.mp4' } }, + ], + }) + }) + it('StepFun 3.7 多模态能力不绑定固定 baseURL', async () => { + const adapter = createOpenAICompatibleAdapter({ + apiKey: 'test-key', + baseURL: 'https://api.stepfun.com/step_plan/v1', + model: 'step-3.7-flash', + maxContextTokens: 128_000, + }) + + await adapter.complete([{ role: 'user', content: '描述图片', parts: [ + { kind: 'image', source: { type: 'url', url: 'https://example.com/a.png' } }, + ] }]) + + expect(createCompletion.mock.calls[0]![0].messages[0].content).toEqual([ + { type: 'text', text: '描述图片' }, + { type: 'image_url', image_url: { url: 'https://example.com/a.png' } }, + ]) + }) + it('StepFun 3.7 支持 data URL 与 stepfile provider_file', async () => { + const adapter = createOpenAICompatibleAdapter({ + apiKey: 'test-key', + baseURL: 'https://api.stepfun.com/step_plan/v1', + model: 'step-3.7-flash', + maxContextTokens: 128_000, + }) + + await adapter.complete([{ role: 'user', content: '比较', parts: [ + { kind: 'image', source: { type: 'data', mediaType: 'image/png', data: 'abc123' } }, + { kind: 'video', source: { type: 'provider_file', provider: 'stepfun', fileId: 'file_123' } }, + ] }]) + + expect(createCompletion.mock.calls[0]![0].messages[0].content).toEqual([ + { type: 'text', text: '比较' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,abc123' } }, + { type: 'video_url', video_url: { url: 'stepfile://file_123' } }, + ]) + }) + + it('StepFun 3.7 将已解析 file part 转成文本块并保留原始用户问题', async () => { + const adapter = createOpenAICompatibleAdapter({ + apiKey: 'test-key', + baseURL: 'https://api.stepfun.com/step_plan/v1', + model: 'step-3.7-flash', + maxContextTokens: 128_000, + }) + + await adapter.complete([{ role: 'user', content: '总结重点', parts: [ + { + kind: 'file', + source: { type: 'kitkit_file', fileId: 'mmf_1', objectKey: 'multimodal/doc.pdf', backend: 's3' }, + filename: 'report.pdf', + mediaType: 'application/pdf', + extraction: { provider: 'stepfun', fileId: 'file-C0DD', status: 'success', content: '第一章:项目概况' }, + }, + ] }]) + + const sentMessages = createCompletion.mock.calls[0]![0].messages + expect(sentMessages[0].content).toEqual([ + { type: 'text', text: '总结重点' }, + { + type: 'text', + text: [ + '用户上传了文档:report.pdf', + '', + '第一章:项目概况', + '', + ].join('\n'), + }, + ]) + }) + + it('非 StepFun 3.7 模型收到 parts 时明确报错', async () => { + const adapter = createOpenAICompatibleAdapter({ + apiKey: 'test-key', + baseURL: 'https://api.example.com/v1', + model: 'other-model', + maxContextTokens: 128_000, + }) + + await expect(adapter.complete([{ role: 'user', content: '看图', parts: [ + { kind: 'image', source: { type: 'url', url: 'https://example.com/a.png' } }, + ] }])).rejects.toThrow('Multimodal content parts are only supported for StepFun step-3.7-flash') + expect(createCompletion).not.toHaveBeenCalled() + }) + it('signal 透传到 SDK request options', async () => { const adapter = createOpenAICompatibleAdapter({ apiKey: 'test-key', diff --git a/packages/session/src/__tests__/turn.test.ts b/packages/session/src/__tests__/turn.test.ts index 1a445c3..e8c9ebc 100644 --- a/packages/session/src/__tests__/turn.test.ts +++ b/packages/session/src/__tests__/turn.test.ts @@ -73,6 +73,37 @@ describe('send() 契约', () => { expect(secondCall[4]!.content).toBe('问题2') }) + it('send() 支持当前 turn 多模态 parts,持久化但不在后续历史中重复回放', async () => { + const capturedMessages: Message[][] = [] + const llm = createMockLLM([{ content: '看到了' }, { content: '继续' }]) + const originalComplete = llm.complete.bind(llm) + llm.complete = async (msgs, options) => { + capturedMessages.push(msgs.map((msg) => ({ ...msg, parts: msg.parts ? [...msg.parts] : undefined }))) + return originalComplete(msgs, options) + } + const { session } = await makeSession({ llm }) + const parts: Message['parts'] = [ + { kind: 'image', source: { type: 'url', url: 'https://example.com/a.png' }, detail: 'high' }, + ] + + await session.send({ text: '描述这张图', parts }) + + const firstUserMessage = capturedMessages[0]!.find((message) => message.role === 'user')! + expect(firstUserMessage.content).toBe('描述这张图') + expect(firstUserMessage.parts).toEqual(parts) + + const persistedAfterFirstTurn = await session.messages() + expect(persistedAfterFirstTurn[0]).toMatchObject({ role: 'user', content: '描述这张图', parts }) + + await session.send('继续分析') + + const secondCall = capturedMessages[1]! + const historicalUser = secondCall.find((message) => message.role === 'user' && message.content === '描述这张图')! + const currentUser = secondCall.find((message) => message.role === 'user' && message.content === '继续分析')! + expect(historicalUser.parts).toBeUndefined() + expect(currentUser.parts).toBeUndefined() + }) + it('send() 返回 toolCalls 时透传', async () => { const responseWithTools: LLMResult = { content: null, diff --git a/packages/session/src/adapters/openai-compatible.ts b/packages/session/src/adapters/openai-compatible.ts index 22ad1ee..e109da0 100644 --- a/packages/session/src/adapters/openai-compatible.ts +++ b/packages/session/src/adapters/openai-compatible.ts @@ -1,7 +1,7 @@ import OpenAI from 'openai' import type { ChatCompletion, ChatCompletionChunk } from 'openai/resources/chat/completions' import type { Stream } from 'openai/streaming' -import type { LLMAdapter, LLMResult, Message, LLMCompleteOptions } from '../types/llm.js' +import type { ContentPart, LLMAdapter, LLMResult, Message, LLMCompleteOptions } from '../types/llm.js' type ChatToolCallDelta = NonNullable< NonNullable[number] @@ -23,6 +23,8 @@ export interface OpenAICompatibleOptions { * 在中途被截断,引发上层 JSON 解析失败。 */ maxOutputTokens?: number + /** 将 KitKit 托管的多模态文件转成模型服务可访问的 URL。 */ + resolveMediaUrl?: (source: Extract['source'], { type: 'kitkit_file' }>) => string | Promise } /** 合并连续的 system 消息,兼容只接受单条 system 的提供方。 */ @@ -41,6 +43,89 @@ function mergeConsecutiveSystemMessages(messages: Message[]): Message[] { return merged } +function isStepFun37Flash(options: OpenAICompatibleOptions): boolean { + // StepFun 的不同套餐/入口可能使用不同 baseURL(如 /v1 与 /step_plan/v1)。 + // 多模态能力跟随模型名判断,不能把能力限定死在某一个 endpoint。 + return options.model === 'step-3.7-flash' +} + +function escapeDocumentAttribute(value: string): string { + return value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} + +function renderExtractedFilePart(part: Extract): string { + const filename = part.filename || '未命名文件' + const mediaType = part.mediaType || 'application/octet-stream' + const content = part.extraction?.content + if (!content) { + throw new Error('StepFun file content part requires extracted text content before reaching the OpenAI-compatible adapter') + } + return [ + `用户上传了文档:${filename}`, + ``, + content, + '', + ].join('\n') +} + +async function sourceToURL(part: Extract, options: OpenAICompatibleOptions): Promise { + const source = part.source + if (source.type === 'url') return source.url + if (source.type === 'data') return `data:${source.mediaType};base64,${source.data}` + if (source.type === 'provider_file') { + if (source.uri) return source.uri + if (source.provider === 'stepfun') return `stepfile://${source.fileId}` + throw new Error(`Unsupported provider_file source provider: ${source.provider}`) + } + if (source.type === 'kitkit_file') { + if (options.resolveMediaUrl) return options.resolveMediaUrl(source) + if (source.url && /^https?:\/\//.test(source.url)) return source.url + throw new Error('kitkit_file source must be converted to a model-readable URL before reaching the OpenAI-compatible adapter') + } + const unreachable = source as never + throw new Error(`Unsupported media source: ${JSON.stringify(unreachable)}`) +} + +async function toOpenAIContent(message: Message, allowMultimodal: boolean, options: OpenAICompatibleOptions): Promise>> { + if (!message.parts || message.parts.length === 0) return message.content + if (!allowMultimodal) { + throw new Error('Multimodal content parts are only supported for StepFun step-3.7-flash in this adapter') + } + if (message.role !== 'user') { + throw new Error(`Multimodal content parts are only supported on user messages, got role=${message.role}`) + } + + const blocks: Array> = [] + const hasTextPart = message.parts.some((part) => part.kind === 'text') + if (message.content && !hasTextPart) { + blocks.push({ type: 'text', text: message.content }) + } + + for (const part of message.parts) { + if (part.kind === 'text') { + blocks.push({ type: 'text', text: part.text }) + continue + } + if (part.kind === 'image') { + const imageUrl: Record = { url: await sourceToURL(part, options) } + if (part.detail && part.detail !== 'auto') imageUrl.detail = part.detail + blocks.push({ type: 'image_url', image_url: imageUrl }) + continue + } + if (part.kind === 'video') { + blocks.push({ type: 'video_url', video_url: { url: await sourceToURL(part, options) } }) + continue + } + if (part.kind === 'file') { + blocks.push({ type: 'text', text: renderExtractedFilePart(part) }) + continue + } + throw new Error(`Unsupported multimodal content part kind: ${part.kind}`) + } + + return blocks.length > 0 ? blocks : message.content +} + /** 创建 OpenAI 兼容协议的 LLMAdapter,可对接 MiniMax / DeepSeek / OpenAI 等 */ export function createOpenAICompatibleAdapter(options: OpenAICompatibleOptions): LLMAdapter { const client = new OpenAI({ @@ -49,8 +134,9 @@ export function createOpenAICompatibleAdapter(options: OpenAICompatibleOptions): }) /** 构建公共请求参数 */ - function buildParams(messages: Message[], completeOptions?: LLMCompleteOptions) { + async function buildParams(messages: Message[], completeOptions?: LLMCompleteOptions) { const normalizedMessages = mergeConsecutiveSystemMessages(messages) + const allowMultimodal = isStepFun37Flash(options) return { model: options.model, max_tokens: completeOptions?.maxTokens ?? options.maxOutputTokens ?? 4096, @@ -67,9 +153,9 @@ export function createOpenAICompatibleAdapter(options: OpenAICompatibleOptions): })), } : {}), - messages: normalizedMessages.map((m) => ({ + messages: await Promise.all(normalizedMessages.map(async (m) => ({ role: m.role as 'system' | 'user' | 'assistant' | 'tool', - content: m.content, + content: await toOpenAIContent(m, allowMultimodal, options), ...(m.role === 'tool' && m.toolCallId ? { tool_call_id: m.toolCallId } : {}), ...(m.role === 'assistant' && m.reasoningContent ? { reasoning_content: m.reasoningContent } @@ -86,7 +172,7 @@ export function createOpenAICompatibleAdapter(options: OpenAICompatibleOptions): })), } : {}), - })), + }))), } } @@ -95,7 +181,7 @@ export function createOpenAICompatibleAdapter(options: OpenAICompatibleOptions): async complete(messages: Message[], completeOptions?: LLMCompleteOptions): Promise { const response = await client.chat.completions.create( { - ...buildParams(messages, completeOptions), + ...(await buildParams(messages, completeOptions)), ...(options.extraBody ?? {}), stream: false, } as Parameters[0], @@ -131,7 +217,7 @@ export function createOpenAICompatibleAdapter(options: OpenAICompatibleOptions): async *stream(messages: Message[], completeOptions?: LLMCompleteOptions) { const stream = await client.chat.completions.create( { - ...buildParams(messages, completeOptions), + ...(await buildParams(messages, completeOptions)), ...(options.extraBody ?? {}), stream: true, } as Parameters[0], diff --git a/packages/session/src/context-utils.ts b/packages/session/src/context-utils.ts index b7eb86b..f017770 100644 --- a/packages/session/src/context-utils.ts +++ b/packages/session/src/context-utils.ts @@ -45,6 +45,14 @@ export function removeIncompleteToolCallGroups(records: Message[]): Message[] { return result } +function stripMultimodalParts(messages: Message[]): Message[] { + return messages.map((message) => { + if (!message.parts) return message + const { parts: _parts, ...rest } = message + return rest + }) +} + /** 内置默认压缩提示词 */ const BUILTIN_COMPRESS_PROMPT = `你是对话压缩助手。请将以下对话历史压缩为一段简洁的摘要,保留关键上下文信息。 要求: @@ -181,6 +189,7 @@ export async function assembleSessionContext( label?: string, sharedMemoryContext?: string, topologyContext?: string, + userParts?: Message['parts'], ): Promise { const prefixMessages: Message[] = [] let insightConsumed = false @@ -212,10 +221,15 @@ export async function assembleSessionContext( } const userTimestamp = new Date().toISOString() - const userMessage: Message = { role: 'user', content: userContent, timestamp: userTimestamp } + const userMessage: Message = { + role: 'user', + content: userContent, + timestamp: userTimestamp, + ...(userParts && userParts.length > 0 ? { parts: userParts } : {}), + } // 净化历史:移除中断/崩溃残留的不完整 tool call 组,保证送给 LLM 的 prompt 协议合法 - const history = removeIncompleteToolCallGroups(await storage.listRecords(sessionId)) + const history = stripMultimodalParts(removeIncompleteToolCallGroups(await storage.listRecords(sessionId))) // 估算全量 token 数 const fullMessages = [...prefixMessages, ...history, userMessage] diff --git a/packages/session/src/create-session.ts b/packages/session/src/create-session.ts index 0fd5a55..304ebb0 100644 --- a/packages/session/src/create-session.ts +++ b/packages/session/src/create-session.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import type { Session, MessageQueryOptions, SessionSendOptions } from './types/session-api.js' +import type { Session, MessageQueryOptions, SessionInput, SessionSendOptions } from './types/session-api.js' import { SessionArchivedError } from './types/session-api.js' import type { SessionMeta, SessionMetaUpdate, ForkOptions } from './types/session.js' import type { Message } from './types/llm.js' @@ -49,6 +49,14 @@ function attachTurnMetadata(records: Message[], turnId: string): Message[] { })) } +function normalizeSessionInput(input: string | SessionInput): { text: string; parts?: Message['parts'] } { + if (typeof input === 'string') return { text: input } + return { + text: input.text, + ...(input.parts && input.parts.length > 0 ? { parts: input.parts } : {}), + } +} + /** 把 tool 执行结果序列化为 tool message content,对齐 OpenAI/Anthropic 标准(只含结果数据)。 */ function serializeToolResultContent(result: ToolResultEnvelope['toolResults'][number]): string { if (!result.success) { @@ -59,6 +67,14 @@ function serializeToolResultContent(result: ToolResultEnvelope['toolResults'][nu return JSON.stringify(result.data) } +function stripMultimodalParts(records: Message[]): Message[] { + return records.map((record) => { + if (!record.parts) return record + const { parts: _parts, ...rest } = record + return rest + }) +} + /** 为 toolResults continuation 组装固定上下文与历史。 */ async function assembleSessionReplayContext( sessionId: string, @@ -99,7 +115,7 @@ async function assembleSessionReplayContext( // 注意:此处刻意不调用 removeIncompleteToolCallGroups。 // replay 路径会把"assistant(toolCalls) + 由 envelope 合成的 tool 消息"拼接成完整组, // 在加载阶段过早裁剪反而会把回灌目标删掉。完整组校验放在拼接后由调用方做。 - const history = await storage.listRecords(sessionId) + const history = stripMultimodalParts(await storage.listRecords(sessionId)) messages.push(...history) return { messages, insightConsumed } } @@ -204,7 +220,7 @@ function buildSession( return currentMeta }, - async send(content: string, sendOptions?: SessionSendOptions): Promise { + async send(input: string | SessionInput, sendOptions?: SessionSendOptions): Promise { if (currentMeta.status === 'archived') { throw new SessionArchivedError(currentMeta.id) } @@ -213,6 +229,8 @@ function buildSession( } // pre-flight:已 abort 的 signal 立即抛出,不发起任何 LLM 请求 sendOptions?.signal?.throwIfAborted() + const normalizedInput = normalizeSessionInput(input) + const content = normalizedInput.text // 组装上下文(自动压缩) const assembled = await assembleSessionContext( @@ -221,6 +239,7 @@ function buildSession( currentMeta.label, sendOptions?.sharedMemoryContext, sendOptions?.topologyContext, + normalizedInput.parts, ) persistAndApplyCompressionCache(assembled.compressionCache) @@ -230,7 +249,12 @@ function buildSession( } let promptMessages = assembled.messages - let recordsToPersist: Message[] = [{ role: 'user', content, timestamp: assembled.userTimestamp }] + let recordsToPersist: Message[] = [{ + role: 'user', + content, + timestamp: assembled.userTimestamp, + ...(normalizedInput.parts ? { parts: normalizedInput.parts } : {}), + }] const toolEnvelope = parseToolResultEnvelope(content) if (toolEnvelope) { const replayContext = await assembleSessionReplayContext(currentMeta.id, storage, currentMeta.label, sendOptions?.sharedMemoryContext, sendOptions?.topologyContext) @@ -279,7 +303,7 @@ function buildSession( } }, - stream(content: string, sendOptions?: SessionSendOptions): StreamResult { + stream(input: string | SessionInput, sendOptions?: SessionSendOptions): StreamResult { if (currentMeta.status === 'archived') { throw new SessionArchivedError(currentMeta.id) } @@ -290,6 +314,8 @@ function buildSession( return createStreamResult(async (push) => { // pre-flight:已 abort 的 signal 立即让 result reject,processor 不进入下游 sendOptions?.signal?.throwIfAborted() + const normalizedInput = normalizeSessionInput(input) + const content = normalizedInput.text // 组装上下文(自动压缩) const assembled = await assembleSessionContext( @@ -298,6 +324,7 @@ function buildSession( currentMeta.label, sendOptions?.sharedMemoryContext, sendOptions?.topologyContext, + normalizedInput.parts, ) persistAndApplyCompressionCache(assembled.compressionCache) @@ -307,7 +334,12 @@ function buildSession( } let promptMessages = assembled.messages - let recordsToPersist: Message[] = [{ role: 'user', content, timestamp: assembled.userTimestamp }] + let recordsToPersist: Message[] = [{ + role: 'user', + content, + timestamp: assembled.userTimestamp, + ...(normalizedInput.parts ? { parts: normalizedInput.parts } : {}), + }] const toolEnvelope = parseToolResultEnvelope(content) if (toolEnvelope) { const replayContext = await assembleSessionReplayContext(currentMeta.id, storage, currentMeta.label, sendOptions?.sharedMemoryContext, sendOptions?.topologyContext) diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index 1e545f7..382fd3a 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -2,10 +2,12 @@ export type { SessionMeta, SessionMetaUpdate, SessionFilter, ForkOptions, ForkContextFn } from './types/session.js' export type { SessionStorage, ListRecordsOptions, CompressionCacheSnapshot } from './types/storage.js' export type { - Message, ToolCall, LLMCompleteOptions, LLMResult, LLMChunk, LLMAdapter, + Message, ContentPart, TextPart, ImagePart, VideoPart, FilePart, AudioPart, MediaSource, + ToolCall, LLMCompleteOptions, LLMResult, LLMChunk, LLMAdapter, } from './types/llm.js' export type { Session, + SessionInput, MessageQueryOptions, SessionSendOptions, } from './types/session-api.js' diff --git a/packages/session/src/types/llm.ts b/packages/session/src/types/llm.ts index 8871fea..7e45afa 100644 --- a/packages/session/src/types/llm.ts +++ b/packages/session/src/types/llm.ts @@ -2,6 +2,8 @@ export interface Message { role: 'system' | 'user' | 'assistant' | 'tool' content: string + /** Provider 中性的多模态内容块。content 仍是文本投影;adapter 可按需使用 parts。 */ + parts?: ContentPart[] /** 推理模型的思考内容(stepFun/DeepSeek 等),仅 role=assistant 时有效 */ reasoningContent?: string /** assistant 发起的工具调用列表,仅 role=assistant 时有效 */ @@ -14,6 +16,65 @@ export interface Message { metadata?: Record } +export type ContentPart = TextPart | ImagePart | VideoPart | FilePart | AudioPart + +export interface TextPart { + kind: 'text' + text: string +} + +export interface ImagePart { + kind: 'image' + source: MediaSource + detail?: 'low' | 'high' | 'auto' + altText?: string + filename?: string + mediaType?: string + sizeBytes?: number +} + +export interface VideoPart { + kind: 'video' + source: MediaSource + filename?: string + mediaType?: string + durationSeconds?: number + sizeBytes?: number +} + +export interface FilePart { + kind: 'file' + source: MediaSource + filename?: string + mediaType?: string + sizeBytes?: number + /** Parsed document text supplied by an upstream document extraction service. */ + extraction?: DocumentExtraction +} + +export interface DocumentExtraction { + provider: 'stepfun' + fileId: string + status?: 'processed' | 'success' | 'failed' + content?: string + contentChars?: number +} + +export interface AudioPart { + kind: 'audio' + source: MediaSource + filename?: string + mediaType?: string + durationSeconds?: number + sizeBytes?: number +} + +export type MediaSource = + | { type: 'url'; url: string } + | { type: 'data'; mediaType: string; data: string } + | { type: 'provider_file'; provider: string; fileId: string; uri?: string } + | { type: 'kitkit_file'; fileId: string; objectKey: string; backend: 'local' | 's3'; bucket?: string; url?: string } + /** LLM 返回的工具调用请求 */ export interface ToolCall { id: string diff --git a/packages/session/src/types/session-api.ts b/packages/session/src/types/session-api.ts index 7713b2a..bfb0299 100644 --- a/packages/session/src/types/session-api.ts +++ b/packages/session/src/types/session-api.ts @@ -1,5 +1,5 @@ import type { SessionMeta, SessionMetaUpdate, ForkOptions } from './session.js' -import type { Message, LLMAdapter, LLMCompleteOptions } from './llm.js' +import type { ContentPart, Message, LLMAdapter, LLMCompleteOptions } from './llm.js' import type { SendResult, StreamResult } from './functions.js' @@ -31,6 +31,12 @@ export interface SessionSendOptions { topologyContext?: string } +/** Session.send / Session.stream 的对象输入。text 是持久文本投影,parts 是当前 turn 的多模态源数据。 */ +export interface SessionInput { + text: string + parts?: ContentPart[] +} + /** Session 错误:操作归档中的 Session */ export class SessionArchivedError extends Error { constructor(sessionId: string) { @@ -56,10 +62,10 @@ export interface Session { readonly meta: Readonly /** 发送一条消息:组装上下文 → 调 LLM → 存 L3(用户消息 + LLM 响应)→ 返回结果 */ - send(content: string, options?: SessionSendOptions): Promise + send(input: string | SessionInput, options?: SessionSendOptions): Promise /** 流式发送:同 send() 但逐 chunk 输出,流结束后自动存 L3 */ - stream(content: string, options?: SessionSendOptions): StreamResult + stream(input: string | SessionInput, options?: SessionSendOptions): StreamResult /** 读取 L3 对话记录 */ messages(options?: MessageQueryOptions): Promise From 81bb9890991914ba3851a0d7d15a3146ab872c7e Mon Sep 17 00:00:00 2001 From: uchouT Date: Mon, 1 Jun 2026 00:36:36 +0800 Subject: [PATCH 38/40] feat: builtin tools support (#70) --- packages/core/src/index.ts | 1 + .../session/src/__tests__/anthropic.test.ts | 64 +++++++ .../src/__tests__/openai-compatible.test.ts | 158 ++++++++++++++++++ packages/session/src/adapters/anthropic.ts | 81 ++++++++- .../session/src/adapters/openai-compatible.ts | 113 ++++++++++--- packages/session/src/index.ts | 3 +- packages/session/src/types/llm.ts | 34 +++- 7 files changed, 424 insertions(+), 30 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 80de8bf..448e5c8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -157,6 +157,7 @@ export { SessionArchivedError, NotImplementedError } from '@stello-ai/session'; export type { // LLM 适配器 LLMAdapter, LLMResult, LLMChunk, LLMCompleteOptions, Message, + ClientToolDefinition, ProviderToolDefinition, ProviderToolProvider, ProviderToolEvent, ClaudeModel, ClaudeOptions, GPTModel, GPTOptions, OpenAICompatibleOptions, diff --git a/packages/session/src/__tests__/anthropic.test.ts b/packages/session/src/__tests__/anthropic.test.ts index c00b81a..0dd8132 100644 --- a/packages/session/src/__tests__/anthropic.test.ts +++ b/packages/session/src/__tests__/anthropic.test.ts @@ -213,6 +213,70 @@ describe('createAnthropicAdapter complete() max_tokens', () => { undefined, ) }) + + it('将 providerTools 原样透传给 Anthropic tools 数组', async () => { + const adapter = createAnthropicAdapter({ + apiKey: 'k', + model: 'm', + maxContextTokens: 200_000, + providerTools: [{ + id: 'anthropic_web_search', + provider: 'anthropic', + spec: { type: 'web_search_20250305', name: 'web_search', max_uses: 3 }, + }], + }) + + await adapter.complete([{ role: 'user', content: 'latest news' }], { + tools: [{ name: 'client_tool', description: 'client', inputSchema: { type: 'object' } }], + }) + + expect(messagesCreate).toHaveBeenCalledWith( + expect.objectContaining({ + tools: [ + { name: 'client_tool', description: 'client', input_schema: { type: 'object' } }, + { type: 'web_search_20250305', name: 'web_search', max_uses: 3 }, + ], + }), + undefined, + ) + }) + + it('Anthropic server-side tool blocks 不会变成客户端 toolCalls,并保留 providerToolEvents', async () => { + messagesCreate.mockResolvedValueOnce({ + content: [ + { type: 'server_tool_use', id: 'srv_1', name: 'web_search', input: { query: 'OpenAI news' } }, + { type: 'web_search_tool_result', tool_use_id: 'srv_1', content: [{ type: 'web_search_result', title: 'Example', url: 'https://example.com' }] }, + { type: 'text', text: 'answer' }, + ], + usage: { input_tokens: 10, output_tokens: 5 }, + }) + + const adapter = createAnthropicAdapter({ + apiKey: 'k', + model: 'm', + maxContextTokens: 200_000, + }) + + const result = await adapter.complete([{ role: 'user', content: 'latest news' }]) + + expect(result.content).toBe('answer') + expect(result.toolCalls).toBeUndefined() + expect(result.providerToolEvents).toEqual([ + { + id: 'srv_1', + type: 'server_tool_use', + name: 'web_search', + input: { query: 'OpenAI news' }, + raw: { type: 'server_tool_use', id: 'srv_1', name: 'web_search', input: { query: 'OpenAI news' } }, + }, + { + id: 'srv_1', + type: 'web_search_tool_result', + results: [{ type: 'web_search_result', title: 'Example', url: 'https://example.com' }], + raw: { type: 'web_search_tool_result', tool_use_id: 'srv_1', content: [{ type: 'web_search_result', title: 'Example', url: 'https://example.com' }] }, + }, + ]) + }) }) describe('createAnthropicAdapter stream() max_tokens', () => { diff --git a/packages/session/src/__tests__/openai-compatible.test.ts b/packages/session/src/__tests__/openai-compatible.test.ts index 6b7644d..5cc2c2e 100644 --- a/packages/session/src/__tests__/openai-compatible.test.ts +++ b/packages/session/src/__tests__/openai-compatible.test.ts @@ -189,6 +189,164 @@ describe('createOpenAICompatibleAdapter', () => { ], }) }) + + it('将 providerTools 原样透传给 OpenAI-compatible tools 数组', async () => { + const adapter = createOpenAICompatibleAdapter({ + apiKey: 'test-key', + baseURL: 'https://api.stepfun.com/v1', + model: 'step-3.7-flash', + maxContextTokens: 128_000, + }) + + await adapter.complete([{ role: 'user', content: '今天有什么新闻?' }], { + tools: [{ name: 'client_search', description: 'client search', inputSchema: { type: 'object' } }], + providerTools: [{ + id: 'stepfun_web_search', + provider: 'openai-compatible', + spec: { + type: 'web_search', + function: { description: '搜索互联网实时信息' }, + }, + }], + }) + + expect(createCompletion).toHaveBeenCalledWith( + expect.objectContaining({ + tool_choice: 'auto', + tools: [ + { + type: 'function', + function: { + name: 'client_search', + description: 'client search', + parameters: { type: 'object' }, + }, + }, + { + type: 'web_search', + function: { description: '搜索互联网实时信息' }, + }, + ], + }), + undefined, + ) + }) + + it('StepFun web_search tool_calls 不会变成客户端 toolCalls,并保留 providerToolEvents', async () => { + createCompletion.mockResolvedValueOnce({ + choices: [{ + message: { + content: '上海中心大厦', + tool_calls: [{ + id: 'call_search_1', + type: 'web_search', + function: { + name: 'step_websearch', + arguments: '{"keyword":"上海最高的楼"}', + results: [{ index: 0, url: 'https://example.com', title: '上海最高的楼' }], + }, + }], + }, + }], + usage: { prompt_tokens: 10, completion_tokens: 4 }, + }) + + const adapter = createOpenAICompatibleAdapter({ + apiKey: 'test-key', + baseURL: 'https://api.stepfun.com/v1', + model: 'step-3.7-flash', + maxContextTokens: 128_000, + }) + + const result = await adapter.complete([{ role: 'user', content: '上海最高的楼?' }], { + providerTools: [{ + id: 'stepfun_web_search', + provider: 'openai-compatible', + spec: { type: 'web_search', function: { description: '搜索互联网实时信息' } }, + }], + }) + + expect(result.toolCalls).toEqual([]) + expect(result.providerToolEvents).toEqual([{ + id: 'call_search_1', + type: 'web_search', + name: 'step_websearch', + input: { keyword: '上海最高的楼' }, + results: [{ index: 0, url: 'https://example.com', title: '上海最高的楼' }], + raw: { + id: 'call_search_1', + type: 'web_search', + function: { + name: 'step_websearch', + arguments: '{"keyword":"上海最高的楼"}', + results: [{ index: 0, url: 'https://example.com', title: '上海最高的楼' }], + }, + }, + }]) + }) + + it('stream() 忽略 provider tool delta 的客户端执行通道,并下发 providerToolEvents', async () => { + createCompletion.mockResolvedValueOnce((async function* () { + yield { + choices: [{ + delta: { + tool_calls: [{ + index: 0, + id: 'call_search_1', + type: 'web_search', + function: { + name: 'step_websearch', + arguments: '{"keyword":"上海最高的楼"}', + results: [{ index: 0, url: 'https://example.com', title: '上海最高的楼' }], + }, + }], + }, + }], + } + yield { choices: [{ delta: { content: '上海中心大厦' } }] } + })()) + + const adapter = createOpenAICompatibleAdapter({ + apiKey: 'test-key', + baseURL: 'https://api.stepfun.com/v1', + model: 'step-3.7-flash', + maxContextTokens: 128_000, + }) + + if (!adapter.stream) throw new Error('adapter.stream is required') + + const chunks = [] + for await (const chunk of adapter.stream([{ role: 'user', content: '上海最高的楼?' }], { + providerTools: [{ + id: 'stepfun_web_search', + provider: 'openai-compatible', + spec: { type: 'web_search', function: { description: '搜索互联网实时信息' } }, + }], + })) { + chunks.push(chunk) + } + + expect(chunks.flatMap((chunk) => chunk.toolCallDeltas ?? [])).toEqual([]) + expect(chunks.flatMap((chunk) => chunk.providerToolEvents ?? [])).toEqual([{ + id: 'call_search_1', + type: 'web_search', + name: 'step_websearch', + input: { keyword: '上海最高的楼' }, + results: [{ index: 0, url: 'https://example.com', title: '上海最高的楼' }], + raw: { + index: 0, + id: 'call_search_1', + type: 'web_search', + function: { + name: 'step_websearch', + arguments: '{"keyword":"上海最高的楼"}', + results: [{ index: 0, url: 'https://example.com', title: '上海最高的楼' }], + }, + }, + }]) + expect(chunks.map((chunk) => chunk.delta).join('')).toBe('上海中心大厦') + }) + it('StepFun 3.7 多模态能力不绑定固定 baseURL', async () => { const adapter = createOpenAICompatibleAdapter({ apiKey: 'test-key', diff --git a/packages/session/src/adapters/anthropic.ts b/packages/session/src/adapters/anthropic.ts index edabc74..147dc0f 100644 --- a/packages/session/src/adapters/anthropic.ts +++ b/packages/session/src/adapters/anthropic.ts @@ -7,7 +7,25 @@ import type { Tool, ContentBlock, } from '@anthropic-ai/sdk/resources/messages/messages' -import type { LLMAdapter, LLMResult, LLMChunk, Message, ToolCall, LLMCompleteOptions } from '../types/llm.js' +import type { + LLMAdapter, + LLMResult, + LLMChunk, + Message, + ToolCall, + LLMCompleteOptions, + ProviderToolDefinition, + ProviderToolEvent, +} from '../types/llm.js' + +type AnthropicProviderBlock = { + type: string + id?: string + tool_use_id?: string + name?: string + input?: unknown + content?: unknown +} & Record /** Anthropic 原生协议的配置选项 */ export interface AnthropicAdapterOptions { @@ -24,6 +42,8 @@ export interface AnthropicAdapterOptions { * 在中途被截断,引发上层 JSON 解析失败。建议按模型上限设置。 */ maxOutputTokens?: number + /** Provider-hosted tools to send with every request for this adapter. */ + providerTools?: ProviderToolDefinition[] } /** 将 Stello 内部 Message 转换为 Anthropic MessageParam 格式 */ @@ -106,6 +126,27 @@ function toAnthropicTools( })) } +function isAnthropicProviderTool(tool: ProviderToolDefinition): boolean { + return tool.provider === 'anthropic' +} + +function buildProviderTools( + adapterTools: ProviderToolDefinition[] | undefined, + requestTools: ProviderToolDefinition[] | undefined, +): Record[] { + return [...(adapterTools ?? []), ...(requestTools ?? [])] + .filter(isAnthropicProviderTool) + .map((tool) => tool.spec) +} + +function buildRequestTools(completeOptions: LLMCompleteOptions | undefined, adapterTools: ProviderToolDefinition[] | undefined): Tool[] { + const clientTools = completeOptions?.tools && completeOptions.tools.length > 0 + ? toAnthropicTools(completeOptions.tools) + : [] + const providerTools = buildProviderTools(adapterTools, completeOptions?.providerTools) + return [...clientTools, ...providerTools] as Tool[] +} + /** 从 Anthropic response content blocks 中提取 tool calls */ function extractToolCalls(content: ContentBlock[]): ToolCall[] { return content @@ -125,6 +166,27 @@ function extractText(content: ContentBlock[]): string | null { return texts.length > 0 ? texts.join('') : null } +function toProviderToolEvent(block: AnthropicProviderBlock): ProviderToolEvent | null { + if (block.type === 'text' || block.type === 'tool_use') return null + const event: ProviderToolEvent = { + type: block.type, + raw: block, + } + const id = block.id ?? block.tool_use_id + if (id) event.id = id + if (block.name) event.name = block.name + if ('input' in block) event.input = block.input + if ('content' in block) event.results = block.content + return event +} + +function extractProviderToolEvents(content: ContentBlock[]): ProviderToolEvent[] { + return content.flatMap((block) => { + const event = toProviderToolEvent(block as AnthropicProviderBlock) + return event ? [event] : [] + }) +} + /** 创建基于 Anthropic 原生协议的 LLMAdapter */ export function createAnthropicAdapter(options: AnthropicAdapterOptions): LLMAdapter { const client = new Anthropic({ @@ -141,6 +203,7 @@ export function createAnthropicAdapter(options: AnthropicAdapterOptions): LLMAda const system = systemMessages.length > 0 ? systemMessages.map((m) => m.content).join('\n\n') : undefined + const requestTools = buildRequestTools(completeOptions, options.providerTools) const response = await client.messages.create( { @@ -148,19 +211,19 @@ export function createAnthropicAdapter(options: AnthropicAdapterOptions): LLMAda max_tokens: completeOptions?.maxTokens ?? options.maxOutputTokens ?? 4096, ...(completeOptions?.temperature !== undefined && { temperature: completeOptions.temperature }), ...(system && { system }), - ...(completeOptions?.tools && completeOptions.tools.length > 0 - ? { tools: toAnthropicTools(completeOptions.tools) } - : {}), + ...(requestTools.length > 0 ? { tools: requestTools } : {}), messages: toAnthropicMessages(nonSystemMessages), }, completeOptions?.signal ? { signal: completeOptions.signal } : undefined, ) const toolCalls = extractToolCalls(response.content) + const providerToolEvents = extractProviderToolEvents(response.content) return { content: extractText(response.content), ...(toolCalls.length > 0 ? { toolCalls } : {}), + ...(providerToolEvents.length > 0 ? { providerToolEvents } : {}), usage: { promptTokens: response.usage.input_tokens, completionTokens: response.usage.output_tokens, @@ -175,6 +238,7 @@ export function createAnthropicAdapter(options: AnthropicAdapterOptions): LLMAda const system = systemMessages.length > 0 ? systemMessages.map((m) => m.content).join('\n\n') : undefined + const requestTools = buildRequestTools(completeOptions, options.providerTools) const stream = client.messages.stream( { @@ -182,9 +246,7 @@ export function createAnthropicAdapter(options: AnthropicAdapterOptions): LLMAda max_tokens: completeOptions?.maxTokens ?? options.maxOutputTokens ?? 4096, ...(completeOptions?.temperature !== undefined && { temperature: completeOptions.temperature }), ...(system && { system }), - ...(completeOptions?.tools && completeOptions.tools.length > 0 - ? { tools: toAnthropicTools(completeOptions.tools) } - : {}), + ...(requestTools.length > 0 ? { tools: requestTools } : {}), messages: toAnthropicMessages(nonSystemMessages), }, completeOptions?.signal ? { signal: completeOptions.signal } : undefined, @@ -205,6 +267,11 @@ export function createAnthropicAdapter(options: AnthropicAdapterOptions): LLMAda name: event.content_block.name, }], } + } else { + const providerEvent = toProviderToolEvent(event.content_block as AnthropicProviderBlock) + if (providerEvent) { + yield { delta: '', providerToolEvents: [providerEvent] } + } } } else if (event.type === 'content_block_delta') { if (event.delta.type === 'text_delta') { diff --git a/packages/session/src/adapters/openai-compatible.ts b/packages/session/src/adapters/openai-compatible.ts index e109da0..8b509ed 100644 --- a/packages/session/src/adapters/openai-compatible.ts +++ b/packages/session/src/adapters/openai-compatible.ts @@ -1,11 +1,26 @@ import OpenAI from 'openai' import type { ChatCompletion, ChatCompletionChunk } from 'openai/resources/chat/completions' import type { Stream } from 'openai/streaming' -import type { ContentPart, LLMAdapter, LLMResult, Message, LLMCompleteOptions } from '../types/llm.js' +import type { + ContentPart, + LLMAdapter, + LLMResult, + Message, + LLMCompleteOptions, + ProviderToolDefinition, + ProviderToolEvent, +} from '../types/llm.js' -type ChatToolCallDelta = NonNullable< - NonNullable[number] -> +type RawOpenAIToolCall = { + index?: number + id?: string + type?: string + function?: { + name?: string + arguments?: string + results?: unknown + } +} & Record /** OpenAI 兼容协议的配置选项 */ export interface OpenAICompatibleOptions { @@ -23,6 +38,8 @@ export interface OpenAICompatibleOptions { * 在中途被截断,引发上层 JSON 解析失败。 */ maxOutputTokens?: number + /** Provider-hosted tools to send with every request for this adapter. */ + providerTools?: ProviderToolDefinition[] /** 将 KitKit 托管的多模态文件转成模型服务可访问的 URL。 */ resolveMediaUrl?: (source: Extract['source'], { type: 'kitkit_file' }>) => string | Promise } @@ -49,6 +66,45 @@ function isStepFun37Flash(options: OpenAICompatibleOptions): boolean { return options.model === 'step-3.7-flash' } +function isOpenAICompatibleProviderTool(tool: ProviderToolDefinition): boolean { + return tool.provider === 'openai' || tool.provider === 'openai-compatible' +} + +function buildProviderTools( + adapterTools: ProviderToolDefinition[] | undefined, + requestTools: ProviderToolDefinition[] | undefined, +): Record[] { + return [...(adapterTools ?? []), ...(requestTools ?? [])] + .filter(isOpenAICompatibleProviderTool) + .map((tool) => tool.spec) +} + +function parseProviderToolArguments(value: string | undefined): unknown { + if (!value) return undefined + try { + return JSON.parse(value) + } catch { + return value + } +} + +function isProviderToolCall(call: RawOpenAIToolCall): boolean { + return typeof call.type === 'string' && call.type !== 'function' +} + +function toProviderToolEvent(call: RawOpenAIToolCall): ProviderToolEvent { + const event: ProviderToolEvent = { + ...(call.id ? { id: call.id } : {}), + type: call.type ?? 'provider_tool', + ...(call.function?.name ? { name: call.function.name } : {}), + raw: call, + } + const input = parseProviderToolArguments(call.function?.arguments) + if (input !== undefined) event.input = input + if (call.function && 'results' in call.function) event.results = call.function.results + return event +} + function escapeDocumentAttribute(value: string): string { return value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') } @@ -137,22 +193,22 @@ export function createOpenAICompatibleAdapter(options: OpenAICompatibleOptions): async function buildParams(messages: Message[], completeOptions?: LLMCompleteOptions) { const normalizedMessages = mergeConsecutiveSystemMessages(messages) const allowMultimodal = isStepFun37Flash(options) + const clientTools = completeOptions?.tools?.map((tool) => ({ + type: 'function' as const, + function: { + name: tool.name, + description: tool.description, + parameters: tool.inputSchema, + }, + })) ?? [] + const providerTools = buildProviderTools(options.providerTools, completeOptions?.providerTools) + const requestTools = [...clientTools, ...providerTools] return { model: options.model, max_tokens: completeOptions?.maxTokens ?? options.maxOutputTokens ?? 4096, ...(completeOptions?.temperature !== undefined && { temperature: completeOptions.temperature }), - ...(completeOptions?.tools - ? { - tools: completeOptions.tools.map((tool) => ({ - type: 'function' as const, - function: { - name: tool.name, - description: tool.description, - parameters: tool.inputSchema, - }, - })), - } - : {}), + ...(requestTools.length > 0 ? { tools: requestTools } : {}), + ...(providerTools.length > 0 ? { tool_choice: 'auto' as const } : {}), messages: await Promise.all(normalizedMessages.map(async (m) => ({ role: m.role as 'system' | 'user' | 'assistant' | 'tool', content: await toOpenAIContent(m, allowMultimodal, options), @@ -194,18 +250,24 @@ export function createOpenAICompatibleAdapter(options: OpenAICompatibleOptions): const reasoningContent = typeof rawMessage?.reasoning_content === 'string' ? rawMessage.reasoning_content : null + const rawToolCalls = (choice?.message?.tool_calls ?? []) as RawOpenAIToolCall[] + const providerToolEvents = rawToolCalls + .filter(isProviderToolCall) + .map(toProviderToolEvent) return { content: choice?.message?.content ?? null, ...(reasoningContent ? { reasoningContent } : {}), - toolCalls: (choice?.message?.tool_calls ?? []).flatMap((call) => { + toolCalls: rawToolCalls.flatMap((call) => { + if (isProviderToolCall(call)) return [] if (!('function' in call) || !call.function) return [] return [{ - id: call.id, + id: call.id ?? 'unknown_tool_call', name: call.function.name ?? 'unknown_tool', input: call.function.arguments ? JSON.parse(call.function.arguments) as Record : {}, }] }), + ...(providerToolEvents.length > 0 ? { providerToolEvents } : {}), usage: response.usage ? { promptTokens: response.usage.prompt_tokens, @@ -231,14 +293,23 @@ export function createOpenAICompatibleAdapter(options: OpenAICompatibleOptions): const reasoningDelta = typeof rawDelta?.reasoning_content === 'string' ? rawDelta.reasoning_content : undefined - const toolCallDeltas = (chunk.choices[0]?.delta?.tool_calls ?? []).map((call: ChatToolCallDelta) => ({ + const rawToolCalls = (chunk.choices[0]?.delta?.tool_calls ?? []) as RawOpenAIToolCall[] + const providerToolEvents = rawToolCalls + .filter(isProviderToolCall) + .map(toProviderToolEvent) + const toolCallDeltas = rawToolCalls.filter((call) => !isProviderToolCall(call)).map((call) => ({ index: call.index ?? 0, id: call.id, name: call.function?.name, input: call.function?.arguments, })) - if (delta || reasoningDelta || toolCallDeltas.length > 0) { - yield { delta, ...(reasoningDelta ? { reasoningDelta } : {}), toolCallDeltas } + if (delta || reasoningDelta || toolCallDeltas.length > 0 || providerToolEvents.length > 0) { + yield { + delta, + ...(reasoningDelta ? { reasoningDelta } : {}), + ...(toolCallDeltas.length > 0 ? { toolCallDeltas } : {}), + ...(providerToolEvents.length > 0 ? { providerToolEvents } : {}), + } } } }, diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index 382fd3a..7642939 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -3,7 +3,8 @@ export type { SessionMeta, SessionMetaUpdate, SessionFilter, ForkOptions, ForkCo export type { SessionStorage, ListRecordsOptions, CompressionCacheSnapshot } from './types/storage.js' export type { Message, ContentPart, TextPart, ImagePart, VideoPart, FilePart, AudioPart, MediaSource, - ToolCall, LLMCompleteOptions, LLMResult, LLMChunk, LLMAdapter, + ToolCall, ClientToolDefinition, ProviderToolDefinition, ProviderToolProvider, ProviderToolEvent, + LLMCompleteOptions, LLMResult, LLMChunk, LLMAdapter, } from './types/llm.js' export type { Session, diff --git a/packages/session/src/types/llm.ts b/packages/session/src/types/llm.ts index 7e45afa..72cbb08 100644 --- a/packages/session/src/types/llm.ts +++ b/packages/session/src/types/llm.ts @@ -82,6 +82,32 @@ export interface ToolCall { input: Record } +/** 客户端执行的 function-calling tool 定义。 */ +export interface ClientToolDefinition { + name: string + description: string + inputSchema: Record +} + +export type ProviderToolProvider = 'openai' | 'openai-compatible' | 'anthropic' + +/** Provider 执行的内置 tool 原生描述符。Stello 只透传,不本地执行。 */ +export interface ProviderToolDefinition { + id: string + provider: ProviderToolProvider + spec: Record +} + +/** Provider 内置 tool 的事件 / 结果。由 adapter 从 provider 响应中提取。 */ +export interface ProviderToolEvent { + id?: string + type: string + name?: string + input?: unknown + results?: unknown + raw: unknown +} + /** LLM complete 的选项 */ export interface LLMCompleteOptions { /** 最大生成 token 数 */ @@ -89,7 +115,9 @@ export interface LLMCompleteOptions { /** 温度参数 */ temperature?: number /** 可用工具列表的 schema(JSON Schema 格式) */ - tools?: Array<{ name: string; description: string; inputSchema: Record }> + tools?: ClientToolDefinition[] + /** Provider 执行的内置 tool 原生描述符。 */ + providerTools?: ProviderToolDefinition[] /** * AbortSignal — adapter 应在 abort 时中断 LLM 调用并以 AbortError reject。 * 不支持取消的 adapter 可忽略此字段(best-effort 语义)。 @@ -103,6 +131,8 @@ export interface LLMResult { /** 推理模型的思考内容,多轮对话时需回传给 API */ reasoningContent?: string | null toolCalls?: ToolCall[] + /** Provider 内置 tool 事件 / 结果,不进入客户端 tool loop。 */ + providerToolEvents?: ProviderToolEvent[] usage?: { promptTokens: number completionTokens: number @@ -122,6 +152,8 @@ export interface LLMChunk { name?: string input?: string }> + /** Provider 内置 tool 事件 / 结果,不进入客户端 tool loop。 */ + providerToolEvents?: ProviderToolEvent[] } /** From 1d3a64f98d441c4aa37a118eceb648cae9132c35 Mon Sep 17 00:00:00 2001 From: Ec3o <2499302531@qq.com> Date: Tue, 2 Jun 2026 23:54:55 +0800 Subject: [PATCH 39/40] fix lint failures --- eslint.config.mjs | 8 +++++++- .../__tests__/session-runtime.test.ts | 3 ++- .../core/src/llm/__tests__/defaults.test.ts | 20 +++++++++---------- packages/session/src/context-utils.ts | 3 ++- packages/session/src/create-session.ts | 3 ++- 5 files changed, 23 insertions(+), 14 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index e680a38..51a71ec 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,7 +2,13 @@ import tseslint from 'typescript-eslint'; export default tseslint.config( { - ignores: ['**/dist/', '**/node_modules/'], + ignores: [ + '**/dist/', + '**/node_modules/', + '**/.venv/', + '.agents/', + 'excalidraw-diagram-skill/', + ], }, ...tseslint.configs.recommended, { diff --git a/packages/core/src/adapters/__tests__/session-runtime.test.ts b/packages/core/src/adapters/__tests__/session-runtime.test.ts index b5d962b..b2149d2 100644 --- a/packages/core/src/adapters/__tests__/session-runtime.test.ts +++ b/packages/core/src/adapters/__tests__/session-runtime.test.ts @@ -264,7 +264,8 @@ describe('session-runtime adapters', () => { topologyContextProvider: provider, }); const stream = runtime.stream!('hi'); - for await (const _ of stream) { + for await (const chunk of stream) { + void chunk; // drain } await stream.result; diff --git a/packages/core/src/llm/__tests__/defaults.test.ts b/packages/core/src/llm/__tests__/defaults.test.ts index 654b61e..97cd722 100644 --- a/packages/core/src/llm/__tests__/defaults.test.ts +++ b/packages/core/src/llm/__tests__/defaults.test.ts @@ -74,8 +74,8 @@ describe('createDefaultCompressFn', () => { describe('label option in DefaultFnOptions', () => { it('prepends [session: {label}] to compress user prompt when label set', async () => { - let captured: any[] = []; - const llm = async (msgs: any[]) => { captured = msgs; return 'summary'; }; + let captured: Parameters[0] = []; + const llm: LLMCallFn = async (msgs) => { captured = msgs; return 'summary'; }; const fn = createDefaultCompressFn('PROMPT', llm, { label: 'Alpha' }); await fn([{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' }]); const userMsg = captured.find(m => m.role === 'user'); @@ -84,8 +84,8 @@ describe('label option in DefaultFnOptions', () => { }); it('omits prefix when label undefined', async () => { - let captured: any[] = []; - const llm = async (msgs: any[]) => { captured = msgs; return 'summary'; }; + let captured: Parameters[0] = []; + const llm: LLMCallFn = async (msgs) => { captured = msgs; return 'summary'; }; const fn = createDefaultCompressFn('PROMPT', llm); await fn([{ role: 'user', content: 'hi' }]); const userMsg = captured.find(m => m.role === 'user'); @@ -93,8 +93,8 @@ describe('label option in DefaultFnOptions', () => { }); it('prepends [session: {label}] to consolidate user prompt before "当前摘要"', async () => { - let captured: any[] = []; - const llm = async (msgs: any[]) => { captured = msgs; return 'new memory'; }; + let captured: Parameters[0] = []; + const llm: LLMCallFn = async (msgs) => { captured = msgs; return 'new memory'; }; const fn = createDefaultConsolidateFn('PROMPT', llm, { label: 'Beta' }); await fn('old memory', [{ role: 'user', content: 'x' }]); const userMsg = captured.find(m => m.role === 'user'); @@ -102,8 +102,8 @@ describe('label option in DefaultFnOptions', () => { }); it('omits prefix in consolidate when label undefined', async () => { - let captured: any[] = []; - const llm = async (msgs: any[]) => { captured = msgs; return 'new'; }; + let captured: Parameters[0] = []; + const llm: LLMCallFn = async (msgs) => { captured = msgs; return 'new'; }; const fn = createDefaultConsolidateFn('PROMPT', llm); await fn(null, [{ role: 'user', content: 'x' }]); const userMsg = captured.find(m => m.role === 'user'); @@ -111,8 +111,8 @@ describe('label option in DefaultFnOptions', () => { }); it('coexists with roleContext (both injected)', async () => { - let captured: any[] = []; - const llm = async (msgs: any[]) => { captured = msgs; return 's'; }; + let captured: Parameters[0] = []; + const llm: LLMCallFn = async (msgs) => { captured = msgs; return 's'; }; const fn = createDefaultCompressFn('PROMPT', llm, { label: 'L', roleContext: 'RC' }); await fn([{ role: 'user', content: 'x' }]); const systemContents = captured.filter(m => m.role === 'system').map(m => m.content); diff --git a/packages/session/src/context-utils.ts b/packages/session/src/context-utils.ts index f017770..9239023 100644 --- a/packages/session/src/context-utils.ts +++ b/packages/session/src/context-utils.ts @@ -48,7 +48,8 @@ export function removeIncompleteToolCallGroups(records: Message[]): Message[] { function stripMultimodalParts(messages: Message[]): Message[] { return messages.map((message) => { if (!message.parts) return message - const { parts: _parts, ...rest } = message + const { parts, ...rest } = message + void parts return rest }) } diff --git a/packages/session/src/create-session.ts b/packages/session/src/create-session.ts index 304ebb0..269325b 100644 --- a/packages/session/src/create-session.ts +++ b/packages/session/src/create-session.ts @@ -70,7 +70,8 @@ function serializeToolResultContent(result: ToolResultEnvelope['toolResults'][nu function stripMultimodalParts(records: Message[]): Message[] { return records.map((record) => { if (!record.parts) return record - const { parts: _parts, ...rest } = record + const { parts, ...rest } = record + void parts return rest }) } From dfcf386e993f3a39622b67b58c997ba7ed7a1c7d Mon Sep 17 00:00:00 2001 From: Ec3o <2499302531@qq.com> Date: Tue, 2 Jun 2026 23:57:32 +0800 Subject: [PATCH 40/40] fix default llm tests typecheck --- packages/core/src/llm/__tests__/defaults.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/llm/__tests__/defaults.test.ts b/packages/core/src/llm/__tests__/defaults.test.ts index 97cd722..d15ca08 100644 --- a/packages/core/src/llm/__tests__/defaults.test.ts +++ b/packages/core/src/llm/__tests__/defaults.test.ts @@ -78,7 +78,7 @@ describe('label option in DefaultFnOptions', () => { const llm: LLMCallFn = async (msgs) => { captured = msgs; return 'summary'; }; const fn = createDefaultCompressFn('PROMPT', llm, { label: 'Alpha' }); await fn([{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' }]); - const userMsg = captured.find(m => m.role === 'user'); + const userMsg = captured.find(m => m.role === 'user')!; expect(userMsg.content.startsWith('[session: Alpha]\n\n')).toBe(true); expect(userMsg.content).toContain('对话记录:'); }); @@ -88,7 +88,7 @@ describe('label option in DefaultFnOptions', () => { const llm: LLMCallFn = async (msgs) => { captured = msgs; return 'summary'; }; const fn = createDefaultCompressFn('PROMPT', llm); await fn([{ role: 'user', content: 'hi' }]); - const userMsg = captured.find(m => m.role === 'user'); + const userMsg = captured.find(m => m.role === 'user')!; expect(userMsg.content.startsWith('[session:')).toBe(false); }); @@ -97,7 +97,7 @@ describe('label option in DefaultFnOptions', () => { const llm: LLMCallFn = async (msgs) => { captured = msgs; return 'new memory'; }; const fn = createDefaultConsolidateFn('PROMPT', llm, { label: 'Beta' }); await fn('old memory', [{ role: 'user', content: 'x' }]); - const userMsg = captured.find(m => m.role === 'user'); + const userMsg = captured.find(m => m.role === 'user')!; expect(userMsg.content.startsWith('[session: Beta]\n\n当前摘要:')).toBe(true); }); @@ -106,7 +106,7 @@ describe('label option in DefaultFnOptions', () => { const llm: LLMCallFn = async (msgs) => { captured = msgs; return 'new'; }; const fn = createDefaultConsolidateFn('PROMPT', llm); await fn(null, [{ role: 'user', content: 'x' }]); - const userMsg = captured.find(m => m.role === 'user'); + const userMsg = captured.find(m => m.role === 'user')!; expect(userMsg.content.startsWith('[session:')).toBe(false); }); @@ -117,7 +117,7 @@ describe('label option in DefaultFnOptions', () => { await fn([{ role: 'user', content: 'x' }]); const systemContents = captured.filter(m => m.role === 'system').map(m => m.content); expect(systemContents.some(c => c.includes('\nRC\n'))).toBe(true); - const userMsg = captured.find(m => m.role === 'user'); + const userMsg = captured.find(m => m.role === 'user')!; expect(userMsg.content.startsWith('[session: L]')).toBe(true); }); });