Merge pull request #1543 from deepseek-harness/stack/agent-profiles-8-authoring

feat(web): author agent presets from a settings page
This commit is contained in:
Yichen Jiang
2026-08-10 11:55:58 +08:00
committed by GitHub
331 changed files with 16605 additions and 558 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md
README.md: b7fa8fc2918711dd24eeba67132a267452d401f9
README.zh.md: cf044b937f7ae6d0464603a1b59ad9489423ebe0
README.md: 628833513a8092280970230c8657a50d00db4527
README.zh.md: 4eb2d4f2bebf9ed17190ef3cb21a2bc3c8d9123b

View File

@@ -14,7 +14,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
session.jsonl # only with compression: 'none'
```
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`).
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth, agentPreset? }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. `agentPreset` is durable because it decides the resumed session's tools and prompt — restoring a different composition would replay history the model can no longer act on. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`).
- A storage record is a `SessionEvent` JSON verbatim, or — for an eligible run when `packChunks` is enabled — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically.
- The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff.
- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename.

View File

@@ -14,7 +14,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d
session.jsonl # only with compression: 'none'
```
- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth }``delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。
- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth, agentPreset? }``delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。`agentPreset` 必须持久化,因为它决定了被恢复会话的工具与提示词——恢复成另一套组装,就会重放模型已无法据以行动的历史。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。
- 存储记录是原样 `SessionEvent` JSON或在 `packChunks` 已启用且连续段符合条件时写入的**打包分片行**`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session``packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist任何未识别内容原样存储。读取与布局无关`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。
- 项目目录保留规范化 cwd 可读,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript文本记录身份验证才接受备选路径写法。配置根仍由部署控制可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。
- 会话 id 是未验证的带品牌类型的字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。

View File

@@ -39,6 +39,7 @@ export interface HeaderLine {
seedLength?: number
origin?: 'subagent'
delegationDepth: number
agentPreset?: string
}
/**
@@ -57,6 +58,7 @@ export function toHeaderLine(header: SessionHeader): HeaderLine {
...header.seedLength !== undefined ? { seedLength: header.seedLength } : {},
...header.origin !== undefined ? { origin: header.origin } : {},
delegationDepth: header.delegationDepth ?? 0,
...header.agentPreset !== undefined ? { agentPreset: header.agentPreset } : {},
}
}
@@ -78,6 +80,7 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader {
...line.seedLength !== undefined ? { seedLength: line.seedLength } : {},
...line.origin !== undefined ? { origin: line.origin } : {},
delegationDepth: line.delegationDepth,
...line.agentPreset !== undefined ? { agentPreset: line.agentPreset } : {},
}
}
@@ -98,6 +101,8 @@ function isHeaderLine(value: unknown): value is HeaderLine {
&& !Object.is((value as { delegationDepth: number }).delegationDepth, -0)
&& ((value as { origin?: unknown }).origin === undefined
|| (value as { origin?: unknown }).origin === 'subagent')
&& ((value as { agentPreset?: unknown }).agentPreset === undefined
|| typeof (value as { agentPreset?: unknown }).agentPreset === 'string')
)
}

View File

@@ -817,6 +817,27 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
})
it('round-trips the agent preset a session was composed from', () => {
const line = toHeaderLine({
version: 0,
id: SessionId('composed'),
createdAt: 1,
delegationDepth: 0,
agentPreset: 'minimal',
})
const log = `${JSON.stringify(line)}\n`
// The preset decides the resumed session's tools and prompt; dropping it
// on disk would restore a composition the logged history contradicts.
expect(scanLog(Buffer.from(log)).meta.agentPreset).toBe('minimal')
})
it('rejects a session header whose agentPreset is not a string', () => {
const log = '{"type":"session","version":0,"id":"bad-preset","createdAt":1,"delegationDepth":0,"agentPreset":7}\n'
expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
})
it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => {
const log = [
JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1, delegationDepth: 0 }),

View File

@@ -380,8 +380,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
private writeRow(meta: SessionHeader): void {
this.db.prepare(`
INSERT INTO sessions
(id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, incarnation, revision)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
(id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, agent_preset, incarnation, revision)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
created_at = excluded.created_at,
@@ -389,7 +389,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
parent_session = excluded.parent_session,
seed_length = excluded.seed_length,
origin = excluded.origin,
delegation_depth = excluded.delegation_depth
delegation_depth = excluded.delegation_depth,
agent_preset = excluded.agent_preset
`).run(
meta.id,
meta.version,
@@ -399,6 +400,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
meta.seedLength ?? null,
meta.origin ?? null,
meta.delegationDepth ?? null,
meta.agentPreset ?? null,
randomUUID(),
)
}

View File

@@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee
* layout; orthogonal to a session's own `version` (which versions the EVENT
* vocabulary, stored per session in the `sessions` row).
*/
export const SCHEMA_VERSION = 13
export const SCHEMA_VERSION = 14
/** SQLite application id protecting unrelated databases from persistence writes. */
export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
@@ -42,6 +42,7 @@ export interface SessionRow {
/** Monotonic log-change token incremented in each mutating transaction. */
revision: number
delegation_depth: number | null
agent_preset: string | null
}
/** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */
@@ -125,6 +126,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
seed_length INTEGER,
origin TEXT,
delegation_depth INTEGER,
agent_preset TEXT,
incarnation TEXT NOT NULL,
revision INTEGER NOT NULL
) STRICT;
@@ -184,6 +186,7 @@ export function rowToMeta(row: SessionRow): SessionHeader {
...row.seed_length !== null ? { seedLength: row.seed_length } : {},
...row.origin !== null ? { origin: row.origin } : {},
...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {},
...row.agent_preset !== null ? { agentPreset: row.agent_preset } : {},
}
}

View File

@@ -172,6 +172,7 @@ describe('rowToMeta', () => {
incarnation: 'with-origin',
revision: 1,
delegation_depth: null,
agent_preset: null,
})).toMatchObject({ id: 'with-origin', origin: 'subagent' })
})
@@ -187,8 +188,27 @@ describe('rowToMeta', () => {
incarnation: 'fractional',
revision: 1,
delegation_depth: null,
agent_preset: null,
})).toThrow('stored session createdAt must be a non-negative safe integer')
})
it('restores the agent preset a session was composed from', () => {
// The preset decides the resumed session's tools and prompt; a row that
// dropped it would rebuild a composition the stored history contradicts.
expect(rowToMeta({
id: 'composed',
version: 0,
created_at: 1,
cwd: null,
parent_session: null,
seed_length: null,
origin: null,
incarnation: 'composed',
revision: 1,
delegation_depth: null,
agent_preset: 'minimal',
})).toMatchObject({ agentPreset: 'minimal' })
})
})
describe('SessionPersistenceSqlite: durability and crash semantics', () => {
@@ -638,7 +658,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
})
it('exposes the schema version constant', () => {
expect(SCHEMA_VERSION).toBe(13)
expect(SCHEMA_VERSION).toBe(14)
})
it('keeps the revision stable for an empty repair hook', async () => {

View File

@@ -134,10 +134,22 @@ interface UnitCell {
observedSeq: number
}
/** One live registration: the unit plus its per-session cells (dropped whole on disposal). */
/**
* One live registration: the unit plus its per-session cells (dropped whole
* once the last registrant releases it).
*
* `refs` exists because one unit definition already serves every session — the
* cells are keyed by `Session` — while the registrants are now per-session:
* an agent preset mounts the same tool package once per agent, so N sessions
* on one preset register the same key N times. Without a count the first
* registrant would own the disposer, and its session ending would strip the
* projection from every other live session.
*/
interface Registration {
readonly def: ErasedDefinition
readonly cells: WeakMap<Session, UnitCell>
/** Live registrants sharing this unit; the last one out removes the key. */
refs: number
}
/**
@@ -149,9 +161,12 @@ interface Registration {
* older than the registry, folds `init` over the in-memory log on first
* touch (event or read). Registration is an effect (disposer rides the
* calling fiber): an unloaded domain plugin's key disappears from snapshots
* and clients read it as capability absence. Duplicate keys throw. Domain
* and clients read it as capability absence. Domain
* plugins register under `ctx.inject(['sessionProjections'], …)` so headless
* assemblies without the registry stay unaffected.
* assemblies without the registry stay unaffected. Registrants sharing a key
* share one unit and are counted: the same tool package mounted in N agent
* presets registers N times, and the key survives until the last one
* unloads.
*/
export class SessionProjectionRegistry extends Service {
private readonly registrations = new Map<string, Registration>()
@@ -182,12 +197,25 @@ export class SessionProjectionRegistry extends Service {
}
const dispose = this.ctx.effect(function* (this: SessionProjectionRegistry) {
const key = definition.key as string
if (this.registrations.has(key)) {
throw new Error(`session projection key ${JSON.stringify(key)} is already registered`)
const existing = this.registrations.get(key)
if (existing === undefined) {
this.registrations.set(key, { def: definition, cells: new WeakMap(), refs: 1 })
} else {
// A differing `stateVersion` is the one incompatibility this can name:
// the versioned contract says the cached state shape differs, so the
// two registrants cannot share cells. Anything else about a definition
// is functions, which no runtime comparison can tell apart.
if (existing.def.stateVersion !== definition.stateVersion) {
throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(definition.stateVersion)}`)
}
existing.refs += 1
}
this.registrations.set(key, { def: definition, cells: new WeakMap() })
yield () => {
this.registrations.delete(key)
const live = this.registrations.get(key)
/* v8 ignore next -- the disposer runs once per successful registration, so the entry it counted is still here */
if (live === undefined) return
live.refs -= 1
if (live.refs === 0) this.registrations.delete(key)
}
}.bind(this), 'sessionProjections.register()')
return () => void dispose()

