Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/releases/v1.0.51.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# GraphAgent v1.0.51

Reasoning distillation is now opt-in and updates conversation history as well as future model context.

- Configure `reasoningDistillation.enabled` in `opencode.json`. The default is `false`.
- When enabled, the first completed user turn waits for distillation; later completed turns run distillation in the background. Tool steps remain part of their user turn.
- Validated results replace the original reasoning part in place. The TUI updates through its normal message events, reloaded sessions keep the adopted text, and subsequent requests reuse it.
- Failed, cancelled or stale work retains the current reasoning. Disabling the feature prevents new work and pending adoption. Existing compatibility and fidelity checks still protect unsupported, signed and encrypted reasoning.
- Host AI SDK, native adapter and Core runner share the same persisted replacement semantics; Core history caches are invalidated after adoption.

Existing conversations are not automatically reprocessed when the feature is enabled. Background work requires the application to remain running.
53 changes: 37 additions & 16 deletions docs/reasoning-distillation-design.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# 内化推理蒸馏:开发设计与验收规格

> **2026-09-28 product contract:** `opencode.json` controls `reasoningDistillation.enabled`, default `false`.
> Once enabled, the first completed user turn is distilled synchronously before the turn becomes idle.
> Each subsequent completed user turn is queued in the background. Tool steps do not count as turns.
> Successful adoption replaces the persisted reasoning part and emits its normal update event; the TUI,
> reopened history and later model requests use that same text. In-flight requests retain their snapshot.
> Failed, incompatible, stale or cancelled work retains the original. Disabling blocks new work and adoption.
> This contract supersedes the historical request-time, default-on and projection-only behavior below.

```json
{
"reasoningDistillation": {
"enabled": false
}
}
```

Set `enabled` to `true` to opt in. Existing exact-provider compatibility evidence and fidelity checks still apply;
turn scheduling alone does not authorize rewriting signed, encrypted or unsupported reasoning.
The original source is retained as host-owned provenance, excluded from provider request conversion.
Background work belongs to the running application scope; shutdown cancels unfinished work without changing history.

- 日期:2026-09-22,第 4 版。
- 状态:提案。产品实现未开始;文档检查通过不代表产品、模型质量或上游兼容性通过验收。
- 调研基线:`cedcfb3647e50d9a25633b65e4f3e599adc7fb6c`;`origin/dev` = `f3f4e50a0164c3b11b3b0126b2cd9c1ebe25dc37`。实施前重新核对开发分支。
Expand Down Expand Up @@ -74,7 +95,7 @@ W1/W2/W3 描述可定位的候选形态,**并不自动授予改写资格**。

上一轮读取的本地配置中,small_model 为 `local-proxy-compatible/deepseek`;多个 compatible 模型配置了 `interleaved.field = reasoning_content`,另有 Anthropic 通道和外部 think MCP。这是当时的配置观察,不是本版重新实测上游的结果,也不是可发布的兼容白名单。

该 compatible 配置说明 W1 值得优先验证,但尚不能得出“完全可改写”或“全部历史思绪均被上游计费”的结论。成本需要用实际发送载荷和 usage 证明。默认开启功能时,这些槽位仍受 P5 门控。
该 compatible 配置说明 W1 值得优先验证,但尚不能得出“完全可改写”或“全部历史思绪均被上游计费”的结论。成本需要用实际发送载荷和 usage 证明。显式开启功能时,这些槽位仍受 P5 门控。

## 3. think 内化的含义与边界

Expand All @@ -92,20 +113,20 @@ W1/W2/W3 描述可定位的候选形态,**并不自动授予改写资格**。

## 4. 已确认目标与形式化边界

