fix(web): record which preset a session actually runs
The creation header names the preset a session STARTED with and is frozen, which is correct — it is a creation fact. Switching is legal only while a session is blank, and that looked like enough: no history exists yet. It is not, because the switch's effect outlives the blank window. The user switches, then sends the first message; every turn from there runs under the new composition while the header still names the old one. The session is then locked around a misrecorded preset, and resume reads the header to rebuild it — composing one preset's tools over a history another produced, which is exactly the replay the blank-only lock exists to prevent, reached by another route. A picker showed `standard` for a session running `core-web`. A switch is now an `agent-preset/selected` event appended after the swap commits, and `resolveSessionPreset()` (last selection, else the header) is what every reconstruction reads: the summary, resume, the conflict guard, and the fork introduced one layer down.
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-hmr": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
|
||||
@@ -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/client/ui-agent-preset/README.md
|
||||
README.md: 322fc7c8ba6eb621f27cb09475079e3d5bccf03f
|
||||
README.zh.md: 6eece3248d7f3c3652a30579f907eaf5f888f35f
|
||||
README.md: 14921afb7b90bb0b42a8f7f83ebc78773e8419a3
|
||||
README.zh.md: 06807199e996a6ae9d8a4216b8f686b6bbc044d9
|
||||
|
||||
@@ -63,8 +63,6 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'async recompose(agentCtx: Context, id: string): Promise<AgentPreset>',
|
||||
jsDoc: '/**\n * Replace the composition installed for one agent.\n *\n * Only valid while the agent has produced nothing: swapping tools mid\n * conversation would leave logged tool calls the new composition cannot make.\n * The CALLER owns that check — this method does not read session history.\n *\n * The swap is unmount-then-mount because two compositions cannot coexist:\n * both would register the same tool names into one layer. A failed mount\n * therefore restores the previous composition rather than leaving the agent\n * with nothing.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the profile to compose the agent from instead.\n * @returns the profile now installed.\n * @throws when the profile is unknown or its composition is unusable; the\n * previous composition is restored first.\n */',
|
||||
jsDoc: '/**\n * Replace the composition installed for one agent.\n *\n * Only valid while the agent has produced nothing: swapping tools mid\n * conversation would leave logged tool calls the new composition cannot make.\n * The CALLER owns that check — this method does not read session history.\n *\n * The swap is unmount-then-mount because two compositions cannot coexist:\n * both would register the same tool names into one layer. A failed mount\n * therefore restores the previous composition rather than leaving the agent\n * with nothing.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset to compose the agent from instead.\n * @returns the preset now installed.\n * @throws when the preset is unknown or its composition is unusable; the\n * previous composition is restored first.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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/host/apiproxy/README.md
|
||||
README.md: 9484fadcc798652979f998c81f84444c1ebdbf52
|
||||
README.zh.md: 238339213f6fec0f3f466907e5994953f391cd26
|
||||
README.md: 963a590f46e3ad41e432ad7ec98666ca180f7426
|
||||
README.zh.md: 87f1a702dd754119e34e615f203e1bb073c9f5d4
|
||||
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
WorkspaceMoveInvalidError, WorkspaceUnknownSessionError,
|
||||
} from '@deepseek-ai/dsh-workspace'
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
||||
import { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
|
||||
import {
|
||||
PresetMountError, resolveSessionPreset, UnknownPresetError,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
|
||||
@@ -256,17 +258,21 @@ function sessionBlank(session: Session): boolean {
|
||||
}
|
||||
|
||||
/** Shared Session-header projection for list baselines and creation frames. */
|
||||
function sessionListFields(header: SessionHeader): {
|
||||
function sessionListFields(header: SessionHeader, events: readonly SessionEvent[] = []): {
|
||||
parentSessionId?: SessionId
|
||||
origin?: 'subagent'
|
||||
cwd?: string
|
||||
agentPreset?: string
|
||||
} {
|
||||
// The preset comes from the log, not the header: a session that switched
|
||||
// while blank ran its turns under the newer composition, and a picker
|
||||
// showing the creation-time value would contradict what the model saw.
|
||||
const agentPreset = resolveSessionPreset({ header, events })
|
||||
return {
|
||||
...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession },
|
||||
...header.origin === undefined ? {} : { origin: header.origin },
|
||||
...header.cwd === undefined ? {} : { cwd: header.cwd },
|
||||
...header.agentPreset === undefined ? {} : { agentPreset: header.agentPreset },
|
||||
...agentPreset === undefined ? {} : { agentPreset },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +285,7 @@ function summarize(session: Session, running: boolean): SessionSummary {
|
||||
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
|
||||
running,
|
||||
blank: sessionBlank(session),
|
||||
...sessionListFields(session.header),
|
||||
...sessionListFields(session.header, session.events),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1232,7 +1238,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
if (inspected.meta.cwd !== cwd) {
|
||||
throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd)
|
||||
}
|
||||
assertPresetUnchanged(sessionId, presetId, inspected.meta.agentPreset)
|
||||
// Resolved from the log, not the header: a session that switched
|
||||
// while blank ran every turn under the newer composition.
|
||||
const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events })
|
||||
assertPresetUnchanged(sessionId, presetId, storedPreset)
|
||||
// The stored preset wins over anything the request names: a resumed
|
||||
// session's history was produced under that composition, and
|
||||
// rebuilding it differently would replay tool calls the model can no
|
||||
@@ -1240,7 +1249,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return (await ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions,
|
||||
setup: (await composeAgent(inspected.meta.agentPreset)).setup,
|
||||
setup: (await composeAgent(storedPreset)).setup,
|
||||
})).agent
|
||||
}
|
||||
|
||||
@@ -1936,7 +1945,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// those tools, and composing anything else would strand the tool calls
|
||||
// it already carries. Now that no model-facing row sits in the host
|
||||
// plane, composing nothing would leave the child with no tools at all.
|
||||
const forkComposition = await composeAgent(source.header.agentPreset)
|
||||
const forkComposition = await composeAgent(resolveSessionPreset(source))
|
||||
try {
|
||||
await ctx.agents.create({
|
||||
sessionId: childId,
|
||||
@@ -2528,6 +2537,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
try {
|
||||
const preset = await presets.recompose(agent.ctx, agentPreset)
|
||||
// Recorded only after the swap committed: the log states what the
|
||||
// agent runs, and a rejected mount leaves the previous composition.
|
||||
agent.session.append('agent-preset/selected', { agentPreset: preset.id })
|
||||
return ok(request, { agentPreset: preset.id })
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof UnknownPresetError) {
|
||||
@@ -2857,7 +2869,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// has run no turn yet, so this is constantly true in practice.
|
||||
blank: sessionBlank(session),
|
||||
// Including cwd lets the client group the new session without refreshing the list.
|
||||
...sessionListFields(session.header),
|
||||
...sessionListFields(session.header, session.events),
|
||||
}))
|
||||
}),
|
||||
ctx.on('session/disposed', (session: Session) => {
|
||||
|
||||
@@ -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/preset/agent-presets/README.md
|
||||
README.md: 5e785b747209d4c0cedaebbd3a90ba1b46dcd1c6
|
||||
README.zh.md: 4c40d7b7bfabb83dba2859251ad189a2646170c6
|
||||
README.md: b60d89b6dcda97a7570680072195231885fd0d45
|
||||
README.zh.md: f2485663ada031a9e8fa5ce3a06327e6cbc1de10
|
||||
|
||||
@@ -21,6 +21,12 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
|
||||
|
||||
The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the composition installed while the agent is still unpublished, so a rejected mount rolls the whole creation back rather than leaving a half-composed session. The subtree is owned by `agentCtx`'s fiber, so it unwinds with the agent and the caller receives no disposer.
|
||||
|
||||
### Which preset a session runs
|
||||
|
||||
The creation header names the preset a session STARTED with; `resolveSessionPreset(session)` names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — resolves rather than reading the header.
|
||||
|
||||
The header stays frozen because it is a creation fact. A switch is an `agent-preset/selected` session event appended after the swap commits, which is what the model-visible ⟺ logged rule requires: the preset decides the tool schemas and prompt sections the model sees, so it has to be reconstructable from the log. Reading the header alone would rebuild a switched session under the composition it was created with, replaying history the new tool set cannot act on — the exact hazard the blank-only lock exists to prevent.
|
||||
|
||||
## Config
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|
||||
@@ -21,6 +21,12 @@
|
||||
|
||||
agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,组装是在 agent 尚未发布时装入的,因此挂载被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。子树归 `agentCtx` 的 fiber 所有,随 agent 一起卸载,调用方无需持有 disposer。
|
||||
|
||||
### 会话实际运行的是哪个 preset
|
||||
|
||||
创建头部记录的是会话**以什么开始**,`resolveSessionPreset(session)` 给出的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都走解析,而非直接读头部。
|
||||
|
||||
头部保持冻结,因为它是创建期事实。切换以 `agent-preset/selected` 会话事件记录,在替换提交之后追加;这正是 model-visible ⟺ logged 规则的要求:preset 决定模型看到的工具 schema 与提示词段落,因此必须能从日志重建。只读头部会让切换过的会话按创建时的组装重建,从而重放新工具集无法执行的历史——这正是「仅空白可切」那道锁要防的危险。
|
||||
|
||||
## 配置
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@ export {
|
||||
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
|
||||
unmountPresetFor, type PresetMount,
|
||||
} from './mount.ts'
|
||||
export { resolveSessionPreset, type PresetBearingSession } from './session.ts'
|
||||
export { PresetMountError, UnknownPresetError } from './types.ts'
|
||||
export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts'
|
||||
|
||||
|
||||
54
packages/preset/agent-presets/src/session.ts
Normal file
54
packages/preset/agent-presets/src/session.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* The session-log record of which preset a session actually runs.
|
||||
*
|
||||
* The creation header names the preset a session STARTED with, and it is
|
||||
* deep-frozen because that is a creation fact. A session may still change
|
||||
* preset while it is blank, and the effect of that change outlives the blank
|
||||
* window: the first turn — and every turn after it — runs under the newly
|
||||
* mounted composition. Recording the change is what keeps the log honest, and
|
||||
* it is required outright by the repo's model-visible ⟺ logged rule, since the
|
||||
* preset decides the tool schemas and prompt sections the model sees.
|
||||
*
|
||||
* Reconstruction reads {@link resolveSessionPreset}, never the header alone.
|
||||
* @module @deepseek-ai/dsh-agent-presets/session
|
||||
*/
|
||||
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* The session's agent preset was chosen after creation, while the session
|
||||
* was still blank. Log-only: it records the composition later turns ran
|
||||
* under, so a resumed or forked session rebuilds the same one instead of
|
||||
* the header's creation-time value.
|
||||
*/
|
||||
'agent-preset/selected': { agentPreset: string }
|
||||
}
|
||||
}
|
||||
|
||||
/** The minimum a caller must supply to resolve a session's preset. */
|
||||
export interface PresetBearingSession {
|
||||
/** The session's creation header. */
|
||||
readonly header: SessionHeader
|
||||
/** The session's event log, oldest first. */
|
||||
readonly events: readonly SessionEvent[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The preset a session actually runs, newest selection winning.
|
||||
*
|
||||
* The header supplies the creation-time value; every later selection is a
|
||||
* logged event, so the last one is the answer. Reading the header alone
|
||||
* rebuilds a switched session under the composition it was created with, not
|
||||
* the one its history was produced under.
|
||||
* @param session - the session's header and event log.
|
||||
* @returns the preset id, or `undefined` when the deployment composes none.
|
||||
*/
|
||||
export function resolveSessionPreset(session: PresetBearingSession): string | undefined {
|
||||
for (let index = session.events.length - 1; index >= 0; index -= 1) {
|
||||
const event = session.events[index]
|
||||
if (event?.type === 'agent-preset/selected') return event.data.agentPreset
|
||||
}
|
||||
return session.header.agentPreset
|
||||
}
|
||||
@@ -21,6 +21,9 @@
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user