@sqdr2/dsh-fusion 0.3.0 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +148 -0
- package/README.en.md +9 -4
- package/README.md +9 -4
- package/RELEASING.md +129 -0
- package/RELEASING.zh.md +120 -0
- package/dshx.yml +3 -0
- package/lib/client.js +293 -75
- package/lib/client.js.map +1 -1
- package/lib/dsh-fusion.js +609 -237
- package/lib/types/client/FusionPermission.d.ts +15 -0
- package/lib/types/client/FusionSettings.d.ts +9 -1
- package/lib/types/client/status-polling.d.ts +6 -2
- package/lib/types/contracts.d.ts +2 -0
- package/lib/types/host/cache-policy.d.ts +16 -6
- package/lib/types/host/native-role-sandbox.d.ts +10 -2
- package/lib/types/host/native-selection.d.ts +15 -5
- package/lib/types/host/pairs.d.ts +47 -0
- package/lib/types/host/settings.d.ts +7 -3
- package/lib/types/identity.d.ts +16 -14
- package/lib/types/status.d.ts +17 -1
- package/package.json +64 -60
- package/src/client/FusionHint.tsx +11 -4
- package/src/client/FusionPermission.tsx +52 -0
- package/src/client/FusionSettings.tsx +109 -30
- package/src/client/index.ts +10 -5
- package/src/client/status-polling.ts +9 -4
- package/src/contracts.ts +2 -0
- package/src/dsh-fusion.ts +130 -54
- package/src/host/cache-policy.ts +18 -6
- package/src/host/native-role-sandbox.ts +18 -6
- package/src/host/native-scopes.ts +2 -2
- package/src/host/native-selection.ts +65 -21
- package/src/host/native-worker-brief.ts +18 -5
- package/src/host/pairs.ts +178 -0
- package/src/host/settings.ts +9 -5
- package/src/host/status.ts +24 -1
- package/src/identity.ts +39 -16
- package/src/status.ts +17 -1
|
@@ -3,11 +3,11 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
|
3
3
|
import { SessionId as NativeSessionId } from '@deepseek-ai/dsh-session'
|
|
4
4
|
import type { Session } from '@deepseek-ai/dsh-session'
|
|
5
5
|
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
|
6
|
-
import type { Role } from '../contracts.js'
|
|
6
|
+
import type { Role, SandboxMode } from '../contracts.js'
|
|
7
7
|
import type { SqliteFusionStore } from '../task/sqlite-store.js'
|
|
8
8
|
import type { SessionBinding } from './bindings.js'
|
|
9
9
|
|
|
10
|
-
type Mode =
|
|
10
|
+
type Mode = SandboxMode
|
|
11
11
|
|
|
12
12
|
// Mirrors the Host sandbox-policy augmentation (a type-only declaration; the Host
|
|
13
13
|
// registers the event at runtime). The plugin does not depend on that package.
|
|
@@ -36,6 +36,19 @@ interface Intent {
|
|
|
36
36
|
|
|
37
37
|
export const roleSeparated = (binding: SessionBinding) => binding.profile.workflowPolicy === 'enforced-v3'
|
|
38
38
|
|
|
39
|
+
const intentId = (sessionId: string) => `sandbox-intent:${sessionId}`
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The mode the user chose for a confined Fusion conversation, which is the mode
|
|
43
|
+
* the Sidekick runs under. Undefined until Fusion has confined the Lead, or after
|
|
44
|
+
* the conversation left Fusion and its own mode was restored.
|
|
45
|
+
*/
|
|
46
|
+
export function readSandboxIntent(store: SqliteFusionStore, sessionId: string): { mode: Mode } | undefined {
|
|
47
|
+
const intent = store.readDocument(intentId(sessionId))?.value as Intent | undefined
|
|
48
|
+
if (!intent || intent.schemaVersion !== 1 || !intent.active) return undefined
|
|
49
|
+
return { mode: intent.mode }
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
/**
|
|
40
53
|
* Role separation enforced below the prompt: the Lead's session runs under the
|
|
41
54
|
* Host's own read-only sandbox (bash and filesystem are refused by the OS
|
|
@@ -53,13 +66,12 @@ export class NativeRoleSandbox {
|
|
|
53
66
|
return (this.ctx as unknown as { get(name: string): unknown }).get('sandboxPolicy') as SandboxPolicy | undefined
|
|
54
67
|
}
|
|
55
68
|
|
|
56
|
-
#id(sessionId: string) { return `sandbox-intent:${sessionId}` }
|
|
57
69
|
#intent(sessionId: string): Intent | undefined {
|
|
58
|
-
return this.store.readDocument(
|
|
70
|
+
return this.store.readDocument(intentId(sessionId))?.value as Intent | undefined
|
|
59
71
|
}
|
|
60
72
|
#save(intent: Intent) {
|
|
61
|
-
const prior = this.store.readDocument(
|
|
62
|
-
this.store.writeDocument(
|
|
73
|
+
const prior = this.store.readDocument(intentId(intent.sessionId))
|
|
74
|
+
this.store.writeDocument(intentId(intent.sessionId), prior?.revision ?? 0, { ...intent, ownSeqs: intent.ownSeqs.slice(-32) })
|
|
63
75
|
}
|
|
64
76
|
// Session listeners run synchronously inside append, before the seq is known here.
|
|
65
77
|
#writing = false
|
|
@@ -7,7 +7,7 @@ import { requestOutputReservation } from '../context/guard.js'
|
|
|
7
7
|
import type { PhysicalRoute, Role } from '../contracts.js'
|
|
8
8
|
import type { SessionBinding } from './bindings.js'
|
|
9
9
|
import { digestOf } from '../digest.js'
|
|
10
|
-
import {
|
|
10
|
+
import { isFusionSelectionLabel } from '../identity.js'
|
|
11
11
|
import { fusionToolDiagnostic, projectRoleTools } from './tool-table.js'
|
|
12
12
|
|
|
13
13
|
export interface FusionScopeCallbacks {
|
|
@@ -122,7 +122,7 @@ export class NativeFusionScopes {
|
|
|
122
122
|
return { ...decision, messages: [...decision.messages.filter(message => {
|
|
123
123
|
const source = message.source
|
|
124
124
|
return !(source?.kind === 'model-selection' && source.form === 'notice'
|
|
125
|
-
&&
|
|
125
|
+
&& isFusionSelectionLabel(source.summary))
|
|
126
126
|
}).map(message =>
|
|
127
127
|
this.callbacks.projectMessage?.(agent, entry.binding, role, message) ?? message), ...(taskContext ? [taskContext] : [])] }
|
|
128
128
|
}, { prepend: true }))
|
|
@@ -5,30 +5,47 @@ import type { GenerateOptions, LlmModelInfo, LlmResolvedModelInfo, ModelModality
|
|
|
5
5
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
6
6
|
import type {} from '@deepseek-ai/dsh-api-session-controller/types'
|
|
7
7
|
import type { PhysicalRoute } from '../contracts.js'
|
|
8
|
-
import { FUSION_MODEL, FUSION_PROVIDER, isCurrentFusionSelection, isFusionSelection } from '../identity.js'
|
|
8
|
+
import { FUSION_MODEL, FUSION_PROVIDER, isCurrentFusionSelection, isFusionModel, isFusionSelection } from '../identity.js'
|
|
9
|
+
import { modelOfProfileId } from './pairs.js'
|
|
9
10
|
import type { ResolvedProfile } from '../profile/resolve.js'
|
|
10
11
|
import type { FusionCoordinator } from './coordinator.js'
|
|
11
12
|
|
|
12
13
|
export { FUSION_MODEL, FUSION_PROVIDER, isFusionSelection }
|
|
13
14
|
|
|
14
|
-
/**
|
|
15
|
-
export
|
|
15
|
+
/** One combo as the native menu needs it: its id, its label and its Lead's modalities. */
|
|
16
|
+
export interface FusionModelEntry {
|
|
17
|
+
readonly id: string
|
|
18
|
+
readonly name: string
|
|
19
|
+
readonly description?: string
|
|
20
|
+
readonly inputModalities: readonly ModelModality[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Read every combo this Host currently advertises; empty means unconfigured. */
|
|
24
|
+
export type FusionModels = () => Promise<readonly FusionModelEntry[]>
|
|
25
|
+
|
|
26
|
+
const textOnly = [...new Set<ModelModality>(['text'])]
|
|
16
27
|
|
|
17
28
|
/** Catalog-only local adapter; the native request is physically routed before dispatch. */
|
|
18
29
|
export class FusionCatalogAdapter extends LlmAdapter {
|
|
19
|
-
constructor(private readonly
|
|
30
|
+
constructor(private readonly models?: FusionModels) { super() }
|
|
20
31
|
override providerInfo(provider: string) { return { id: provider, name: 'Fusion' } }
|
|
21
32
|
override async listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
const declared = await this.models?.().catch(() => []) ?? []
|
|
34
|
+
// The Lead is the only role that reads user content, so an entry admits
|
|
35
|
+
// exactly what its own Lead admits; text-only until it resolves. An
|
|
36
|
+
// installation with no combo yet still offers the historical entry, so the
|
|
37
|
+
// menu can explain what to configure instead of hiding Fusion entirely.
|
|
38
|
+
const entries = declared.length ? declared
|
|
39
|
+
: [{ id: FUSION_MODEL, name: 'Fusion · 自动', inputModalities: textOnly }]
|
|
40
|
+
return entries.map(entry => ({ provider, id: entry.id, name: entry.name,
|
|
41
|
+
description: entry.description ?? 'Lead 与 Worker 协作。在设置 → Fusion 中配置模型。',
|
|
42
|
+
inputModalities: [...new Set<ModelModality>(['text', ...entry.inputModalities])] }))
|
|
28
43
|
}
|
|
29
44
|
override async resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
|
30
|
-
if (provider !== FUSION_PROVIDER || model
|
|
31
|
-
|
|
45
|
+
if (provider !== FUSION_PROVIDER || !isFusionModel(model)) throw new Error('Unknown local Fusion selection')
|
|
46
|
+
const declared = (await this.listModels(provider)).find(entry => entry.id === model)
|
|
47
|
+
if (!declared) throw new Error('Unknown local Fusion selection')
|
|
48
|
+
return declared
|
|
32
49
|
}
|
|
33
50
|
override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
34
51
|
throw new Error('Fusion 尚未就绪。请前往设置 → Fusion 配置 Lead 和 Worker。')
|
|
@@ -38,30 +55,57 @@ export class FusionCatalogAdapter extends LlmAdapter {
|
|
|
38
55
|
/** Public native lifecycle integration. No model-menu replacement or Host patch. */
|
|
39
56
|
export function installNativeFusionSelection(ctx: Context, input: {
|
|
40
57
|
coordinator(): FusionCoordinator | undefined
|
|
41
|
-
profile
|
|
58
|
+
/** The frozen profile of one combo, or undefined when it cannot be used yet. */
|
|
59
|
+
profile(model: string): ResolvedProfile | undefined
|
|
60
|
+
/** Why one combo cannot run, for the message a blocked session shows. */
|
|
61
|
+
reason?(model: string): string | undefined
|
|
42
62
|
selection(agent: Agent): PhysicalRoute | undefined
|
|
43
63
|
}): () => void {
|
|
44
64
|
const pending = new Map<string, Promise<void>>()
|
|
45
65
|
const failures = new Map<string, string>()
|
|
46
66
|
const desired = new Map<string, PhysicalRoute>()
|
|
47
67
|
const selection = (agent: Agent) => desired.get(agent.id) ?? input.selection(agent)
|
|
48
|
-
const
|
|
68
|
+
const binding = (agent: Agent) => input.coordinator()?.bindings.read(agent.id)?.binding
|
|
69
|
+
const selected = (agent: Agent) => binding(agent)?.selected === true
|
|
70
|
+
/** The combo a selected session is frozen to, or undefined when it runs no Fusion task. */
|
|
71
|
+
const runningCombo = (agent: Agent): string | undefined => {
|
|
72
|
+
const current = binding(agent)
|
|
73
|
+
return current?.selected ? modelOfProfileId(current.profile.id) ?? FUSION_MODEL : undefined
|
|
74
|
+
}
|
|
75
|
+
const blocked = (model: string) => input.reason?.(model) ?? '请先在设置 → Fusion 配置 Lead 和 Worker,再继续此会话。'
|
|
49
76
|
const change = (agent: Agent) => {
|
|
50
77
|
if (agent.session.header.parentSession || pending.has(agent.id)) return
|
|
51
|
-
const coordinator = input.coordinator(),
|
|
78
|
+
const coordinator = input.coordinator(), route = selection(agent)
|
|
52
79
|
if (!coordinator) return
|
|
53
80
|
if (isFusionSelection(route)) {
|
|
54
|
-
|
|
81
|
+
const model = route?.model ?? FUSION_MODEL
|
|
82
|
+
const profile = input.profile(model), running = runningCombo(agent)
|
|
83
|
+
if (running !== undefined && running !== model) {
|
|
84
|
+
// Another combo was picked inside this conversation. Each combo freezes
|
|
85
|
+
// its own pair, so the drain is the same as leaving Fusion: the next
|
|
86
|
+
// turn starts a new task under the newly chosen combo.
|
|
87
|
+
const transition = (async () => {
|
|
88
|
+
if (agent.status !== 'idle') await coordinator.pause(agent)
|
|
89
|
+
await coordinator.clear(agent)
|
|
90
|
+
if (profile) coordinator.selectBeforeAssembly(agent, profile)
|
|
91
|
+
else failures.set(agent.id, blocked(model))
|
|
92
|
+
})().catch(error => { failures.set(agent.id, String(error)) }).finally(() => { pending.delete(agent.id) })
|
|
93
|
+
pending.set(agent.id, transition)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (running === undefined) {
|
|
97
|
+
if (profile) coordinator.selectBeforeAssembly(agent, profile)
|
|
98
|
+
else failures.set(agent.id, blocked(model))
|
|
99
|
+
}
|
|
55
100
|
if (selected(agent)) {
|
|
56
101
|
// A saved Host default need not emit model/selection. Persist that
|
|
57
102
|
// already-chosen intent before physical headers reach the native menu's
|
|
58
103
|
// projection, otherwise it starts displaying the Lead as the selection.
|
|
59
|
-
// A session that named the
|
|
60
|
-
//
|
|
61
|
-
// the next assembly dispatches under the registered name.
|
|
104
|
+
// A session that named the combo before a rename keeps its task, but the
|
|
105
|
+
// route it stored no longer has an adapter; rewrite the spelling only.
|
|
62
106
|
const intent = agent.session.snapshotEvents().findLast(event => event.type === 'model/selection')
|
|
63
107
|
if (!isCurrentFusionSelection(intent?.data)) agent.session.append('model/selection', {
|
|
64
|
-
provider: FUSION_PROVIDER, model
|
|
108
|
+
provider: FUSION_PROVIDER, model,
|
|
65
109
|
})
|
|
66
110
|
failures.delete(agent.id)
|
|
67
111
|
}
|
|
@@ -101,7 +145,7 @@ export function installNativeFusionSelection(ctx: Context, input: {
|
|
|
101
145
|
// A newly installed exact-Agent interceptor can wrap this listener;
|
|
102
146
|
// its physical route is applied after this continuation returns.
|
|
103
147
|
// The catalog adapter itself cannot dispatch any upstream request.
|
|
104
|
-
if (!selected(agent)) throw new Error(
|
|
148
|
+
if (!selected(agent)) throw new Error(blocked(route?.model ?? FUSION_MODEL))
|
|
105
149
|
} else if (selected(agent)) throw new Error('先停止当前 Fusion 任务,再切换模型。')
|
|
106
150
|
return config
|
|
107
151
|
}, { prepend: true }),
|
|
@@ -109,12 +109,25 @@ export function workerToolBriefProblem(store: SqliteFusionStore, taskId: TaskId,
|
|
|
109
109
|
const message = native ?? (transport === undefined ? undefined
|
|
110
110
|
: events?.findLast(event => event.type === 'assistant/message'
|
|
111
111
|
&& event.data.message.content.some(block => block.type === 'tool-call' && block.name === transport)))
|
|
112
|
-
|
|
113
|
-
const row = store.readDocument(`worker-request:${exec.agent!.id}:${message.data.turn}:${message.data.step}`)?.value as {
|
|
112
|
+
type WorkerRequestRow = {
|
|
114
113
|
schemaVersion?: number; taskId?: string; sessionId?: string; turn?: number; step?: number; briefRevision?: number; workOrderId?: string
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
114
|
+
}
|
|
115
|
+
let row: WorkerRequestRow | undefined
|
|
116
|
+
if (message && message.type === 'assistant/message' && !message.data.interrupted) {
|
|
117
|
+
row = store.readDocument(`worker-request:${exec.agent!.id}:${message.data.turn}:${message.data.step}`)?.value as WorkerRequestRow | undefined
|
|
118
|
+
if (row?.schemaVersion !== 1 || row.taskId !== taskId || row.sessionId !== exec.agent!.id
|
|
119
|
+
|| row.turn !== message.data.turn || row.step !== message.data.step) return 'Worker tool has no captured brief identity'
|
|
120
|
+
} else if (transport !== undefined) {
|
|
121
|
+
const ids = store.listDocumentIds(`worker-request:${exec.agent!.id}:`)
|
|
122
|
+
const rows = ids
|
|
123
|
+
.map(id => store.readDocument(id)?.value as WorkerRequestRow | undefined)
|
|
124
|
+
.filter((r): r is NonNullable<WorkerRequestRow> => Boolean(r && r.schemaVersion === 1 && r.taskId === taskId && r.sessionId === exec.agent!.id))
|
|
125
|
+
rows.sort((a, b) => (a.turn ?? 0) - (b.turn ?? 0) || (a.step ?? 0) - (b.step ?? 0))
|
|
126
|
+
row = rows.at(-1)
|
|
127
|
+
if (!row) return 'Worker tool has no captured brief identity'
|
|
128
|
+
} else {
|
|
129
|
+
return 'Worker tool requires a complete native model request'
|
|
130
|
+
}
|
|
118
131
|
if (row.briefRevision !== revision) return 'A newer Lead brief superseded this request; process it before further tools or a report'
|
|
119
132
|
const order = store.load(taskId)?.currentWorkOrder
|
|
120
133
|
if (row.workOrderId !== order?.id && (row.workOrderId || order?.mode)) return 'A newer work order superseded this request; process its plan before further tools'
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { bi } from '../bilingual.js'
|
|
3
|
+
import { FUSION_MODEL, isFusionModel } from '../identity.js'
|
|
4
|
+
import { keepaliveChoice } from '../profile/keepalive.js'
|
|
5
|
+
import { compactorChoice } from '../profile/compactor.js'
|
|
6
|
+
import type { SqliteFusionStore } from '../task/sqlite-store.js'
|
|
7
|
+
import type { PairChoice, SettingsCatalog } from './settings.js'
|
|
8
|
+
import { modelProfileFromChoice, validatePairChoice } from './settings.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One saved combo: the model id the native menu advertises, the label beside
|
|
12
|
+
* it, and the pair that id freezes. The id is the durable identity — a session
|
|
13
|
+
* that selected it keeps its own frozen copy — so renaming never moves a combo.
|
|
14
|
+
*/
|
|
15
|
+
export interface PairEntry {
|
|
16
|
+
readonly id: string
|
|
17
|
+
readonly name: string
|
|
18
|
+
readonly pair: PairChoice
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The saved combos, in menu order. `auto` is the historical first combo. */
|
|
22
|
+
export interface PairsSnapshot {
|
|
23
|
+
readonly revision: number
|
|
24
|
+
readonly pairs: readonly PairEntry[]
|
|
25
|
+
/** True when these came from a pre-combo `settings:profile` document. */
|
|
26
|
+
readonly migrated: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Where the combo list lives; `settings:profile` stays readable for migration. */
|
|
30
|
+
export const PAIRS_DOCUMENT = 'settings:pairs'
|
|
31
|
+
const LEGACY_PROFILE_DOCUMENT = 'settings:profile'
|
|
32
|
+
|
|
33
|
+
/** Every menu entry costs one row in the native picker, so the list stays short. */
|
|
34
|
+
export const MAX_PAIRS = 6
|
|
35
|
+
export const MAX_PAIR_NAME = 40
|
|
36
|
+
/** The migrated first combo keeps the label the historical entry had. */
|
|
37
|
+
export const DEFAULT_PAIR_NAME = '自动'
|
|
38
|
+
|
|
39
|
+
export const profileIdOf = (model: string): string => `fusion-${model}`
|
|
40
|
+
|
|
41
|
+
/** The combo a frozen profile belongs to, or undefined for a non-combo profile. */
|
|
42
|
+
export function modelOfProfileId(id: string): string | undefined {
|
|
43
|
+
const model = id.startsWith('fusion-') ? id.slice('fusion-'.length) : undefined
|
|
44
|
+
return isFusionModel(model) ? model : undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const newModel = (taken: readonly string[]): string => {
|
|
48
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
49
|
+
const model = `pair-${randomUUID().replace(/-/g, '').slice(0, 8)}`
|
|
50
|
+
if (!taken.includes(model)) return model
|
|
51
|
+
}
|
|
52
|
+
throw new Error(bi('无法生成新的组合标识', 'Could not allocate a new combo id'))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function object(value: unknown): Record<string, unknown> | undefined {
|
|
56
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function route(value: unknown): PairChoice['lead'] | undefined {
|
|
60
|
+
const item = object(value)
|
|
61
|
+
if (!item || typeof item.provider !== 'string' || !item.provider.trim() || typeof item.model !== 'string' || !item.model.trim()) return undefined
|
|
62
|
+
return { provider: item.provider, model: item.model,
|
|
63
|
+
...(typeof item.reasoningEffort === 'string' && item.reasoningEffort.trim() ? { reasoningEffort: item.reasoningEffort } : {}) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read one stored pair without the Host catalog: a combo whose model has since
|
|
68
|
+
* been retired must stay visible (and editable) instead of hiding the list.
|
|
69
|
+
*/
|
|
70
|
+
function structuralPair(value: unknown): PairChoice | undefined {
|
|
71
|
+
const raw = object(value)
|
|
72
|
+
const lead = route(raw?.lead), worker = route(raw?.worker)
|
|
73
|
+
if (!raw || !lead || !worker) return undefined
|
|
74
|
+
const compactorRoute = route(object(raw.compactor)?.route)
|
|
75
|
+
const maxOutputTokens = object(raw.compactor)?.maxOutputTokens
|
|
76
|
+
let cacheKeepalive: PairChoice['cacheKeepalive']
|
|
77
|
+
try { cacheKeepalive = keepaliveChoice(raw.cacheKeepalive) } catch { cacheKeepalive = undefined }
|
|
78
|
+
let compactor: PairChoice['compactor']
|
|
79
|
+
try { compactor = compactorRoute ? compactorChoice({ route: compactorRoute, maxOutputTokens }) : undefined } catch { compactor = undefined }
|
|
80
|
+
return { lead, worker, ...(compactor === undefined ? {} : { compactor }), ...(cacheKeepalive === undefined ? {} : { cacheKeepalive }) }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A pre-combo profile document as the choice it was saved from. */
|
|
84
|
+
export function choiceFromStoredProfile(value: unknown): PairChoice | undefined {
|
|
85
|
+
const raw = object(value)
|
|
86
|
+
const pair = structuralPair(raw)
|
|
87
|
+
if (!raw || !pair) return undefined
|
|
88
|
+
if (raw.interactionMode === 'model-like') return pair
|
|
89
|
+
// Legacy profiles keep explicit per-role output limits in `context`.
|
|
90
|
+
const context = object(raw.context)
|
|
91
|
+
const outputTokens = Object.fromEntries((['lead', 'worker'] as const)
|
|
92
|
+
.map(role => [role, object(context?.[role])?.reserveOutputTokens] as const)
|
|
93
|
+
.filter(([, tokens]) => Number.isSafeInteger(tokens) && Number(tokens) > 0 && Number(tokens) !== 8_000)
|
|
94
|
+
.map(([role, tokens]) => [role, Number(tokens)]))
|
|
95
|
+
return Object.keys(outputTokens).length ? { ...pair, outputTokens } : pair
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parsePairs(value: unknown): PairEntry[] | undefined {
|
|
99
|
+
const raw = object(value)
|
|
100
|
+
if (!raw || raw.schemaVersion !== 1 || !Array.isArray(raw.pairs)) return undefined
|
|
101
|
+
const seen = new Set<string>()
|
|
102
|
+
return raw.pairs.flatMap((item): PairEntry[] => {
|
|
103
|
+
const entry = object(item)
|
|
104
|
+
const pair = structuralPair(entry?.pair)
|
|
105
|
+
if (!entry || !pair || typeof entry.id !== 'string' || !isFusionModel(entry.id) || seen.has(entry.id)) return []
|
|
106
|
+
if (typeof entry.name !== 'string' || !entry.name.trim() || entry.name.length > MAX_PAIR_NAME) return []
|
|
107
|
+
seen.add(entry.id)
|
|
108
|
+
return [{ id: entry.id, name: entry.name, pair }]
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The saved combos; a pre-combo installation migrates to one `auto` combo on read. */
|
|
113
|
+
export function readPairs(store: SqliteFusionStore): PairsSnapshot {
|
|
114
|
+
const document = store.readDocument(PAIRS_DOCUMENT)
|
|
115
|
+
if (document) {
|
|
116
|
+
const pairs = parsePairs(document.value)
|
|
117
|
+
if (!pairs?.length) throw new Error(bi('组合配置已损坏,请在 Fusion 页面重新保存', 'The combo list is unreadable; save it again from the Fusion page'))
|
|
118
|
+
return { revision: document.revision, pairs, migrated: false }
|
|
119
|
+
}
|
|
120
|
+
const legacy = choiceFromStoredProfile(store.readDocument(LEGACY_PROFILE_DOCUMENT)?.value)
|
|
121
|
+
// Revision 0 lets the first save create the document without a conflict.
|
|
122
|
+
return legacy ? { revision: 0, pairs: [{ id: FUSION_MODEL, name: DEFAULT_PAIR_NAME, pair: legacy }], migrated: true }
|
|
123
|
+
: { revision: 0, pairs: [], migrated: false }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function pairName(value: unknown, fallback: string): string {
|
|
127
|
+
if (typeof value !== 'string') return fallback
|
|
128
|
+
const name = value.trim()
|
|
129
|
+
if (!name) return fallback
|
|
130
|
+
if (name.length > MAX_PAIR_NAME) throw new Error(bi(`组合名称最多 ${MAX_PAIR_NAME} 个字符`, `A combo name is at most ${MAX_PAIR_NAME} characters`))
|
|
131
|
+
return name
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Validate the list a settings action proposes. Ids are assigned here, never by
|
|
136
|
+
* the client, so a stale tab cannot name a combo that is already frozen
|
|
137
|
+
* elsewhere; reordering only moves the menu rows.
|
|
138
|
+
*/
|
|
139
|
+
export function validatePairs(value: unknown, catalog: SettingsCatalog, existing: readonly PairEntry[]): PairEntry[] {
|
|
140
|
+
if (!Array.isArray(value) || !value.length) throw new Error(bi('请至少保留一个组合', 'Keep at least one combo'))
|
|
141
|
+
if (value.length > MAX_PAIRS) throw new Error(bi(`最多保存 ${MAX_PAIRS} 个组合`, `At most ${MAX_PAIRS} combos can be saved`))
|
|
142
|
+
const known = new Set(existing.map(pair => pair.id))
|
|
143
|
+
const taken = new Set<string>()
|
|
144
|
+
const names = new Set<string>()
|
|
145
|
+
return value.map((item, index) => {
|
|
146
|
+
const raw = object(item)
|
|
147
|
+
if (!raw) throw new Error(bi('组合配置无效', 'Invalid combo entry'))
|
|
148
|
+
// A frozen session keeps its own copy, so an id may be renamed or reordered
|
|
149
|
+
// freely; a new row (or a duplicate) gets an id this Host has never issued.
|
|
150
|
+
// `auto` is the reserved historical id, so the first combo may claim it even
|
|
151
|
+
// on a fresh installation: selections made before combos existed still name it.
|
|
152
|
+
const requested = typeof raw.id === 'string' ? raw.id : ''
|
|
153
|
+
const id = (known.has(requested) || requested === FUSION_MODEL) && !taken.has(requested) ? requested : newModel([...taken])
|
|
154
|
+
taken.add(id)
|
|
155
|
+
const name = pairName(raw.name, index === 0 ? DEFAULT_PAIR_NAME : `组合 ${index + 1}`)
|
|
156
|
+
if (names.has(name)) throw new Error(bi(`组合名称 "${name}" 重复`, `The combo name "${name}" is used twice`))
|
|
157
|
+
names.add(name)
|
|
158
|
+
return { id, name, pair: validatePairChoice(raw.pair, catalog) }
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function pairsDocument(pairs: readonly PairEntry[]): Record<string, unknown> {
|
|
163
|
+
return { schemaVersion: 1, pairs: pairs.map(({ id, name, pair }) => ({ id, name, pair })) }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Persist the list under one compare-and-swap revision. The first combo is
|
|
168
|
+
* mirrored into the pre-combo `settings:profile` document so an older tab (or a
|
|
169
|
+
* downgrade) still finds the pair it knows about.
|
|
170
|
+
*/
|
|
171
|
+
export function writePairs(store: SqliteFusionStore, expectedRevision: number, pairs: readonly PairEntry[]): number {
|
|
172
|
+
const mirror = pairs.find(pair => pair.id === FUSION_MODEL) ?? pairs[0]!
|
|
173
|
+
const legacy = store.readDocument(LEGACY_PROFILE_DOCUMENT)
|
|
174
|
+
return store.writeDocuments([
|
|
175
|
+
{ id: PAIRS_DOCUMENT, expectedRevision, value: pairsDocument(pairs) },
|
|
176
|
+
{ id: LEGACY_PROFILE_DOCUMENT, expectedRevision: legacy?.revision ?? 0, value: modelProfileFromChoice(mirror.pair, profileIdOf(mirror.id)) },
|
|
177
|
+
])[0]!
|
|
178
|
+
}
|
package/src/host/settings.ts
CHANGED
|
@@ -66,19 +66,23 @@ export function validatePairChoice(value: unknown, catalog: SettingsCatalog): Pa
|
|
|
66
66
|
/** New sessions can opt into this Worker cap. Frozen sessions keep their saved profile. */
|
|
67
67
|
export const WORKER_IMPLEMENTATION_OUTPUT_TOKENS = 128_000
|
|
68
68
|
|
|
69
|
-
export function profileFromChoice(pair: PairChoice): Record<string, unknown> {
|
|
69
|
+
export function profileFromChoice(pair: PairChoice, id = 'fusion-auto'): Record<string, unknown> {
|
|
70
70
|
const { outputTokens, ...routes } = pair
|
|
71
71
|
const version = outputTokens?.worker === WORKER_IMPLEMENTATION_OUTPUT_TOKENS ? '0.1.0-worker-128k' : '0.1.0-faithful'
|
|
72
|
-
return { schemaVersion: 1, id
|
|
72
|
+
return { schemaVersion: 1, id, version, enabled: true, quality: 'experimental',
|
|
73
73
|
...routes, workerUpgradePath: [], dataPolicyId: 'native-host-workspace-policy', evidenceCampaignIds: [],
|
|
74
74
|
...(outputTokens === undefined ? {} : { context: Object.fromEntries(Object.entries(outputTokens)
|
|
75
75
|
.map(([role, reserveOutputTokens]) => [role, { reserveOutputTokens }])) }) }
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Product defaults; explicit legacy/benchmark profiles retain their old contract.
|
|
80
|
+
* `id` names the combo; the first one keeps the historical `fusion-auto` so a
|
|
81
|
+
* profile frozen before combos existed keeps its identity.
|
|
82
|
+
*/
|
|
83
|
+
export function modelProfileFromChoice(pair: PairChoice, id = 'fusion-auto'): Record<string, unknown> {
|
|
80
84
|
const { outputTokens: _output, ...native } = pair
|
|
81
|
-
return { ...profileFromChoice(native), version: '0.2.0-enforced-v3', interactionMode: 'model-like', workflowPolicy: 'enforced-v3' }
|
|
85
|
+
return { ...profileFromChoice(native, id), version: '0.2.0-enforced-v3', interactionMode: 'model-like', workflowPolicy: 'enforced-v3' }
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
/** Keep existing default settings stable; expose explicit larger/smaller limits on reload. */
|
package/src/host/status.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { readModelControl } from './native-model-control.js'
|
|
2
|
+
import { readSandboxIntent, roleSeparated } from './native-role-sandbox.js'
|
|
3
|
+
import type { Phase, Role } from '../contracts.js'
|
|
2
4
|
import type { TaskState } from '../task/state.js'
|
|
3
5
|
import type { SqliteFusionStore } from '../task/sqlite-store.js'
|
|
4
6
|
import type { StatusIndex } from '../task/status-index.js'
|
|
@@ -27,6 +29,18 @@ const EN: Record<string, string> = {
|
|
|
27
29
|
}
|
|
28
30
|
const english = (text: string | null | undefined) => text == null ? text : EN[text] ?? text
|
|
29
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The role working right now: an in-flight request names it exactly (requests read
|
|
34
|
+
* newest first), and between requests the phase does ('DIRECT' is the Lead writing).
|
|
35
|
+
*/
|
|
36
|
+
function activeRole(requests: readonly { role: Role; purpose: string; outcome: string }[], phase: Phase): Role | null {
|
|
37
|
+
const running = requests.find(request => request.purpose === 'conversation' && request.outcome === 'entered')
|
|
38
|
+
if (running) return running.role
|
|
39
|
+
if (phase === 'WORKER_RUNNING' || phase === 'REWORK') return 'worker'
|
|
40
|
+
if (phase === 'PLANNING' || phase === 'REVIEWING' || phase === 'DIRECT') return 'lead'
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
export function taskStage(state: TaskState, checking: boolean, workerStepLimitReached = false, workerQuotaExhausted = false): Pick<NonNullable<FusionStatus['task']>, 'stage' | 'detail' | 'attention'> {
|
|
31
45
|
const control = state.control
|
|
32
46
|
const value = (stage: string, detail: string | null = null, attention = false) => ({ stage, detail, attention })
|
|
@@ -81,7 +95,8 @@ function readStatus(store: SqliteFusionStore, sessionId: string, index: StatusIn
|
|
|
81
95
|
const { checking, ...facts } = index.read(taskId)
|
|
82
96
|
const runtime = store.readDocument(`runtime:${taskId}`)?.value as { schemaVersion?: number; taskId?: string;
|
|
83
97
|
workerStepStop?: { workOrderId?: string; requests: number; limit: number };
|
|
84
|
-
workerFailure?: { workOrderId?: string; childId?: string; category?: string }
|
|
98
|
+
workerFailure?: { workOrderId?: string; childId?: string; category?: string };
|
|
99
|
+
takeover?: boolean } | undefined
|
|
85
100
|
const stop = runtime?.workerStepStop
|
|
86
101
|
const workerStepLimitReached = runtime?.schemaVersion === 1 && runtime.taskId === taskId && Boolean(stop
|
|
87
102
|
&& state.currentWorkOrder && stop.workOrderId === state.currentWorkOrder.id && stop.limit > 0 && stop.requests >= stop.limit)
|
|
@@ -98,10 +113,18 @@ function readStatus(store: SqliteFusionStore, sessionId: string, index: StatusIn
|
|
|
98
113
|
const pendingDelivery = modelControl?.operation && ['prepared', 'dispatching'].includes(modelControl.operation.state)
|
|
99
114
|
? { stage: '等待检查', detail: '继续消息的投递结果尚未确认。检查会话后使用 /fusion recover 提交检查记录。', attention: true } : {}
|
|
100
115
|
const stage = { ...taskStage(state, checking, workerStepLimitReached, workerQuotaExhausted), ...modelStage, ...pendingDelivery }
|
|
116
|
+
// The composer's own permission control follows the conversation (Lead) session,
|
|
117
|
+
// where Fusion's read-only confinement matches no configured preset. Report the
|
|
118
|
+
// per-role modes so the UI can name the permission of the role that is working.
|
|
119
|
+
const intent = roleSeparated(binding) ? readSandboxIntent(store, sessionId) : undefined
|
|
120
|
+
const takeover = runtime?.schemaVersion === 1 && runtime.taskId === taskId && runtime.takeover === true
|
|
121
|
+
const permissions = intent && { lead: takeover ? intent.mode : 'read-only' as const,
|
|
122
|
+
worker: intent.mode, active: activeRole(facts.requests, state.phase) }
|
|
101
123
|
return { ...base, task: { id: taskId, revision: state.revision, ...stage, stageEn: english(stage.stage)!,
|
|
102
124
|
detailEn: 'detailEn' in stage && stage.detailEn ? stage.detailEn : english(stage.detail) ?? null, ...(modelControl ? { modelControl } : {}), verification: state.verification,
|
|
103
125
|
workerId: state.acceptedChild ?? binding.workerId ?? null,
|
|
104
126
|
models: { lead: modelControl?.routes.lead ?? binding.profile.lead, worker: modelControl?.routes.worker ?? binding.profile.worker, compactor: binding.profile.compactor?.route ?? null },
|
|
127
|
+
...(permissions ? { permissions } : {}),
|
|
105
128
|
...facts,
|
|
106
129
|
automatedChecks: Array.isArray((runtime as { checks?: unknown[] } | undefined)?.checks) && runtime?.taskId === taskId
|
|
107
130
|
? (runtime as { checks: unknown[] }).checks.length : null,
|
package/src/identity.ts
CHANGED
|
@@ -33,12 +33,30 @@ export const PLUGIN_NAME = 'dsh-fusion'
|
|
|
33
33
|
/** The plugin id and package name this project used before the rename. */
|
|
34
34
|
export const LEGACY_PLUGIN_NAME = 'dsh-model-fusion'
|
|
35
35
|
|
|
36
|
-
/** Registered provider route for the Fusion model
|
|
36
|
+
/** Registered provider route for the Fusion model entries. */
|
|
37
37
|
export const FUSION_PROVIDER = 'dsh-fusion'
|
|
38
38
|
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* The model id of the combo every installation starts with.
|
|
41
|
+
*
|
|
42
|
+
* It is a pair id like any other; keeping this exact spelling for the first
|
|
43
|
+
* combo means a durable `model/selection` written before combos existed still
|
|
44
|
+
* names a combo that exists.
|
|
45
|
+
*/
|
|
40
46
|
export const FUSION_MODEL = 'auto'
|
|
41
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The model ids this provider advertises: `auto` plus one generated
|
|
50
|
+
* `pair-xxxxxxxx` per additional combo. The browser bundle inlines this module,
|
|
51
|
+
* so the namespace shape is fixed here rather than read from a settings
|
|
52
|
+
* document at import time.
|
|
53
|
+
*/
|
|
54
|
+
const FUSION_MODEL_PATTERN = /^(?:auto|pair-[a-z0-9]{6,12})$/
|
|
55
|
+
|
|
56
|
+
/** Whether a model id names a Fusion combo on this provider. */
|
|
57
|
+
export const isFusionModel = (model: string | undefined): boolean =>
|
|
58
|
+
typeof model === 'string' && FUSION_MODEL_PATTERN.test(model)
|
|
59
|
+
|
|
42
60
|
/**
|
|
43
61
|
* The provider route this plugin registered before it was renamed from
|
|
44
62
|
* `dsh-model-fusion`. Sessions created then still name it in their durable
|
|
@@ -51,26 +69,31 @@ export const LEGACY_FUSION_PROVIDER = 'dsh-model-fusion'
|
|
|
51
69
|
export const isFusionProvider = (provider: string | undefined): boolean =>
|
|
52
70
|
provider === FUSION_PROVIDER || provider === LEGACY_FUSION_PROVIDER
|
|
53
71
|
|
|
54
|
-
/** Whether a route names
|
|
72
|
+
/** Whether a route names a Fusion combo, under either provider spelling. */
|
|
55
73
|
export const isFusionSelection = (route: { provider?: string; model?: string } | undefined): boolean =>
|
|
56
|
-
isFusionProvider(route?.provider) && route?.model
|
|
74
|
+
isFusionProvider(route?.provider) && isFusionModel(route?.model)
|
|
57
75
|
|
|
58
76
|
/**
|
|
59
|
-
* How a native model-switch notice names
|
|
77
|
+
* How a native model-switch notice names a combo: `provider/model`, or the bare
|
|
78
|
+
* model when both sides share this provider (switching from one combo to
|
|
79
|
+
* another).
|
|
60
80
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
81
|
+
* The notice is a pseudo-change: it compares the virtual menu route with the
|
|
82
|
+
* physical Lead route that actually dispatches, so it would otherwise repeat on
|
|
83
|
+
* every Fusion step. Both provider spellings are accepted because a session
|
|
84
|
+
* that switched to Fusion before the rename carries the old one in its durable
|
|
85
|
+
* log; any combo id matches, since combos are not physical models.
|
|
64
86
|
*/
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
87
|
+
const FUSION_SELECTION_LABEL_PATTERN = /(?:(?:dsh-fusion|dsh-model-fusion)\/|→ )(?:auto|pair-[a-z0-9]{6,12})$/
|
|
88
|
+
|
|
89
|
+
/** Whether a switch-notice summary describes a Fusion combo as either side. */
|
|
90
|
+
export const isFusionSelectionLabel = (summary: string | undefined): boolean =>
|
|
91
|
+
typeof summary === 'string' && FUSION_SELECTION_LABEL_PATTERN.test(summary)
|
|
69
92
|
|
|
70
93
|
/**
|
|
71
|
-
* Whether a route already names
|
|
72
|
-
* intent that fails this is rewritten before the next request is
|
|
73
|
-
* the legacy spelling is a read-side alias, not a route
|
|
94
|
+
* Whether a route already names a combo under its current provider spelling. A
|
|
95
|
+
* durable intent that fails this is rewritten before the next request is
|
|
96
|
+
* assembled; the legacy spelling is a read-side alias, not a dispatching route.
|
|
74
97
|
*/
|
|
75
98
|
export const isCurrentFusionSelection = (route: { provider?: string; model?: string } | undefined): boolean =>
|
|
76
|
-
route?.provider === FUSION_PROVIDER && route.model
|
|
99
|
+
route?.provider === FUSION_PROVIDER && isFusionModel(route.model)
|
package/src/status.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ControlView } from './host/native-model-control.js'
|
|
2
|
-
import type { PhysicalRoute, Role, TokenMeasurement, VerificationLevel } from './contracts.js'
|
|
2
|
+
import type { PhysicalRoute, Role, SandboxMode, TokenMeasurement, VerificationLevel } from './contracts.js'
|
|
3
3
|
|
|
4
4
|
/** Bounded, read-only UI projection. No prompts, tool arguments or credentials. */
|
|
5
5
|
export interface FusionStatus {
|
|
@@ -20,6 +20,22 @@ export interface FusionStatus {
|
|
|
20
20
|
workerId: string | null
|
|
21
21
|
modelControl?: ControlView
|
|
22
22
|
models: { lead: PhysicalRoute; worker: PhysicalRoute; compactor: PhysicalRoute | null }
|
|
23
|
+
/**
|
|
24
|
+
* Role-separated sandbox modes, present while Fusion confines the Lead. The
|
|
25
|
+
* composer's own permission control follows the conversation (Lead) session,
|
|
26
|
+
* so the UI shows these instead of the derived preset name.
|
|
27
|
+
*/
|
|
28
|
+
permissions?: {
|
|
29
|
+
/**
|
|
30
|
+
* What the Lead's own work is confined to. The session is widened only for the
|
|
31
|
+
* checks Fusion runs itself and for the Lead during a Host-unlocked takeover.
|
|
32
|
+
*/
|
|
33
|
+
lead: SandboxMode
|
|
34
|
+
/** What the Sidekick runs under: the mode the user chose for this conversation. */
|
|
35
|
+
worker: SandboxMode
|
|
36
|
+
/** The role working right now; null while the task waits. */
|
|
37
|
+
active: Role | null
|
|
38
|
+
}
|
|
23
39
|
usage: {
|
|
24
40
|
calls: number
|
|
25
41
|
finalCalls: number
|