| 编号 | 已确认目标 | 本版解释 |
| ---- | ---------------------------- | ------------------------------------------------------------------ |
| D01 | 内化,无外部 MCP/插件依赖 | 共享核心与宿主适配承担 |
| D02 | 默认开启 | 开关默认开启,兼容授权仍默认保护 |
| D03 | 仅在压缩/动态压缩触发时执行 | 无后台任务;每请求只做有界开关、用途和预算判定,不做无条件模型整理 |
| D04 | 替换而非附加回传思绪 | conversation 中原槽位替换;不旁路添加额外消息 |
| D05 | 协议保护优先 | 无兼容证据不改写;收益不能覆盖保护失败 |
| D06 | 不写回原始历史 | 缓存、验证、审计和投影均为派生数据 |
| D07 | 小模型 → agent 模型 → 主模型 | 分级解析与实际尝试区分,所有实际调用共用预算 |
| D08 | 信息守恒审计 | 按具体声明核对证据;违反规则不等于证明模型具有欺骗意图 |
| D09 | 结构化、可回溯 | 每条 claim 有原文锚点,支持证据与来源锚点分开 |
| D10 | 回传信息 think 化 | 正反意见与理由保留,不以工具式 think 替代 |
| D11 | 安静、可关闭 | 诊断不含正文;关闭后不应用已有候选缓存 |
| D12 | 与折叠、全文压缩独立验收 | 各自计收益和回归,验证组合次序 |
| 编号 | 已确认目标 | 本版解释 |
| ---- | ---------------------------- | ------------------------------------------------------------ |
| D01 | 内化,无外部 MCP/插件依赖 | 共享核心与宿主适配承担 |
| D02 | 默认关闭、显式开启 | opencode.json 中 reasoningDistillation.enabled,缺省为 false |
| D03 | 完整回合结束时执行 | 首轮同步,此后每轮后台异步;工具步骤不单独计轮 |
| D04 | 替换而非附加回传思绪 | conversation 中原槽位替换;不旁路添加额外消息 |
| D05 | 协议保护优先 | 无兼容证据不改写;收益不能覆盖保护失败 |
| D06 | 保存已采用的思考 | 事务更新原思考槽位,保留来源信息,同步显示与后续回传 |
| D07 | 小模型 → agent 模型 → 主模型 | 分级解析与实际尝试区分,所有实际调用共用预算 |
| D08 | 信息守恒审计 | 按具体声明核对证据;违反规则不等于证明模型具有欺骗意图 |
| D09 | 结构化、可回溯 | 每条 claim 有原文锚点,支持证据与来源锚点分开 |
| D10 | 回传信息 think 化 | 正反意见与理由保留,不以工具式 think 替代 |
| D11 | 安静、可关闭 | 诊断不含正文;关闭后不应用已有候选缓存 |
| D12 | 与折叠、全文压缩独立验收 | 各自计收益和回归,验证组合次序 |

### 4.1 决策等价下的最小表示

Expand Down Expand Up @@ -531,7 +552,7 @@ claim store 由宿主按 InstanceState 管理,以 location/session 隔离,
## 7. 待决事项与剩余风险

1. **触发判据 — 已解决(2026-09-22,预算制)**:owner 确认采用 `ContextFoldingBudget.overBudget === true`(见 §5.1),不以 duplicatePlan 非空触发;实施前在 dev 分支复核预算估算路径与软阈值取值。
2. **实现治理 — 已豁免(2026-09-22)**:项目所有者明确授权开发本功能,覆盖 `AGENTS.md` v1 focused-maintenance 的“禁止新增平台特性”约束。豁免仅限推理蒸馏本身及其直接依赖的模块/测试/配置,不扩展到其它无关平台特性或基础重构;是否同步修订 `AGENTS.md` 治理措辞属独立决定。默认开启仍为已确认产品目标。
2. **实现治理 — 已豁免(2026-09-22)**:项目所有者明确授权开发本功能,覆盖 `AGENTS.md` v1 focused-maintenance 的“禁止新增平台特性”约束。豁免仅限推理蒸馏本身及其直接依赖的模块/测试/配置,不扩展到其它无关平台特性或基础重构;是否同步修订 `AGENTS.md` 治理措辞属独立决定。默认开启目标已由 2026-09-28 的默认关闭产品规则取代。
3. **兼容授权,启用阻塞**:当前配置只证明 W1 形态,不能生成上游白名单。每个实际启用组合必须完成 §2.1;签名/加密通道收益不对等,不能靠破坏保护补收益。
4. **语义能力上限**:一般命题等价和支持依赖 judged 证据,仍可能误判。需要跨度级证据、留出基准与明确归因;未知不放行,不能宣称模型审阅等同确定性证明。
5. **证据完整性上限**:历史和调用清单可能缺失,导致只能 unverifiable。结果正文清理不抹除调用状态;若需要新增持久化执行账本,那是另一个需批准的范围,不在首版偷偷加入。
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ export type SessionsContextOutput = {
readonly type: "reasoning"
readonly id: string
readonly text: string
readonly distillation?: { readonly originalText: string; readonly sourceFingerprint: string } | null
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null
}
| {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
}),
reasoningDistillation: ConfigReasoningDistillation.Info.pipe(Schema.optional).annotate({
description:
"Reasoning distillation of returned model thoughts (default-on; any rewrite is gated by per-provider compatibility evidence)",
"Reasoning distillation of returned model thoughts (default-off; enable in opencode.json; any rewrite is gated by per-provider compatibility evidence)",
}),
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs to discover skills from",
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/config/reasoning-distillation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export class Compatibility extends Schema.Class<Compatibility>("ConfigV2.Reasoni
}) {}

