---
name: fork-design
description: Fork 机制完整说明。覆盖 ForkProfile 与 EngineForkOptions 的字段对齐、四层 fallback 合成链（sessionDefaults → parent → profile → forkOptions）、systemPrompt 合成三种模式、skills 三态语义、持久化边界（SerializableSessionConfig 只固化 systemPrompt/skills）。任何涉及 fork / profile / stello_create_session / 配置合成 的工作都应读这个。
---

# Fork — 子 Session 创建机制

## 定位

Fork 是 Stello 创建子 Session 的唯一路径。编排层（Engine）负责完整编排：

1. 校验 profile 存在性
2. SplitGuard 拦截（可选）
3. 读取 sourceSession 的固化配置
4. 按四层链合成最终 SessionConfig
5. 创建拓扑节点（topology-first，先拿 ID）
6. 将可序列化子集固化入存储
7. 调用 `session.fork({ id, ... })` 创建 Session 实例
8. 触发 `onSessionFork` 事件

调用方不感知内部顺序。两条触发路径（LLM / 代码）共用这整套流程。

---

## 触发路径

| 触发者 | 入口 | 典型场景 |
|--------|------|---------|
| LLM | 内置 tool `stello_create_session` | LLM 判断当前任务需要拆分为子任务 |
| 应用层代码 | `agent.forkSession(sourceId, options)` | 代码驱动的主动编排（UI 按钮、策略脚本） |

两条路径汇聚到同一个 `forkSession(options: EngineForkOptions)`。LLM 路径只是在前面加了一层 JSON Schema 校验 + tool args → EngineForkOptions 映射。

---

## 类型体系

三个类型层层叠加，**共享同一个基座**。

### 基座：`SessionConfig`（6 字段）

所有 Session 运行时可配置项。由 `sessionDefaults`、父 session 固化 config、ForkProfile、EngineForkOptions 共同描述，`mergeSessionConfig` 输出也是这个形状。

| 字段 | 类型 | 说明 |
|------|------|------|
| `systemPrompt` | `string` | Session 的 system prompt |
| `llm` | `LLMAdapter` | LLM 适配器 |
| `tools` | `LLMCompleteOptions['tools']` | 用户 tool 定义 |
| `skills` | `string[]` | skill 白名单（三态语义见下文） |
| `consolidateFn` | `SessionCompatibleConsolidateFn` | L3→L2 提炼函数 |
| `compressFn` | `SessionCompatibleCompressFn` | 上下文压缩函数 |

### `ForkProfile extends SessionConfig`

预注册的 fork 配置模板，注册期定义。在 6 个基座字段之上新增 4 个 fork 专属字段：

| 新增字段 | 类型 | 说明 |
|---------|------|------|
| `systemPromptFn` | `(vars) => string` | 动态模板，**优先于** `systemPrompt` 字段 |
| `systemPromptMode` | `'preset' \| 'prepend' \| 'append'` | 合成策略，默认 `'prepend'` |
| `context` | `'none' \| 'inherit' \| ForkContextFn` | 上下文继承策略（默认值） |
| `prompt` | `string` | fork 后的开场消息（默认值） |

### `EngineForkOptions extends SessionConfig`

运行时每次 fork 传入的参数。在 6 个基座字段之上新增 6 个运行时字段：

| 新增字段 | 类型 | 说明 |
|---------|------|------|
| `label` | `string`（必填） | 子 session 显示名 |
| `prompt` | `string` | fork 后的开场消息 |
| `context` | `'none' \| 'inherit' \| ForkContextFn` | 上下文继承（覆盖 profile 默认值） |
| `topologyParentId` | `string` | 显式指定拓扑父节点（不传 = 当前 sessionId） |
| `profile` | `string` | 引用预注册的 ForkProfile 名 |
| `profileVars` | `Record<string, string>` | `systemPromptFn` 的模板变量 |

### 字段对齐矩阵

