dsh-harbor-evolution 0.7.2 → 0.8.0
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/README.md +16 -4
- package/index.js +72 -6
- package/lib/candidate.js +58 -5
- package/lib/client.js +283 -63
- package/lib/dashboard.js +307 -34
- package/lib/evolution.js +328 -20
- package/lib/model-runtime.js +53 -8
- package/lib/runtime-identity.js +7 -0
- package/lib/service.js +187 -33
- package/lib/session-diagnostic.js +320 -0
- package/lib/session-materializer.js +194 -0
- package/lib/session-projection.js +161 -0
- package/lib/session-redaction.js +311 -0
- package/lib/session-selection.js +294 -0
- package/lib/setup.js +11 -5
- package/lib/version.js +128 -0
- package/lib/web.js +5 -1
- package/package.json +13 -3
- package/schemas/dsh-session-observation.schema.json +69 -0
- package/schemas/evaluation-result-v2.schema.json +45 -0
- package/schemas/historical-evaluation-context.schema.json +66 -0
- package/schemas/historical-evaluation-summary.schema.json +49 -0
- package/schemas/historical-generation-batch.schema.json +76 -0
- package/skills/evolve-agent-with-harbor/SKILL.md +127 -13
- package/skills/evolve-agent-with-harbor/evals/evals.json +57 -9
- package/skills/evolve-agent-with-harbor/references/evaluator-upgrade.md +34 -1
- package/skills/evolve-agent-with-harbor/references/initialization.md +9 -2
package/lib/service.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { access } from 'node:fs/promises'
|
|
1
|
+
import { access, stat } from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
|
|
4
|
+
import { loadModelBinding } from './candidate.js'
|
|
5
|
+
|
|
4
6
|
import {
|
|
7
|
+
discoverWorkspaceConfigs,
|
|
5
8
|
readComparison,
|
|
6
9
|
readDashboardSnapshot,
|
|
7
10
|
readDatasetPreview,
|
|
@@ -16,6 +19,7 @@ import {
|
|
|
16
19
|
compareCandidates,
|
|
17
20
|
initializeGroundTruth,
|
|
18
21
|
initializeProject,
|
|
22
|
+
initializeQuickDiagnostic,
|
|
19
23
|
inspectEvaluator,
|
|
20
24
|
previewContext,
|
|
21
25
|
readEvaluation,
|
|
@@ -25,7 +29,9 @@ import {
|
|
|
25
29
|
snapshot,
|
|
26
30
|
updateEvaluator,
|
|
27
31
|
validateDataset,
|
|
32
|
+
resolveWithin,
|
|
28
33
|
} from './evolution.js'
|
|
34
|
+
import { createVersionChecker } from './version.js'
|
|
29
35
|
|
|
30
36
|
export async function resolveEvaluatorStackPath(config, governance, explicitPath) {
|
|
31
37
|
if (explicitPath) return explicitPath
|
|
@@ -49,9 +55,54 @@ export async function resolveEvaluatorStackPath(config, governance, explicitPath
|
|
|
49
55
|
/** One Host-side boundary shared by Agent tools and the Web dashboard. */
|
|
50
56
|
export class EvolutionService {
|
|
51
57
|
constructor(config, metadata = {}, modelRuntime) {
|
|
52
|
-
this.config = config
|
|
58
|
+
this.config = { jobsDir: 'jobs', ...config }
|
|
53
59
|
this.metadata = metadata
|
|
54
60
|
this.modelRuntime = modelRuntime
|
|
61
|
+
this.versionChecker = metadata.versionChecker ?? createVersionChecker()
|
|
62
|
+
this.projectRoots = new Map()
|
|
63
|
+
this.workspaceConfigs = new Map()
|
|
64
|
+
this.activeProjectRoot = path.resolve(this.config.projectRoot)
|
|
65
|
+
this._registerProjectRoot(this.activeProjectRoot, metadata.projectRootSource ?? 'configured')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_registerProjectRoot(projectRoot, source) {
|
|
69
|
+
const resolved = path.resolve(projectRoot)
|
|
70
|
+
this.projectRoots.set(resolved, { projectRoot: resolved, source, activatedAt: new Date().toISOString() })
|
|
71
|
+
this.activeProjectRoot = resolved
|
|
72
|
+
return this.projectRoots.get(resolved)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async _refreshWorkspaces() {
|
|
76
|
+
const discovered = []
|
|
77
|
+
this.workspaceConfigs.clear()
|
|
78
|
+
for (const [identity, root] of this.projectRoots.entries()) {
|
|
79
|
+
try {
|
|
80
|
+
const details = await stat(root.projectRoot)
|
|
81
|
+
if (!details.isDirectory()) throw new Error('not a directory')
|
|
82
|
+
} catch {
|
|
83
|
+
this.projectRoots.delete(identity)
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
const configs = await discoverWorkspaceConfigs({ ...this.config, projectRoot: root.projectRoot })
|
|
87
|
+
for (const config of configs) {
|
|
88
|
+
const value = { ...config, projectRootSource: root.source }
|
|
89
|
+
this.workspaceConfigs.set(config.workspaceId, value)
|
|
90
|
+
discovered.push(value)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return discovered
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async _webContext(args = {}) {
|
|
97
|
+
const workspaces = await this._refreshWorkspaces()
|
|
98
|
+
const requested = String(args.workspace ?? '').trim()
|
|
99
|
+
let config = requested ? this.workspaceConfigs.get(requested) : undefined
|
|
100
|
+
if (requested && !config) throw new Error('Workspace is unavailable; reload Harbor and select an active workspace')
|
|
101
|
+
config ??= workspaces.find(item => item.projectRoot === this.activeProjectRoot && item.workspaceRoot === '.')
|
|
102
|
+
?? workspaces.find(item => item.projectRoot === this.activeProjectRoot)
|
|
103
|
+
?? workspaces[0]
|
|
104
|
+
if (!config) throw new Error('No Harbor workspace is available')
|
|
105
|
+
return { config, workspaces }
|
|
55
106
|
}
|
|
56
107
|
|
|
57
108
|
snapshot(args) {
|
|
@@ -62,8 +113,22 @@ export class EvolutionService {
|
|
|
62
113
|
return initializeProject(this.config, args)
|
|
63
114
|
}
|
|
64
115
|
|
|
116
|
+
quickDiagnostic(args) {
|
|
117
|
+
return initializeQuickDiagnostic(this.config, args)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async _resolveCandidateModel(args) {
|
|
121
|
+
const candidatePath = resolveWithin(
|
|
122
|
+
this.config.projectRoot,
|
|
123
|
+
args.candidatePath,
|
|
124
|
+
'candidatePath',
|
|
125
|
+
)
|
|
126
|
+
const pinnedBinding = await loadModelBinding(candidatePath)
|
|
127
|
+
return this.modelRuntime.resolve(args, pinnedBinding)
|
|
128
|
+
}
|
|
129
|
+
|
|
65
130
|
async run(args) {
|
|
66
|
-
const candidateModelBinding = await this.
|
|
131
|
+
const candidateModelBinding = await this._resolveCandidateModel(args)
|
|
67
132
|
return runEvaluation(this.config, { ...args, candidateModelBinding }, this.modelRuntime)
|
|
68
133
|
}
|
|
69
134
|
|
|
@@ -85,7 +150,7 @@ export class EvolutionService {
|
|
|
85
150
|
}
|
|
86
151
|
|
|
87
152
|
async doctor(args) {
|
|
88
|
-
const candidateModelBinding = await this.
|
|
153
|
+
const candidateModelBinding = await this._resolveCandidateModel(args)
|
|
89
154
|
const result = await runDoctor(this.config, args)
|
|
90
155
|
return { ...result, candidate_model_binding: candidateModelBinding }
|
|
91
156
|
}
|
|
@@ -95,54 +160,141 @@ export class EvolutionService {
|
|
|
95
160
|
}
|
|
96
161
|
|
|
97
162
|
async previewContext(args) {
|
|
98
|
-
const candidateModelBinding = await this.
|
|
163
|
+
const candidateModelBinding = await this._resolveCandidateModel(args)
|
|
99
164
|
return previewContext(this.config, { ...args, candidateModelBinding })
|
|
100
165
|
}
|
|
101
166
|
|
|
102
|
-
dashboard() {
|
|
103
|
-
|
|
167
|
+
async dashboard(args = {}) {
|
|
168
|
+
const { config, workspaces } = await this._webContext(args)
|
|
169
|
+
return readDashboardSnapshot(config, {
|
|
170
|
+
...this.metadata,
|
|
171
|
+
projectRootSource: config.projectRootSource,
|
|
172
|
+
workspaces: workspaces.map(item => ({
|
|
173
|
+
id: item.workspaceId,
|
|
174
|
+
label: item.workspaceLabel,
|
|
175
|
+
root: item.workspaceRoot,
|
|
176
|
+
projectRoot: item.projectRoot,
|
|
177
|
+
jobsDir: item.jobsDir,
|
|
178
|
+
stackPath: item.stackPath,
|
|
179
|
+
source: item.projectRootSource,
|
|
180
|
+
})),
|
|
181
|
+
}, args)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async version(args = {}) {
|
|
185
|
+
let config = this.config
|
|
186
|
+
if (args.workspace) ({ config } = await this._webContext(args))
|
|
187
|
+
else {
|
|
188
|
+
try { ({ config } = await this._webContext(args)) } catch {}
|
|
189
|
+
}
|
|
190
|
+
return this.versionChecker({
|
|
191
|
+
currentVersion: this.metadata.pluginVersion ?? 'development',
|
|
192
|
+
projectRoot: config.projectRoot,
|
|
193
|
+
refresh: args.refresh === true || args.refresh === 'true',
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async modelBinding() {
|
|
198
|
+
const binding = await this.modelRuntime.currentBinding()
|
|
199
|
+
return {
|
|
200
|
+
schema_version: 1,
|
|
201
|
+
scope: 'new-candidate',
|
|
202
|
+
candidate_model_binding: binding,
|
|
203
|
+
transport: 'dsh-host-broker',
|
|
204
|
+
protocol: 'dsh-host-model-gateway/v1',
|
|
205
|
+
credentials: {
|
|
206
|
+
mode: 'host-broker-only',
|
|
207
|
+
note: 'The Candidate receives only a short-lived Job capability. Host OAuth and API credentials never enter the Candidate or Harbor artifacts.',
|
|
208
|
+
},
|
|
209
|
+
note: 'Write candidate_model_binding to model-binding.json before snapshotting. Later chat-model changes do not rewrite this Candidate.',
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
activateProjectRoot(requested, source = 'agent-session') {
|
|
214
|
+
if (!path.isAbsolute(requested)) throw new Error('projectRoot must be an absolute directory path')
|
|
215
|
+
const resolved = path.resolve(requested)
|
|
216
|
+
this._registerProjectRoot(resolved, source)
|
|
217
|
+
return {
|
|
218
|
+
projectRoot: resolved,
|
|
219
|
+
reloaded: true,
|
|
220
|
+
source,
|
|
221
|
+
scope: 'Web Workbench only; Agent tools remain isolated to each calling session working directory.',
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async setProjectRoot(args) {
|
|
226
|
+
const requested = String(args?.projectRoot ?? '').trim()
|
|
227
|
+
if (!path.isAbsolute(requested)) throw new Error('projectRoot must be an absolute directory path')
|
|
228
|
+
const resolved = path.resolve(requested)
|
|
229
|
+
const details = await stat(resolved)
|
|
230
|
+
if (!details.isDirectory()) throw new Error('projectRoot must point to an existing directory')
|
|
231
|
+
return this.activateProjectRoot(resolved, 'manual')
|
|
104
232
|
}
|
|
105
233
|
|
|
106
|
-
job(args) {
|
|
107
|
-
|
|
234
|
+
async job(args) {
|
|
235
|
+
const { config } = await this._webContext(args)
|
|
236
|
+
return readJobDetail(config, args)
|
|
108
237
|
}
|
|
109
238
|
|
|
110
|
-
trials(args) {
|
|
111
|
-
|
|
239
|
+
async trials(args) {
|
|
240
|
+
const { config } = await this._webContext(args)
|
|
241
|
+
return readTrialsPage(config, args)
|
|
112
242
|
}
|
|
113
243
|
|
|
114
|
-
trial(args) {
|
|
115
|
-
|
|
244
|
+
async trial(args) {
|
|
245
|
+
const { config } = await this._webContext(args)
|
|
246
|
+
return readTrialDetail(config, args)
|
|
116
247
|
}
|
|
117
248
|
|
|
118
|
-
dataset(args) {
|
|
119
|
-
|
|
249
|
+
async dataset(args) {
|
|
250
|
+
const { config } = await this._webContext(args)
|
|
251
|
+
return readDatasetPreview(config, args)
|
|
120
252
|
}
|
|
121
253
|
|
|
122
|
-
progress(args) {
|
|
123
|
-
|
|
254
|
+
async progress(args) {
|
|
255
|
+
const { config } = await this._webContext(args)
|
|
256
|
+
return readJobProgress(config, args)
|
|
124
257
|
}
|
|
125
258
|
|
|
126
|
-
comparison(args) {
|
|
127
|
-
|
|
259
|
+
async comparison(args) {
|
|
260
|
+
const { config } = await this._webContext(args)
|
|
261
|
+
return readComparison(config, args)
|
|
128
262
|
}
|
|
129
263
|
|
|
130
264
|
async governance(args) {
|
|
131
|
-
const
|
|
265
|
+
const { config } = await this._webContext(args)
|
|
266
|
+
const governance = await readEvaluatorGovernance(config, args)
|
|
132
267
|
try {
|
|
133
|
-
const stackPath = await resolveEvaluatorStackPath(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
268
|
+
const stackPath = await resolveEvaluatorStackPath(config, governance, args.stackPath)
|
|
269
|
+
const current = await inspectEvaluator(config, { ...args, stackPath })
|
|
270
|
+
const historicalEvaluator = governance.components?.evaluator
|
|
271
|
+
const identityMatches = current.stack?.id === governance.stackIdentity.id
|
|
272
|
+
&& current.stack?.version === governance.stackIdentity.version
|
|
273
|
+
&& current.evaluator?.evaluator_id === historicalEvaluator?.id
|
|
274
|
+
&& current.evaluator?.version === historicalEvaluator?.version
|
|
275
|
+
&& current.evaluator?.digest === historicalEvaluator?.digest
|
|
276
|
+
if (!identityMatches) {
|
|
277
|
+
governance.evaluatorInterface = {
|
|
278
|
+
error: 'The live Evaluator no longer matches this historical Job. Historical sources remain readable, but editing is disabled until you open a Job with the current Stack identity.',
|
|
279
|
+
}
|
|
280
|
+
governance.editingPolicy.identityMatch = false
|
|
281
|
+
} else {
|
|
282
|
+
governance.evaluatorInterface = current
|
|
283
|
+
governance.editingPolicy.browserWriteEnabled = true
|
|
284
|
+
governance.editingPolicy.identityMatch = true
|
|
285
|
+
governance.editingPolicy.stackPath = current.stack?.path
|
|
286
|
+
governance.editingPolicy.saveBehavior = 'Update one descriptor-authorized file with optimistic concurrency and create new Evaluator and Stack identities.'
|
|
287
|
+
}
|
|
138
288
|
} catch (error) {
|
|
139
289
|
governance.evaluatorInterface = { error: error instanceof Error ? error.message : String(error) }
|
|
290
|
+
governance.editingPolicy.identityMatch = false
|
|
140
291
|
}
|
|
141
292
|
return governance
|
|
142
293
|
}
|
|
143
294
|
|
|
144
|
-
evaluator(args) {
|
|
145
|
-
|
|
295
|
+
async evaluator(args) {
|
|
296
|
+
const config = args.workspace ? (await this._webContext(args)).config : this.config
|
|
297
|
+
return updateEvaluator(config, args)
|
|
146
298
|
}
|
|
147
299
|
|
|
148
300
|
evaluatorInspect(args) {
|
|
@@ -158,13 +310,15 @@ export class EvolutionService {
|
|
|
158
310
|
}
|
|
159
311
|
|
|
160
312
|
async meta(args) {
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
313
|
+
const { config } = await this._webContext(args)
|
|
314
|
+
const governance = await readEvaluatorGovernance(config, args)
|
|
315
|
+
const stackPath = await resolveEvaluatorStackPath(config, governance, args.stackPath)
|
|
316
|
+
if (!stackPath) return readMetaEvaluation(config, args)
|
|
317
|
+
const stackDirectory = path.dirname(path.resolve(config.projectRoot, stackPath))
|
|
165
318
|
const evaluationRoot = path.dirname(stackDirectory)
|
|
166
|
-
return readMetaEvaluation(
|
|
167
|
-
|
|
319
|
+
return readMetaEvaluation(config, {
|
|
320
|
+
...args,
|
|
321
|
+
evaluationRoot: path.relative(config.projectRoot, evaluationRoot),
|
|
168
322
|
})
|
|
169
323
|
}
|
|
170
324
|
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { buildHistoricalGenerationBatch, writePrivateHistoricalBatch } from './session-materializer.js'
|
|
4
|
+
import { buildSessionObservation } from './session-redaction.js'
|
|
5
|
+
import {
|
|
6
|
+
canonicalDigest,
|
|
7
|
+
SessionSelectionTokenStore,
|
|
8
|
+
selectRecentSessions,
|
|
9
|
+
verifySessionSnapshot,
|
|
10
|
+
} from './session-selection.js'
|
|
11
|
+
|
|
12
|
+
function capability(ctx, name) {
|
|
13
|
+
try {
|
|
14
|
+
return typeof ctx?.get === 'function' ? ctx.get(name) ?? ctx[name] : ctx?.[name]
|
|
15
|
+
} catch {
|
|
16
|
+
return ctx?.[name]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function executionIdentity(exec) {
|
|
21
|
+
const header = exec?.agent?.session?.header
|
|
22
|
+
if (typeof header?.cwd !== 'string' || !path.isAbsolute(header.cwd)) {
|
|
23
|
+
throw new Error('Harbor Session tools require an Agent Session with an absolute working directory')
|
|
24
|
+
}
|
|
25
|
+
if (typeof header.id !== 'string' || !header.id) {
|
|
26
|
+
throw new Error('Harbor Session tools require the calling Agent Session identity')
|
|
27
|
+
}
|
|
28
|
+
return { projectRoot: path.resolve(header.cwd), ownerSessionId: header.id }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function feedbackItems(result) {
|
|
32
|
+
return result?.ok === true && Array.isArray(result.value?.items) ? result.value.items : []
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readFeedback(service, sessionId) {
|
|
36
|
+
if (!service || typeof service.list !== 'function') return { items: [], available: false, failed: false }
|
|
37
|
+
try {
|
|
38
|
+
const result = await service.list({ sessionId })
|
|
39
|
+
return {
|
|
40
|
+
items: feedbackItems(result),
|
|
41
|
+
available: result?.ok === true,
|
|
42
|
+
failed: result?.ok === false,
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
return { items: [], available: true, failed: true }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function withoutRawEvents(selection) {
|
|
50
|
+
const { events: _events, ...rest } = selection
|
|
51
|
+
return rest
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseCreatedAfter(value) {
|
|
55
|
+
if (value === undefined || value === null || value === '') return undefined
|
|
56
|
+
if (typeof value !== 'string') {
|
|
57
|
+
throw new Error('SESSION_CREATED_AFTER_INVALID: createdAfter must be an ISO-8601 string')
|
|
58
|
+
}
|
|
59
|
+
const parsed = Date.parse(value)
|
|
60
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
61
|
+
throw new Error('SESSION_CREATED_AFTER_INVALID: createdAfter must be a valid ISO-8601 timestamp')
|
|
62
|
+
}
|
|
63
|
+
return parsed
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function feedbackDigest(observation) {
|
|
67
|
+
return canonicalDigest(
|
|
68
|
+
{
|
|
69
|
+
available: observation.available,
|
|
70
|
+
failed: observation.failed,
|
|
71
|
+
items: observation.items,
|
|
72
|
+
},
|
|
73
|
+
'harbor-dsh-session-feedback-snapshot-v1',
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function requestedJudge(args) {
|
|
78
|
+
if (Boolean(args.evaluatorProvider) !== Boolean(args.evaluatorModel)) {
|
|
79
|
+
throw new Error('EVALUATOR_MODEL_INVALID: evaluatorProvider and evaluatorModel must be supplied together')
|
|
80
|
+
}
|
|
81
|
+
if (args.evaluatorReasoningEffort !== undefined && !args.evaluatorProvider) {
|
|
82
|
+
throw new Error('EVALUATOR_MODEL_INVALID: evaluatorReasoningEffort requires an explicit evaluatorProvider and evaluatorModel')
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
candidateProvider: args.evaluatorProvider,
|
|
86
|
+
candidateModel: args.evaluatorModel,
|
|
87
|
+
candidateReasoningEffort: args.evaluatorReasoningEffort,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function resolveJudge(modelRuntime, args) {
|
|
92
|
+
const requested = requestedJudge(args)
|
|
93
|
+
if (requested.candidateProvider) return modelRuntime.resolve(requested)
|
|
94
|
+
if (typeof modelRuntime.resolveCurrent === 'function') {
|
|
95
|
+
return modelRuntime.resolveCurrent()
|
|
96
|
+
}
|
|
97
|
+
const current = await modelRuntime.currentBinding()
|
|
98
|
+
return modelRuntime.resolve({
|
|
99
|
+
candidateProvider: current.provider,
|
|
100
|
+
candidateModel: current.model,
|
|
101
|
+
candidateReasoningEffort: current.reasoning_effort,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function judgeIdentity(binding, selections) {
|
|
106
|
+
if (!binding?.provider || !binding?.model) {
|
|
107
|
+
throw new Error('EVALUATOR_MODEL_INVALID: Judge resolution returned no provider/model identity')
|
|
108
|
+
}
|
|
109
|
+
const route = `${binding.provider}/${binding.model}`
|
|
110
|
+
const generatorRoutes = new Set(
|
|
111
|
+
selections.flatMap(item => item.index.modelRoutes.map(value => `${value.provider}/${value.model}`)),
|
|
112
|
+
)
|
|
113
|
+
return {
|
|
114
|
+
evaluator: { id: 'dsh-session-historical-evaluator', version: '1.0.0' },
|
|
115
|
+
judge: {
|
|
116
|
+
provider: binding.provider,
|
|
117
|
+
model: binding.model,
|
|
118
|
+
...(binding.reasoning_effort === undefined
|
|
119
|
+
? {}
|
|
120
|
+
: { reasoning_effort: binding.reasoning_effort }),
|
|
121
|
+
transport: 'dsh-host-broker',
|
|
122
|
+
protocol: 'dsh-host-model-gateway/v1',
|
|
123
|
+
},
|
|
124
|
+
coupling: generatorRoutes.size === 0
|
|
125
|
+
? 'generator-model-unknown-diagnostic-only'
|
|
126
|
+
: generatorRoutes.has(route)
|
|
127
|
+
? 'same-host-model-diagnostic-only'
|
|
128
|
+
: 'independent-historical-judge',
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export class SessionDiagnosticService {
|
|
133
|
+
constructor({
|
|
134
|
+
ctx,
|
|
135
|
+
config,
|
|
136
|
+
modelRuntime,
|
|
137
|
+
runHistoricalEvaluation,
|
|
138
|
+
tokenStore,
|
|
139
|
+
now = () => new Date(),
|
|
140
|
+
}) {
|
|
141
|
+
this.ctx = ctx
|
|
142
|
+
this.config = config
|
|
143
|
+
this.modelRuntime = modelRuntime
|
|
144
|
+
this.runHistoricalEvaluation = runHistoricalEvaluation
|
|
145
|
+
this.tokens = tokenStore ?? new SessionSelectionTokenStore()
|
|
146
|
+
this.now = now
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async preview(args = {}, exec) {
|
|
150
|
+
const identity = executionIdentity(exec)
|
|
151
|
+
const sessionQuery = capability(this.ctx, 'sessionQuery')
|
|
152
|
+
const limit = args.limit ?? 10
|
|
153
|
+
const createdAfter = parseCreatedAfter(args.createdAfter)
|
|
154
|
+
const result = await selectRecentSessions({
|
|
155
|
+
sessionQuery,
|
|
156
|
+
projectRoot: identity.projectRoot,
|
|
157
|
+
currentSessionId: identity.ownerSessionId,
|
|
158
|
+
limit,
|
|
159
|
+
maxSessionReads: this.config.sessionMaxReads ?? 100,
|
|
160
|
+
concurrency: this.config.sessionReadConcurrency ?? 4,
|
|
161
|
+
createdAfter,
|
|
162
|
+
signal: exec?.signal,
|
|
163
|
+
})
|
|
164
|
+
if (!result.selected.length) {
|
|
165
|
+
throw new Error('NO_ELIGIBLE_SESSIONS: no completed top-level DSH Sessions with direct human input and assistant output were found in this workspace')
|
|
166
|
+
}
|
|
167
|
+
const judgeBinding = await resolveJudge(this.modelRuntime, args)
|
|
168
|
+
const includeFeedback = args.includeFeedback !== false
|
|
169
|
+
const feedback = capability(this.ctx, 'messageFeedback')
|
|
170
|
+
const feedbackObservations = includeFeedback
|
|
171
|
+
? await Promise.all(result.selected.map(item => readFeedback(feedback, item.rawSessionId)))
|
|
172
|
+
: result.selected.map(() => ({ items: [], available: false, failed: false }))
|
|
173
|
+
const feedbackSnapshots = feedbackObservations.map(observation => ({
|
|
174
|
+
available: observation.available,
|
|
175
|
+
failed: observation.failed,
|
|
176
|
+
digest: feedbackDigest(observation),
|
|
177
|
+
}))
|
|
178
|
+
const selected = result.publicSelected.map((item, index) => ({
|
|
179
|
+
...item,
|
|
180
|
+
feedback: {
|
|
181
|
+
available: feedbackObservations[index].available,
|
|
182
|
+
positive: feedbackObservations[index].items.filter(value => value.rating === 'positive').length,
|
|
183
|
+
negative: feedbackObservations[index].items.filter(value => value.rating === 'negative').length,
|
|
184
|
+
},
|
|
185
|
+
}))
|
|
186
|
+
const evaluation = judgeIdentity(judgeBinding, result.selected)
|
|
187
|
+
const issued = this.tokens.issue({
|
|
188
|
+
...identity,
|
|
189
|
+
selection: result.selected.map(withoutRawEvents),
|
|
190
|
+
feedbackSnapshots,
|
|
191
|
+
judgeBinding,
|
|
192
|
+
evaluation,
|
|
193
|
+
parameters: { limit, includeFeedback, createdAfter, scope: 'exact-cwd', order: 'last-activity-desc' },
|
|
194
|
+
})
|
|
195
|
+
const warnings = [...result.warnings]
|
|
196
|
+
if (feedbackObservations.some(item => item.failed)) {
|
|
197
|
+
warnings.push('Some Message Feedback could not be read; the Session sample remains usable without it.')
|
|
198
|
+
}
|
|
199
|
+
warnings.push('Frozen Session observations remain local under .harbor/private and the Harbor jobs directory; review repository ignore and retention policy before committing artifacts.')
|
|
200
|
+
return {
|
|
201
|
+
schema_version: 1,
|
|
202
|
+
capability: 'historical-generation-evaluation',
|
|
203
|
+
jobKind: 'historical-generation-evaluation',
|
|
204
|
+
projectRoot: identity.projectRoot,
|
|
205
|
+
scope: 'exact-cwd',
|
|
206
|
+
order: 'last-activity-desc',
|
|
207
|
+
...(createdAfter === undefined ? {} : { createdAfter: new Date(createdAfter).toISOString() }),
|
|
208
|
+
executionMode: 'observe-existing',
|
|
209
|
+
promotionEligible: false,
|
|
210
|
+
evaluationLevel: 'trial',
|
|
211
|
+
selectionToken: issued.token,
|
|
212
|
+
expiresAt: new Date(issued.expiresAt).toISOString(),
|
|
213
|
+
selected,
|
|
214
|
+
excludedCounts: result.excludedCounts,
|
|
215
|
+
warnings,
|
|
216
|
+
estimatedJudgeRequests: selected.length,
|
|
217
|
+
estimatedMaxBytes: selected.length * 512 * 1024,
|
|
218
|
+
evaluation,
|
|
219
|
+
retention: {
|
|
220
|
+
privateEvidence: '.harbor/private/session-batches',
|
|
221
|
+
jobEvidence: this.config.jobsDir ?? 'jobs',
|
|
222
|
+
vcsPolicy: 'an ignore-all file is created only when .harbor/private/.gitignore is absent; existing private rules and jobs retention/VCS policy remain project-owned',
|
|
223
|
+
},
|
|
224
|
+
confirmation: `Run 1 historical-generation-evaluation Job with ${selected.length} immutable Trial(s) using ${evaluation.evaluator.id}@${evaluation.evaluator.version} and Judge ${evaluation.judge.provider}/${evaluation.judge.model} (${evaluation.coupling}); no Candidate will be executed or promoted.`,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async run(args = {}, exec) {
|
|
229
|
+
const identity = executionIdentity(exec)
|
|
230
|
+
if (args.stackPath !== undefined) {
|
|
231
|
+
throw new Error('HISTORICAL_CUSTOM_STACK_UNSUPPORTED: the first release binds the materialized Broker Evaluator and Stack as one immutable unit')
|
|
232
|
+
}
|
|
233
|
+
if (
|
|
234
|
+
args.evaluatorProvider !== undefined
|
|
235
|
+
|| args.evaluatorModel !== undefined
|
|
236
|
+
|| args.evaluatorReasoningEffort !== undefined
|
|
237
|
+
) {
|
|
238
|
+
throw new Error('HISTORICAL_JUDGE_NOT_CONFIRMED: choose the Judge during Preview, then Run with only the confirmed selectionToken')
|
|
239
|
+
}
|
|
240
|
+
const token = String(args.selectionToken ?? '')
|
|
241
|
+
if (!token) throw new Error('selectionToken is required; call harbor_session_diagnostic_preview first')
|
|
242
|
+
const selectedState = this.tokens.consume(token, identity)
|
|
243
|
+
const sessionQuery = capability(this.ctx, 'sessionQuery')
|
|
244
|
+
if (!sessionQuery || typeof sessionQuery.readSession !== 'function') {
|
|
245
|
+
throw new Error('DSH_SESSION_QUERY_UNAVAILABLE: this DSH Profile does not expose the Session Query service')
|
|
246
|
+
}
|
|
247
|
+
const reads = await Promise.allSettled(
|
|
248
|
+
selectedState.selection.map(item => sessionQuery.readSession(item.rawSessionId)),
|
|
249
|
+
)
|
|
250
|
+
if (reads.some(item => item.status === 'rejected')) {
|
|
251
|
+
throw new Error('SESSION_SOURCE_READ_FAILED: at least one selected Session could not be re-read; no Batch was written')
|
|
252
|
+
}
|
|
253
|
+
const snapshots = reads.map(item => item.value)
|
|
254
|
+
if (snapshots.some((snapshot, index) => (
|
|
255
|
+
!verifySessionSnapshot(selectedState.selection[index], snapshot, identity.projectRoot)
|
|
256
|
+
))) {
|
|
257
|
+
throw new Error('SESSION_SAMPLE_CHANGED: at least one selected Session changed after Preview; no Batch was written, preview again')
|
|
258
|
+
}
|
|
259
|
+
const feedback = capability(this.ctx, 'messageFeedback')
|
|
260
|
+
const feedbackObservations = selectedState.parameters.includeFeedback
|
|
261
|
+
? await Promise.all(selectedState.selection.map(item => readFeedback(feedback, item.rawSessionId)))
|
|
262
|
+
: selectedState.selection.map(() => ({ items: [] }))
|
|
263
|
+
if (selectedState.parameters.includeFeedback) {
|
|
264
|
+
for (let index = 0; index < feedbackObservations.length; index += 1) {
|
|
265
|
+
const expected = selectedState.feedbackSnapshots[index]
|
|
266
|
+
const observed = feedbackObservations[index]
|
|
267
|
+
if (!expected || feedbackDigest(observed) !== expected.digest) {
|
|
268
|
+
throw new Error('SESSION_FEEDBACK_CHANGED: Message Feedback changed after Preview; no Batch was written, preview again')
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const frozenSelections = selectedState.selection.map((item, index) => ({
|
|
273
|
+
...item,
|
|
274
|
+
events: snapshots[index].events,
|
|
275
|
+
}))
|
|
276
|
+
const observations = frozenSelections.map((item, index) => (
|
|
277
|
+
buildSessionObservation(item, feedbackObservations[index].items)
|
|
278
|
+
))
|
|
279
|
+
const judgeBinding = selectedState.judgeBinding
|
|
280
|
+
if (!judgeBinding?.provider || !judgeBinding?.model) {
|
|
281
|
+
throw new Error('HISTORICAL_JUDGE_NOT_CONFIRMED: preview again to freeze a valid Judge identity before writing the Batch')
|
|
282
|
+
}
|
|
283
|
+
const batch = buildHistoricalGenerationBatch({
|
|
284
|
+
projectRoot: identity.projectRoot,
|
|
285
|
+
selections: frozenSelections,
|
|
286
|
+
observations,
|
|
287
|
+
limit: selectedState.parameters.limit,
|
|
288
|
+
createdAfter: selectedState.parameters.createdAfter,
|
|
289
|
+
now: this.now(),
|
|
290
|
+
})
|
|
291
|
+
const written = await writePrivateHistoricalBatch({
|
|
292
|
+
projectRoot: identity.projectRoot,
|
|
293
|
+
batch,
|
|
294
|
+
observations,
|
|
295
|
+
})
|
|
296
|
+
const result = await this.runHistoricalEvaluation(
|
|
297
|
+
{ ...this.config, projectRoot: identity.projectRoot },
|
|
298
|
+
{
|
|
299
|
+
batchPath: written.batchPath,
|
|
300
|
+
batchDir: written.batchDir,
|
|
301
|
+
jobName: args.jobName,
|
|
302
|
+
judgeBinding,
|
|
303
|
+
},
|
|
304
|
+
this.modelRuntime,
|
|
305
|
+
)
|
|
306
|
+
return {
|
|
307
|
+
schema_version: 1,
|
|
308
|
+
jobKind: 'historical-generation-evaluation',
|
|
309
|
+
executionMode: 'observe-existing',
|
|
310
|
+
promotionEligible: false,
|
|
311
|
+
batch: {
|
|
312
|
+
id: batch.batch_id,
|
|
313
|
+
digest: batch.digest,
|
|
314
|
+
recordCount: batch.records.length,
|
|
315
|
+
path: path.relative(identity.projectRoot, written.batchPath).split(path.sep).join('/'),
|
|
316
|
+
},
|
|
317
|
+
...result,
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|