/**
* Reasoning-distillation switch (design D02: default-on, but compatibility authorization still defaults to protected).
* Reasoning-distillation switch (default-off; explicit opt-in still requires compatibility authorization).
* The feature only ever rewrites a reasoning slot that carries a §2.1 dual-evidence compatibility record; absent that
* record every slot is P5-protected, so enabling the switch is safe and never rewrites an unproven provider.
*/
Expand All @@ -37,9 +37,9 @@ export type EnableInput = Readonly<{
enabled?: boolean
}>

/** Resolve the effective switch without mutating persisted config. Default-on per D02 unless disabled. */
/** Resolve the effective switch without mutating persisted config. Disabled unless explicitly enabled. */
export function resolveEnabled(input: EnableInput): EnableResolution {
if (input.disabledByEnvironment) return { enabled: false, source: "environment" }
if (input.enabled !== undefined) return { enabled: input.enabled, source: "config" }
return { enabled: true, source: "default" }
return { enabled: false, source: "default" }
}
20 changes: 10 additions & 10 deletions packages/core/src/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export interface Interface {
/** Batch durable publish: one transaction for the whole batch, contiguous seq per aggregate, projectors run in entry order, single durable wake after commit. */
readonly publishMany: (
events: ReadonlyArray<BatchEvent>,
options?: { readonly location?: Location.Ref },
options?: { readonly location?: Location.Ref; readonly validate?: Effect.Effect<void> },
) => Effect.Effect<ReadonlyArray<Payload>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly all: () => Stream.Stream<Payload>
Expand Down Expand Up @@ -139,11 +139,9 @@ export const layerWith = (options?: LayerOptions) =>
)