| 字段 | SessionConfig | ForkProfile | EngineForkOptions |
|------|:---:|:---:|:---:|
| systemPrompt | ✓ | ✓（静态） | ✓ |
| llm / tools / skills | ✓ | ✓ | ✓ |
| consolidateFn / compressFn | ✓ | ✓ | ✓ |
| systemPromptFn | — | ✓ | — |
| systemPromptMode | — | ✓ | — |
| context | — | ✓（默认） | ✓（覆盖） |
| prompt | — | ✓（默认） | ✓（覆盖） |
| label | — | — | ✓ 必填 |
| topologyParentId | — | — | ✓ |
| profile / profileVars | — | — | ✓ |

Profile 和 Options 的差异都是职责驱动的：profile 是模板（不能自引用 profile，不需要 label），options 是运行时参数（需要 label 每次给出，需要引用 profile 入口）。

---

## 四层 Fallback 合成链

所有 fork 最终都要合成一份完整的 SessionConfig 交给 `session.fork()`。合成输入有四层：

```
sessionDefaults → parent（固化 config） → profile → forkOptions
低优先级                                       高优先级
```

**字段级覆盖规则**：后层非 `undefined` 的字段覆盖前层；`undefined` 永不覆盖（保留前层值）。

### 普通字段（llm / tools / skills / consolidateFn / compressFn）

直接走 later-wins 链。每层独立决定某字段是否贡献。

### `systemPrompt` 特殊合成

分有无 profile 两种情况：

**情况 A — 有 profile**：

1. 先求 profile 的 promptSource：`profile.systemPromptFn?.(profileVars) ?? profile.systemPrompt`
2. 按 `profile.systemPromptMode`（默认 `'prepend'`）合成 promptSource 与 `forkOptions.systemPrompt`：

| Mode | 结果 |
|------|------|
| `'preset'` | 仅用 profilePrompt；forkOptions 的 systemPrompt 被忽略 |
| `'prepend'`（默认） | `{profilePrompt}\n\n{forkOptionsPrompt}` |
| `'append'` | `{forkOptionsPrompt}\n\n{profilePrompt}` |

3. 若 profile + forkOptions 都未贡献 prompt（如 preset 模式两者皆空），**回落到 parent → defaults** 的普通 later-wins 链。

**情况 B — 无 profile**：

走 `[defaults, parent, forkOptions]` 的普通 later-wins 链。

### `skills` 三态语义

`skills` 不做合并，整数组替换。三种取值：

| 取值 | 含义 |
|------|------|
| `undefined` | 未配置，本层不贡献 — 继承下层值；若所有层皆 undefined，运行时继承全局 SkillRouter（无白名单） |
| `[]` | 显式禁用 — 该 session 的 `activate_skill` 看不到任何 skill，**可覆盖下层非空值** |
| `['a', 'b']` | 白名单 — 只允许这几个 skill 可见 |

显式 `[]` 覆盖下层 `['a','b']` 是标准行为（"undefined 不覆盖"不阻止显式空数组生效）。

### 实战场景

| 场景 | 合成链贡献 |
|------|----------|
| 从 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 |

---

## 持久化边界

**`SerializableSessionConfig` 只固化两个字段**：`systemPrompt`、`skills`。

原因：其余四个字段（`llm / tools / consolidateFn / compressFn`）本质是运行时引用（函数、适配器、闭包），不可安全序列化。

### 持久化时机

`forkSession()` 完成合成后：

1. 从合成结果挑 `systemPrompt` 和 `skills` 两字段打包为 `SerializableSessionConfig`
2. 若两字段都是 `undefined`（空对象），**跳过 `putConfig` 写入**，避免给存储层制造噪声
3. 否则写入 `sessions.putConfig(childId, serializable)`

### 运行时重建时的后果

Engine 重新装配某 session 的 runtime config 时：

- `systemPrompt` / `skills` 从固化存储重放
- `llm` / `tools` / `consolidateFn` / `compressFn` **不来自父 session 的持久化**，而是每次 fork 时从 `sessionDefaults → profile → forkOptions` 现场合成