View File

@@ -127,14 +127,45 @@ describe('SessionProjectionRegistry drive', () => {
expect(snapshot.values['test/marks']).toEqual({ marks: [] })
})
it('rejects duplicate keys loud and keeps the first unit', async () => {
it('shares one unit between registrants of the same key', async () => {
const { ctx, session } = await harness()
ctx.sessionProjections.register(marksUnit())
expect(() => ctx.sessionProjections.register(marksUnit())).toThrow(/"test\/marks" is already registered/)
// One definition already serves every session (cells are keyed by
// Session), and registrants are per-session now: an agent preset mounts
// the same tool package once per agent.
expect(() => ctx.sessionProjections.register(marksUnit())).not.toThrow()
mark(session, ['kept'])
expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['kept'] })
})
it('keeps the unit until the last registrant releases it', async () => {
const { ctx, session } = await harness()
const first = ctx.sessionProjections.register(marksUnit())
const second = ctx.sessionProjections.register(marksUnit())
mark(session, ['kept'])
first()
// The regression this counts against: one session ending used to strip
// the projection from every other live session, because the first
// registrant owned the only disposer.
expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['kept'] })
second()
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
})
it('refuses to share a key across a stateVersion change', async () => {
const { ctx } = await harness()
ctx.sessionProjections.register(marksUnit())
// The one incompatibility a runtime comparison can name: the versioned
// contract says the cached state shape differs, so the two cannot share
// cells. Everything else about a definition is functions.
expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 9 }))
.toThrow(/already registered at stateVersion 1; refusing to share it with stateVersion 9/)
})
it('rejects a non-integer or negative stateVersion at register time', async () => {
const { ctx } = await harness()
expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: -1 })).toThrow(/stateVersion/)