const wakeDurable = (aggregateID: string) =>
Effect.forEach(
pubsub.durable.get(aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
{ discard: true },
)
Effect.forEach(pubsub.durable.get(aggregateID) ?? [], (wake) => PubSub.publish(wake, undefined), {
discard: true,
})

/** Transaction-scoped single durable event commit: seq allocation, owner checks, projectors, UPSERT + INSERT. */
function commitDurableEventInner(
Expand Down Expand Up @@ -416,9 +414,7 @@ export const layerWith = (options?: LayerOptions) =>
function notify(event: Payload) {
return Effect.gen(function* () {
const snapshot = Array.from(listeners)
forkListeners(
Effect.forEach(snapshot, (listener) => observe(event, listener), { discard: true }),
)
forkListeners(Effect.forEach(snapshot, (listener) => observe(event, listener), { discard: true }))
const typed = pubsub.typed.get(event.type)
if (typed) yield* PubSub.publish(typed, event)
yield* PubSub.publish(pubsub.all, event)
Expand Down Expand Up @@ -447,7 +443,10 @@ export const layerWith = (options?: LayerOptions) =>
})
}

function publishMany(events: ReadonlyArray<BatchEvent>, options?: { readonly location?: Location.Ref }) {
function publishMany(
events: ReadonlyArray<BatchEvent>,
options?: { readonly location?: Location.Ref; readonly validate?: Effect.Effect<void> },
) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const location =
Expand Down Expand Up @@ -507,6 +506,7 @@ export const layerWith = (options?: LayerOptions) =>
// Aligned with entries by index: a deduped entry yields
// undefined so the payload pairing below stays positional.
const results = new Array<{ aggregateID: string; seq: number } | undefined>()
if (options?.validate) yield* options.validate
for (const entry of entries) {
// No replay input: seq is allocated contiguously from the latest sequence inside the transaction.
const result = yield* commitDurableEventInner(
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/message-updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) {
match.text = event.data.text
if (event.data.distillation !== undefined) match.distillation = event.data.distillation
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
}
})
Expand Down
95 changes: 95 additions & 0 deletions packages/core/src/session/reasoning-distillation/adopt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Cause, DateTime, Effect, Schema } from "effect"
import { isDeepStrictEqual } from "node:util"
import { eq } from "drizzle-orm"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { SessionEvent } from "../event"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
import { SessionMessageTable } from "../sql"
import { adoptionProvenance, type ReasoningReplacement } from "./adoption"

export const adoptReasoning = Effect.fn("CoreReasoningDistillation.adopt")(function* (
events: EventV2.Interface,
db: Database.Interface["db"],
sessionID: SessionSchema.ID,
messages: readonly SessionMessage.Message[],
replacements: readonly ReasoningReplacement[],
canAdopt: Effect.Effect<boolean> = Effect.succeed(true),
) {
if (replacements.length === 0) return false
const entries: EventV2.BatchEvent[] = []
const checks: Array<{ messageID: SessionMessage.ID; part: SessionMessage.AssistantReasoning }> = []
const seen = new Set<string>()
for (const replacement of replacements) {
const message = messages.find((message) => message.id === replacement.messageID)
if (!message || message.type !== "assistant" || message.time.completed === undefined) return false
const part = message.content.find((part) => part.id === replacement.partID)
const key = JSON.stringify([message.id, replacement.partID])
if (
!part ||
part.type !== "reasoning" ||
part.distillation ||
part.text !== replacement.before ||
!replacement.after.trim() ||
seen.has(key)
)
return false
seen.add(key)
checks.push({ messageID: message.id, part })
entries.push({
definition: SessionEvent.Reasoning.Ended,
data: {
sessionID,
assistantMessageID: message.id,
reasoningID: part.id,
text: replacement.after,
providerMetadata: part.providerMetadata,
distillation: adoptionProvenance(part.text),
timestamp: DateTime.makeUnsafe(Date.now()),
},
})
}
const validate = Effect.gen(function* () {
if (!(yield* canAdopt)) yield* Effect.die("reasoning distillation was disabled")
for (const user of messages.filter((message) => message.type === "user")) {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, user.id))
.get()
.pipe(Effect.orDie)
const current = row
? yield* Schema.decodeUnknownEffect(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.orDie,
)
: undefined
if (row?.session_id !== sessionID || !isDeepStrictEqual(current, user))
yield* Effect.die("reasoning source user was changed")
}
for (const check of checks) {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, check.messageID))
.get()
.pipe(Effect.orDie)
const message = row
? yield* Schema.decodeUnknownEffect(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.orDie,
)
: undefined
const current =
message?.type === "assistant" ? message.content.find((part) => part.id === check.part.id) : undefined
if (row?.session_id !== sessionID || !isDeepStrictEqual(current, check.part))
yield* Effect.die("stale reasoning adoption")
}
})
return yield* events.publishMany(entries, { validate }).pipe(
Effect.as(true),
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
() => Effect.logWarning("reasoning adoption failed; retaining current history").pipe(Effect.as(false)),
),
)
})
31 changes: 31 additions & 0 deletions packages/core/src/session/reasoning-distillation/adoption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { readWirePath } from "../context-folding/wire-value"
import { Hash } from "../../util/hash"

export type ReasoningReplacement = Readonly<{
messageID: string
partID: string
before: string
after: string
}>

/** Collect every applied slot from the final request, including earlier slots in a multi-slot cycle. */
export function reasoningReplacements(
request: unknown,
slots: readonly Readonly<{
messageID: string
partID: string
text: string
bodyPath: readonly (string | number)[]
}>[],
): ReasoningReplacement[] {
return slots.flatMap((slot) => {
const value = readWirePath(request, slot.bodyPath)
if (!value.ok || typeof value.value !== "string" || !value.value.trim() || value.value === slot.text) return []
return [{ messageID: slot.messageID, partID: slot.partID, before: slot.text, after: value.value }]
})
}

export const adoptionProvenance = (originalText: string) => ({
originalText,
sourceFingerprint: Hash.sha256(originalText),
})
Loading
Loading