**实际语义**：嵌套 fork（子再 fork 孙）时，孙 session 的 llm 不会自动继承子 session 的 llm。孙 session 的 llm 来自 `sessionDefaults`（或孙 fork 时显式指定的 profile/options）。要让某条分支始终用特殊 llm，需在每次 fork 时显式传入、或通过 profile 固化。

---

## Fork 专属行为

### `topologyParentId` vs `sourceSessionId`

两者分离是编排层的关键设计：

| 概念 | 含义 | 来源 |
|------|------|------|
| `sourceSessionId` | 上下文来源 session（系统提示词、历史继承的对象） | 总是 = 当前 session.id |
| `topologyParentId` | 拓扑父节点（星空图上挂靠位置） | options 显式给出，默认 = sourceSessionId |

不传 `topologyParentId` 时两者相等（默认树形拓扑：fork from X → 挂在 X 下）。调用方显式传入 `topologyParentId` 可让两者分离——例如想把节点挂到根或任意已有节点下，但上下文继承仍来自发起 fork 的 session（`sourceSessionId = current`）。

### `context` 继承策略

| 值 | 含义 |
|---|------|
| `'none'`（默认） | 子 session 以空对话历史启动 |
| `'inherit'` | 拷贝父 session 全部 L3 记录 |
| `ForkContextFn` | 自定义函数，接收父消息数组返回继承子集 |

`options.context` 优先级高于 `profile.context`。

### `prompt` 开场消息

Fork 后写入子 session 的首条 assistant 消息，用户进入子 session 时首先看到。`options.prompt` 优先于 `profile.prompt`。

---

## LLM 侧暴露 — `stello_create_session`

Engine 构造时自动注入此内置 tool。参数 schema 随 `ForkProfileRegistry` 状态动态变化：

**基础参数**（始终存在）：`label`（必填）、`systemPrompt`、`prompt`、`context`（enum: `'none' | 'inherit'`）

**条件参数**（仅当有 profile 注册时追加）：
- `profile`：enum，取值为所有已注册 profile 名
- `vars`：object，键值对字符串，传给 `systemPromptFn`

LLM 调用后，Engine 把 tool args 映射为 `EngineForkOptions`，走和代码路径完全相同的 `forkSession()` 流程。

---

## Profile 注册模式

### 固定角色（静态 prompt）

```typescript
profiles.register('poet', {
  systemPrompt: '你是一位诗人，所有回复用诗歌形式。',
  systemPromptMode: 'preset',
})
```

`preset` 模式让 LLM 传入的 systemPrompt 被忽略，保证角色不被覆盖。

### 动态模板（基于变量）

```typescript
profiles.register('region-expert', {
  systemPromptFn: (vars) => `你是${vars.region}地区的留学专家。`,
  systemPromptMode: 'preset',
  skills: ['search', 'summarize'],
})
```

调用方传 `profileVars: { region: '北美' }` 生成具体 prompt。

### 基础约束（允许追加）

```typescript
profiles.register('researcher', {
  systemPrompt: '你是研究助手，善于深入分析。',
  systemPromptMode: 'prepend',   // 允许 fork options 追加具体研究主题
  context: 'inherit',
})
```

LLM 在 prepend 模式下可通过 `systemPrompt` 参数补充具体任务约束，合成结果为 `{profile}\n\n{task-specific}`。

---

## 设计不变量（不会改的决策）

1. **三类型同根** — SessionConfig / ForkProfile / EngineForkOptions 共享 6 字段基座；职责驱动的差异字段独立声明
2. **四层顺序固定** — defaults → parent → profile → forkOptions，不允许调换或插入新层
3. **undefined 不覆盖** — 保证"某层不传"等价于"使用下层值"的直觉
4. **skills 显式 `[]` 能生效** — 与 undefined 区分，让"禁用"成为可表达的意图
5. **只固化 systemPrompt + skills** — 可序列化字段有限，其余字段每次 fork 现场合成
6. **root 是普通 session** — root 的固化 systemPrompt/skills 通过 parent 层正常进入子 session 的合成链，没有任何特殊豁免
7. **topologyParentId 与 sourceSessionId 分离** — 编排层的拓扑策略和上下文继承是两个独立维度
