dsh-harbor-evolution 0.7.0 → 0.7.2
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 +10 -4
- package/index.js +47 -16
- package/lib/candidate.js +7 -1
- package/lib/evolution.js +56 -14
- package/lib/model-runtime.js +216 -0
- package/lib/service.js +12 -7
- package/package.json +1 -1
- package/skills/evolve-agent-with-harbor/SKILL.md +1 -1
- package/skills/evolve-agent-with-harbor/evals/evals.json +11 -0
- package/skills/evolve-agent-with-harbor/references/initialization.md +1 -1
package/README.md
CHANGED
|
@@ -14,10 +14,10 @@ npx --yes dsh-harbor-evolution@latest setup --project-root "$PWD"
|
|
|
14
14
|
|
|
15
15
|
The setup command installs both required runtimes:
|
|
16
16
|
|
|
17
|
-
- `harbor-dsh-evolution==0.7.
|
|
18
|
-
- `dsh-harbor-evolution@0.7.
|
|
17
|
+
- `harbor-dsh-evolution==0.7.2` in a managed Python environment.
|
|
18
|
+
- `dsh-harbor-evolution@0.7.2` in the selected DSH profile.
|
|
19
19
|
|
|
20
|
-
It then stores the absolute Harbor executable paths and `projectRoot` in the profile's `harbor-evolution` block and verifies the integration. Existing unrelated profile entries are preserved, and rerunning setup updates the same block.
|
|
20
|
+
It then stores the absolute Harbor executable paths and a fallback `projectRoot` in the profile's `harbor-evolution` block and verifies the integration. Agent Tool calls always use the calling session's absolute working directory as their project root; the configured value remains the Web Workbench and non-Agent fallback. Existing unrelated profile entries are preserved, and rerunning setup updates the same block.
|
|
21
21
|
|
|
22
22
|
The default profile is `web`. Use `--profile headless` only when that is the profile you actually run. See all options with:
|
|
23
23
|
|
|
@@ -57,6 +57,12 @@ The Web UI is intentionally read-only. Starting an evaluation or deciding promot
|
|
|
57
57
|
|
|
58
58
|
A direct evaluation requires `candidatePath`, `datasetPath`, `stackPath`, and explicit `mode`; `promotion-eligible` additionally requires `policyPath`. Prefer the Skill because it will not run or compare Jobs until the material identities and evaluation contract are resolved.
|
|
59
59
|
|
|
60
|
+
## Candidate model binding
|
|
61
|
+
|
|
62
|
+
Before each Job, the Plugin snapshots the current DSH Agent selection—provider, model, and reasoning effort—then starts a per-Job local Model Broker. The Candidate uses the temporary `dsh-host` adapter through `dsh-host-broker` / `dsh-host-model-gateway/v1`; it receives only a short-lived Job capability file, never GPT Auth, Codex OAuth, or an upstream API key.
|
|
63
|
+
|
|
64
|
+
`harbor_eval_run`, `harbor_context_preview`, and `harbor_evolution_doctor` inherit that selection by default. Advanced callers can override `candidateProvider` and `candidateModel` only as a pair, plus an optional `candidateReasoningEffort`. `openai-codex` performs a GPT Auth sign-in check before Harbor starts. The resulting model binding is part of Context v2 comparison identity, so any provider/model/reasoning change requires a new baseline.
|
|
65
|
+
|
|
60
66
|
`harbor_eval_result` defaults to the stable Summary. Use `view=job`, `view=dataset`, `view=progress`, `view=trial` plus a returned `trialId`, or `view=governance` to inspect sanitized instructions, generated output, evidence, and evaluator source without coupling the Agent to artifact file paths.
|
|
61
67
|
|
|
62
68
|
## What setup writes
|
|
@@ -73,7 +79,7 @@ The selected profile receives one id-targeted override:
|
|
|
73
79
|
pythonPath: ""
|
|
74
80
|
```
|
|
75
81
|
|
|
76
|
-
Keep `pythonPath` empty for the published Python package. `candidatePath`, `datasetPath`, `jobPath`, and `policyPath`
|
|
82
|
+
Keep `pythonPath` empty for the published Python package. For Agent Tool calls, `projectRoot` is replaced by the calling session's working directory for that call. `candidatePath`, `datasetPath`, `jobPath`, and `policyPath` remain constrained to that request-local root, so concurrent sessions cannot redirect each other's Harbor operations.
|
|
77
83
|
|
|
78
84
|
For source development from the repository:
|
|
79
85
|
|
package/index.js
CHANGED
|
@@ -5,11 +5,12 @@ import path from 'node:path'
|
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
6
|
|
|
7
7
|
import { loadBundledSkill } from './lib/official-skill.js'
|
|
8
|
+
import { CandidateModelRuntime } from './lib/model-runtime.js'
|
|
8
9
|
import { EvolutionService } from './lib/service.js'
|
|
9
10
|
import { installDashboardWeb } from './lib/web.js'
|
|
10
11
|
|
|
11
12
|
export const name = 'harbor-evolution'
|
|
12
|
-
export const inject = ['tools', 'skills']
|
|
13
|
+
export const inject = ['tools', 'skills', 'llm', 'agentDefaultModel']
|
|
13
14
|
|
|
14
15
|
const packageDir = path.dirname(fileURLToPath(import.meta.url))
|
|
15
16
|
const checkoutPythonPackage = path.resolve(packageDir, '../harbor-plugin')
|
|
@@ -30,6 +31,13 @@ export const Config = Schema.object({
|
|
|
30
31
|
pluginImportPath: Schema.string().default('dsh-evolution'),
|
|
31
32
|
pythonPath: Schema.string().default(''),
|
|
32
33
|
timeoutMs: Schema.number().default(1800000),
|
|
34
|
+
candidateProvider: Schema.string().default(''),
|
|
35
|
+
candidateModel: Schema.string().default(''),
|
|
36
|
+
candidateReasoningEffort: Schema.string().default(''),
|
|
37
|
+
modelBrokerBindHost: Schema.string().default('127.0.0.1'),
|
|
38
|
+
modelBrokerAdvertisedHost: Schema.string().default('host.docker.internal'),
|
|
39
|
+
modelBrokerMaxRequests: Schema.number().min(1).default(1000),
|
|
40
|
+
modelBrokerMaxRequestBytes: Schema.number().min(1024).default(33554432),
|
|
33
41
|
})
|
|
34
42
|
|
|
35
43
|
function jsonTool(definition, execute) {
|
|
@@ -39,12 +47,20 @@ function jsonTool(definition, execute) {
|
|
|
39
47
|
schema: { type: 'string' },
|
|
40
48
|
render: (_args, value) => [{ type: 'text', text: value }],
|
|
41
49
|
},
|
|
42
|
-
async execute(args) {
|
|
43
|
-
return JSON.stringify(await execute(args), null, 2)
|
|
50
|
+
async execute(args, exec) {
|
|
51
|
+
return JSON.stringify(await execute(args, exec), null, 2)
|
|
44
52
|
},
|
|
45
53
|
})
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
function toolProjectRoot(exec) {
|
|
57
|
+
const cwd = exec?.agent?.session?.header?.cwd
|
|
58
|
+
if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) {
|
|
59
|
+
throw new Error('Harbor tools require an Agent session with an absolute working directory')
|
|
60
|
+
}
|
|
61
|
+
return path.resolve(cwd)
|
|
62
|
+
}
|
|
63
|
+
|
|
48
64
|
export function apply(ctx, config) {
|
|
49
65
|
const resolved = {
|
|
50
66
|
...config,
|
|
@@ -57,7 +73,13 @@ export function apply(ctx, config) {
|
|
|
57
73
|
: ''
|
|
58
74
|
),
|
|
59
75
|
}
|
|
60
|
-
const
|
|
76
|
+
const modelRuntime = new CandidateModelRuntime(ctx, resolved)
|
|
77
|
+
const metadata = { pluginVersion: packageJson.version }
|
|
78
|
+
const service = new EvolutionService(resolved, metadata, modelRuntime)
|
|
79
|
+
const serviceForTool = exec => new EvolutionService({
|
|
80
|
+
...resolved,
|
|
81
|
+
projectRoot: toolProjectRoot(exec),
|
|
82
|
+
}, metadata, modelRuntime)
|
|
61
83
|
|
|
62
84
|
ctx.skills.register(loadBundledSkill())
|
|
63
85
|
installDashboardWeb(ctx, service)
|
|
@@ -70,7 +92,7 @@ export function apply(ctx, config) {
|
|
|
70
92
|
candidateId: { type: 'string' },
|
|
71
93
|
version: { type: 'string' },
|
|
72
94
|
},
|
|
73
|
-
}, args =>
|
|
95
|
+
}, (args, exec) => serviceForTool(exec).snapshot(args)))
|
|
74
96
|
|
|
75
97
|
ctx.tools.register(jsonTool({
|
|
76
98
|
name: 'harbor_evolution_init',
|
|
@@ -92,7 +114,7 @@ export function apply(ctx, config) {
|
|
|
92
114
|
policyVersion: { type: 'string', required: true },
|
|
93
115
|
minImprovement: { type: 'number', required: true },
|
|
94
116
|
},
|
|
95
|
-
}, args =>
|
|
117
|
+
}, (args, exec) => serviceForTool(exec).initialize(args)))
|
|
96
118
|
|
|
97
119
|
ctx.tools.register(jsonTool({
|
|
98
120
|
name: 'harbor_evolution_doctor',
|
|
@@ -103,8 +125,11 @@ export function apply(ctx, config) {
|
|
|
103
125
|
stackPath: { type: 'string', required: true },
|
|
104
126
|
policyPath: { type: 'string' },
|
|
105
127
|
mode: { type: 'string', required: true },
|
|
128
|
+
candidateProvider: { type: 'string', description: 'Optional Candidate provider. Supply it together with candidateModel; defaults to the current DSH Agent model.' },
|
|
129
|
+
candidateModel: { type: 'string' },
|
|
130
|
+
candidateReasoningEffort: { type: 'string' },
|
|
106
131
|
},
|
|
107
|
-
}, args =>
|
|
132
|
+
}, (args, exec) => serviceForTool(exec).doctor(args)))
|
|
108
133
|
|
|
109
134
|
ctx.tools.register(jsonTool({
|
|
110
135
|
name: 'harbor_dataset_validate',
|
|
@@ -112,7 +137,7 @@ export function apply(ctx, config) {
|
|
|
112
137
|
parameters: {
|
|
113
138
|
datasetPath: { type: 'string', required: true },
|
|
114
139
|
},
|
|
115
|
-
}, args =>
|
|
140
|
+
}, (args, exec) => serviceForTool(exec).validateDataset(args)))
|
|
116
141
|
|
|
117
142
|
ctx.tools.register(jsonTool({
|
|
118
143
|
name: 'harbor_context_preview',
|
|
@@ -124,8 +149,11 @@ export function apply(ctx, config) {
|
|
|
124
149
|
datasetPath: { type: 'string', required: true },
|
|
125
150
|
stackPath: { type: 'string', required: true },
|
|
126
151
|
mode: { type: 'string', required: true },
|
|
152
|
+
candidateProvider: { type: 'string', description: 'Optional Candidate provider. Supply it together with candidateModel; defaults to the current DSH Agent model.' },
|
|
153
|
+
candidateModel: { type: 'string' },
|
|
154
|
+
candidateReasoningEffort: { type: 'string' },
|
|
127
155
|
},
|
|
128
|
-
}, args =>
|
|
156
|
+
}, (args, exec) => serviceForTool(exec).previewContext(args)))
|
|
129
157
|
|
|
130
158
|
ctx.tools.register(jsonTool({
|
|
131
159
|
name: 'harbor_eval_run',
|
|
@@ -139,8 +167,11 @@ export function apply(ctx, config) {
|
|
|
139
167
|
mode: { type: 'string', required: true },
|
|
140
168
|
policyPath: { type: 'string' },
|
|
141
169
|
jobName: { type: 'string' },
|
|
170
|
+
candidateProvider: { type: 'string', description: 'Optional Candidate provider. Supply it together with candidateModel; defaults to the current DSH Agent model and is frozen before the Job starts.' },
|
|
171
|
+
candidateModel: { type: 'string' },
|
|
172
|
+
candidateReasoningEffort: { type: 'string' },
|
|
142
173
|
},
|
|
143
|
-
}, args =>
|
|
174
|
+
}, (args, exec) => serviceForTool(exec).run(args)))
|
|
144
175
|
|
|
145
176
|
ctx.tools.register(jsonTool({
|
|
146
177
|
name: 'harbor_eval_result',
|
|
@@ -152,7 +183,7 @@ export function apply(ctx, config) {
|
|
|
152
183
|
compareJob: { type: 'string', description: 'Optional previous Job for view=governance impact analysis' },
|
|
153
184
|
since: { type: 'string', description: 'Optional ISO timestamp for incremental progress changes' },
|
|
154
185
|
},
|
|
155
|
-
}, args =>
|
|
186
|
+
}, (args, exec) => serviceForTool(exec).result(args)))
|
|
156
187
|
|
|
157
188
|
ctx.tools.register(jsonTool({
|
|
158
189
|
name: 'harbor_evaluator_inspect',
|
|
@@ -160,7 +191,7 @@ export function apply(ctx, config) {
|
|
|
160
191
|
parameters: {
|
|
161
192
|
stackPath: { type: 'string', description: 'Defaults to .harbor/evaluation-stack.yml' },
|
|
162
193
|
},
|
|
163
|
-
}, args =>
|
|
194
|
+
}, (args, exec) => serviceForTool(exec).evaluatorInspect(args)))
|
|
164
195
|
|
|
165
196
|
ctx.tools.register(jsonTool({
|
|
166
197
|
name: 'harbor_evaluator_update',
|
|
@@ -173,7 +204,7 @@ export function apply(ctx, config) {
|
|
|
173
204
|
newEvaluatorVersion: { type: 'string', required: true },
|
|
174
205
|
newStackVersion: { type: 'string', required: true },
|
|
175
206
|
},
|
|
176
|
-
}, args =>
|
|
207
|
+
}, (args, exec) => serviceForTool(exec).evaluator(args)))
|
|
177
208
|
|
|
178
209
|
ctx.tools.register(jsonTool({
|
|
179
210
|
name: 'harbor_ground_truth_init',
|
|
@@ -187,7 +218,7 @@ export function apply(ctx, config) {
|
|
|
187
218
|
provenance: { type: 'string', required: true },
|
|
188
219
|
criteria: { type: 'string', required: true, description: 'Comma-separated criterion ids' },
|
|
189
220
|
},
|
|
190
|
-
}, args =>
|
|
221
|
+
}, (args, exec) => serviceForTool(exec).groundTruthInitialize(args)))
|
|
191
222
|
|
|
192
223
|
ctx.tools.register(jsonTool({
|
|
193
224
|
name: 'harbor_evaluator_meta_evaluate',
|
|
@@ -197,7 +228,7 @@ export function apply(ctx, config) {
|
|
|
197
228
|
observationsPath: { type: 'string', required: true },
|
|
198
229
|
outputPath: { type: 'string', description: 'Defaults to .harbor/meta-evaluation-report.json' },
|
|
199
230
|
},
|
|
200
|
-
}, args =>
|
|
231
|
+
}, (args, exec) => serviceForTool(exec).evaluatorMetaEvaluate(args)))
|
|
201
232
|
|
|
202
233
|
ctx.tools.register(jsonTool({
|
|
203
234
|
name: 'harbor_candidate_compare',
|
|
@@ -207,5 +238,5 @@ export function apply(ctx, config) {
|
|
|
207
238
|
candidateJob: { type: 'string', required: true },
|
|
208
239
|
policyPath: { type: 'string', required: true },
|
|
209
240
|
},
|
|
210
|
-
}, args =>
|
|
241
|
+
}, (args, exec) => serviceForTool(exec).compare(args)))
|
|
211
242
|
}
|
package/lib/candidate.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path'
|
|
|
4
4
|
|
|
5
5
|
export const MANIFEST_NAME = 'candidate-manifest.json'
|
|
6
6
|
const DIGEST_PREFIX = Buffer.from('harbor-dsh-candidate-v1\0')
|
|
7
|
-
const EXCLUDED_DIRS = new Set(['.git', 'node_modules', '__pycache__'])
|
|
7
|
+
const EXCLUDED_DIRS = new Set(['.git', 'node_modules', '__pycache__', '.harbor-runtime'])
|
|
8
8
|
const EXCLUDED_FILES = new Set([MANIFEST_NAME, '.DS_Store'])
|
|
9
9
|
const LOCKFILES = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb']
|
|
10
10
|
const CREDENTIAL_FILES = new Set([
|
|
@@ -57,6 +57,12 @@ export async function computeCandidate(candidateDir) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
async function validateCandidateContract(root) {
|
|
60
|
+
try {
|
|
61
|
+
await stat(path.join(root, '.harbor-runtime'))
|
|
62
|
+
throw new Error('Candidate must not contain the reserved .harbor-runtime path')
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error.code !== 'ENOENT') throw error
|
|
65
|
+
}
|
|
60
66
|
for (const required of ['cordis.yml', 'package.json']) {
|
|
61
67
|
try {
|
|
62
68
|
if (!(await stat(path.join(root, required))).isFile()) throw new Error()
|
package/lib/evolution.js
CHANGED
|
@@ -119,6 +119,19 @@ function strictInputs(config, args) {
|
|
|
119
119
|
return { projectRoot, candidate, dataset, stack, jobs, mode, policy }
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
function candidateModelCliArgs(binding) {
|
|
123
|
+
if (!binding?.provider || !binding?.model) throw new Error('Candidate model binding is required')
|
|
124
|
+
return [
|
|
125
|
+
'--candidate-model-provider', binding.provider,
|
|
126
|
+
'--candidate-model', binding.model,
|
|
127
|
+
...(binding.reasoning_effort === undefined
|
|
128
|
+
? []
|
|
129
|
+
: ['--candidate-reasoning-effort', binding.reasoning_effort]),
|
|
130
|
+
'--candidate-model-transport', binding.transport,
|
|
131
|
+
'--candidate-model-protocol', binding.protocol,
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
|
|
122
135
|
export async function validateDataset(config, args) {
|
|
123
136
|
const dataset = resolveWithin(config.projectRoot, args.datasetPath, 'datasetPath')
|
|
124
137
|
return cliJson(config, ['dataset', 'validate', dataset, '--project-root', config.projectRoot], { allowedExitCodes: [0, 2] })
|
|
@@ -157,11 +170,12 @@ export async function previewContext(config, args) {
|
|
|
157
170
|
'--stack', inputs.stack,
|
|
158
171
|
'--jobs-dir', inputs.jobs,
|
|
159
172
|
'--mode', inputs.mode,
|
|
173
|
+
...candidateModelCliArgs(args.candidateModelBinding),
|
|
160
174
|
])
|
|
161
175
|
return { manifest, ...preview }
|
|
162
176
|
}
|
|
163
177
|
|
|
164
|
-
export async function runEvaluation(config, args) {
|
|
178
|
+
export async function runEvaluation(config, args, modelRuntime) {
|
|
165
179
|
const manifest = await snapshot(config, args)
|
|
166
180
|
const inputs = strictInputs(config, args)
|
|
167
181
|
const datasetValidation = await validateDataset(config, args)
|
|
@@ -176,6 +190,7 @@ export async function runEvaluation(config, args) {
|
|
|
176
190
|
'context', 'preview', '--project-root', inputs.projectRoot,
|
|
177
191
|
'--candidate', inputs.candidate, '--dataset', inputs.dataset,
|
|
178
192
|
'--stack', inputs.stack, '--jobs-dir', inputs.jobs, '--mode', inputs.mode,
|
|
193
|
+
...candidateModelCliArgs(args.candidateModelBinding),
|
|
179
194
|
])
|
|
180
195
|
const jobName = args.jobName ?? makeJobName(manifest)
|
|
181
196
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(jobName)) throw new Error('jobName contains unsupported characters')
|
|
@@ -186,6 +201,8 @@ export async function runEvaluation(config, args) {
|
|
|
186
201
|
'--ak', `candidate_path=${inputs.candidate}`,
|
|
187
202
|
'--ak', `candidate_version=${manifest.version}`,
|
|
188
203
|
'--ak', `candidate_digest=${manifest.digest}`,
|
|
204
|
+
'--ak', `candidate_model_provider=${args.candidateModelBinding.provider}`,
|
|
205
|
+
'--ak', `candidate_model=${args.candidateModelBinding.model}`,
|
|
189
206
|
'--job-name', jobName,
|
|
190
207
|
'--jobs-dir', inputs.jobs,
|
|
191
208
|
'--plugin', config.pluginImportPath,
|
|
@@ -194,22 +211,47 @@ export async function runEvaluation(config, args) {
|
|
|
194
211
|
'--plugin-kwarg', `stack_path=${inputs.stack}`,
|
|
195
212
|
'--plugin-kwarg', `project_root=${inputs.projectRoot}`,
|
|
196
213
|
'--plugin-kwarg', `mode=${inputs.mode}`,
|
|
214
|
+
'--plugin-kwarg', `candidate_model_provider=${args.candidateModelBinding.provider}`,
|
|
215
|
+
'--plugin-kwarg', `candidate_model=${args.candidateModelBinding.model}`,
|
|
216
|
+
'--plugin-kwarg', `candidate_model_transport=${args.candidateModelBinding.transport}`,
|
|
217
|
+
'--plugin-kwarg', `candidate_model_protocol=${args.candidateModelBinding.protocol}`,
|
|
197
218
|
]
|
|
219
|
+
if (args.candidateModelBinding.reasoning_effort !== undefined) {
|
|
220
|
+
harborArgs.push('--ak', `candidate_reasoning_effort=${args.candidateModelBinding.reasoning_effort}`)
|
|
221
|
+
harborArgs.push('--plugin-kwarg', `candidate_reasoning_effort=${args.candidateModelBinding.reasoning_effort}`)
|
|
222
|
+
}
|
|
198
223
|
if (inputs.policy) harborArgs.push('--plugin-kwarg', `policy_path=${inputs.policy}`)
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
env: { ...process.env, ...(config.pythonPath ? { PYTHONPATH: config.pythonPath } : {}) },
|
|
224
|
+
const lease = await modelRuntime.openLease(args.candidateModelBinding, {
|
|
225
|
+
candidateDigest: manifest.digest,
|
|
226
|
+
jobName,
|
|
203
227
|
})
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
228
|
+
try {
|
|
229
|
+
const processResult = await runProcess(config.harborBin, harborArgs, {
|
|
230
|
+
cwd: config.projectRoot,
|
|
231
|
+
timeoutMs: config.timeoutMs,
|
|
232
|
+
env: {
|
|
233
|
+
...process.env,
|
|
234
|
+
...(config.pythonPath ? { PYTHONPATH: config.pythonPath } : {}),
|
|
235
|
+
HSE_MODEL_GATEWAY_URL: lease.endpoint,
|
|
236
|
+
HSE_MODEL_GATEWAY_TOKEN: lease.token,
|
|
237
|
+
HSE_MODEL_GATEWAY_PROVIDER: lease.candidateProvider,
|
|
238
|
+
HSE_MODEL_GATEWAY_INFO: JSON.stringify(lease.modelInfo),
|
|
239
|
+
HSE_MODEL_GATEWAY_PROTOCOL: lease.protocol,
|
|
240
|
+
},
|
|
241
|
+
})
|
|
242
|
+
const jobDir = path.join(inputs.jobs, jobName)
|
|
243
|
+
const summary = JSON.parse(await readFile(path.join(jobDir, 'evaluation-summary.json'), 'utf8'))
|
|
244
|
+
return {
|
|
245
|
+
manifest,
|
|
246
|
+
candidateModelBinding: args.candidateModelBinding,
|
|
247
|
+
job: path.relative(inputs.projectRoot, jobDir),
|
|
248
|
+
summary,
|
|
249
|
+
doctor,
|
|
250
|
+
contextPreview: preview,
|
|
251
|
+
process: { code: processResult.code },
|
|
252
|
+
}
|
|
253
|
+
} finally {
|
|
254
|
+
await lease.close()
|
|
213
255
|
}
|
|
214
256
|
}
|
|
215
257
|
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { once } from 'node:events'
|
|
3
|
+
import { createServer } from 'node:http'
|
|
4
|
+
|
|
5
|
+
export const MODEL_GATEWAY_PROTOCOL = 'dsh-host-model-gateway/v1'
|
|
6
|
+
export const CANDIDATE_GATEWAY_PROVIDER = 'dsh-host'
|
|
7
|
+
|
|
8
|
+
function nonBlank(value) {
|
|
9
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function sameSecret(expected, actual) {
|
|
13
|
+
const left = Buffer.from(expected)
|
|
14
|
+
const right = Buffer.from(actual)
|
|
15
|
+
return left.length === right.length && timingSafeEqual(left, right)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readJsonBody(request, maxBytes) {
|
|
19
|
+
const chunks = []
|
|
20
|
+
let size = 0
|
|
21
|
+
for await (const chunk of request) {
|
|
22
|
+
size += chunk.length
|
|
23
|
+
if (size > maxBytes) {
|
|
24
|
+
throw Object.assign(new Error('model gateway request is too large'), { statusCode: 413 })
|
|
25
|
+
}
|
|
26
|
+
chunks.push(chunk)
|
|
27
|
+
}
|
|
28
|
+
let value
|
|
29
|
+
try {
|
|
30
|
+
value = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
31
|
+
} catch {
|
|
32
|
+
throw Object.assign(new Error('model gateway request must be valid JSON'), { statusCode: 400 })
|
|
33
|
+
}
|
|
34
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value) || !Array.isArray(value.messages)) {
|
|
35
|
+
throw Object.assign(new Error('model gateway request requires a messages array'), { statusCode: 400 })
|
|
36
|
+
}
|
|
37
|
+
return value
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sendJson(response, statusCode, value) {
|
|
41
|
+
const body = `${JSON.stringify(value)}\n`
|
|
42
|
+
response.writeHead(statusCode, {
|
|
43
|
+
'content-type': 'application/json; charset=utf-8',
|
|
44
|
+
'content-length': Buffer.byteLength(body),
|
|
45
|
+
'cache-control': 'no-store',
|
|
46
|
+
})
|
|
47
|
+
response.end(body)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function authorized(request, token) {
|
|
51
|
+
const value = request.headers.authorization
|
|
52
|
+
return typeof value === 'string' && value.startsWith('Bearer ')
|
|
53
|
+
&& sameSecret(token, value.slice('Bearer '.length))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function listen(server, host) {
|
|
57
|
+
server.listen(0, host)
|
|
58
|
+
await once(server, 'listening')
|
|
59
|
+
const address = server.address()
|
|
60
|
+
if (address === null || typeof address === 'string') {
|
|
61
|
+
throw new Error('model gateway did not bind a TCP port')
|
|
62
|
+
}
|
|
63
|
+
return address.port
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Freeze the current DSH Agent model per Job, then proxy only that model to an
|
|
68
|
+
* immutable Candidate. The Candidate receives a short-lived capability, never
|
|
69
|
+
* the Host provider's credential or route selection.
|
|
70
|
+
*/
|
|
71
|
+
export class CandidateModelRuntime {
|
|
72
|
+
constructor(ctx, config) {
|
|
73
|
+
this.ctx = ctx
|
|
74
|
+
this.config = config
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async resolve(args = {}) {
|
|
78
|
+
const explicitProvider = nonBlank(args.candidateProvider)
|
|
79
|
+
const explicitModel = nonBlank(args.candidateModel)
|
|
80
|
+
if (Boolean(explicitProvider) !== Boolean(explicitModel)) {
|
|
81
|
+
throw new Error('candidateProvider and candidateModel must be supplied together')
|
|
82
|
+
}
|
|
83
|
+
const configuredProvider = nonBlank(this.config.candidateProvider)
|
|
84
|
+
const configuredModel = nonBlank(this.config.candidateModel)
|
|
85
|
+
if (Boolean(configuredProvider) !== Boolean(configuredModel)) {
|
|
86
|
+
throw new Error('Harbor candidateProvider and candidateModel configuration must be supplied together')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const inherited = this.ctx.agentDefaultModel.currentSelection()
|
|
90
|
+
const provider = explicitProvider ?? configuredProvider ?? inherited.provider
|
|
91
|
+
const model = explicitModel ?? configuredModel ?? inherited.model
|
|
92
|
+
const explicitReasoning = nonBlank(args.candidateReasoningEffort)
|
|
93
|
+
const configuredReasoning = nonBlank(this.config.candidateReasoningEffort)
|
|
94
|
+
const canInheritReasoning = provider === inherited.provider && model === inherited.model
|
|
95
|
+
const reasoningEffort = explicitReasoning ?? configuredReasoning
|
|
96
|
+
?? (canInheritReasoning ? inherited.reasoningEffort : undefined)
|
|
97
|
+
|
|
98
|
+
if (!this.ctx.llm.listProviders().some(item => item.id === provider)) {
|
|
99
|
+
throw new Error(`Candidate model provider "${provider}" is not registered in DeepSeek Harness`)
|
|
100
|
+
}
|
|
101
|
+
const modelInfo = await this.ctx.llm.resolveModelInfo(provider, model)
|
|
102
|
+
if (provider === 'openai-codex') {
|
|
103
|
+
const auth = this.ctx.get('codexAuth')
|
|
104
|
+
if (auth === undefined || typeof auth.status !== 'function') {
|
|
105
|
+
throw new Error('Candidate model openai-codex requires the dsh-codex-auth service')
|
|
106
|
+
}
|
|
107
|
+
const status = await auth.status()
|
|
108
|
+
if (!status.configured) {
|
|
109
|
+
throw new Error('Candidate model openai-codex is not signed in; complete GPT Auth in Settings before starting Harbor')
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
provider,
|
|
115
|
+
model,
|
|
116
|
+
...(reasoningEffort === undefined ? {} : { reasoning_effort: String(reasoningEffort) }),
|
|
117
|
+
transport: 'dsh-host-broker',
|
|
118
|
+
protocol: MODEL_GATEWAY_PROTOCOL,
|
|
119
|
+
model_info: modelInfo,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async openLease(binding, scope) {
|
|
124
|
+
const token = randomBytes(32).toString('base64url')
|
|
125
|
+
const route = `/harbor-model-gateway/v1/${randomBytes(18).toString('base64url')}`
|
|
126
|
+
const controllers = new Set()
|
|
127
|
+
let requestCount = 0
|
|
128
|
+
const server = createServer(async (request, response) => {
|
|
129
|
+
if (request.url !== route || !authorized(request, token)) {
|
|
130
|
+
sendJson(response, 404, { error: 'not found' })
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
if (request.method === 'GET') {
|
|
134
|
+
sendJson(response, 200, {
|
|
135
|
+
protocol: MODEL_GATEWAY_PROTOCOL,
|
|
136
|
+
candidate_digest: scope.candidateDigest,
|
|
137
|
+
job: scope.jobName,
|
|
138
|
+
binding: {
|
|
139
|
+
provider: binding.provider,
|
|
140
|
+
model: binding.model,
|
|
141
|
+
...(binding.reasoning_effort === undefined ? {} : { reasoning_effort: binding.reasoning_effort }),
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
if (request.method !== 'POST') {
|
|
147
|
+
sendJson(response, 405, { error: 'method not allowed' })
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
if (requestCount >= this.config.modelBrokerMaxRequests) {
|
|
151
|
+
sendJson(response, 429, { error: 'model gateway request budget exhausted' })
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
requestCount += 1
|
|
155
|
+
const controller = new AbortController()
|
|
156
|
+
controllers.add(controller)
|
|
157
|
+
response.on('close', () => {
|
|
158
|
+
if (!response.writableEnded) controller.abort(new Error('Candidate disconnected'))
|
|
159
|
+
})
|
|
160
|
+
try {
|
|
161
|
+
const body = await readJsonBody(request, this.config.modelBrokerMaxRequestBytes)
|
|
162
|
+
const {
|
|
163
|
+
provider: _provider,
|
|
164
|
+
model: _model,
|
|
165
|
+
reasoningEffort: _reasoningEffort,
|
|
166
|
+
signal: _signal,
|
|
167
|
+
...requestOptions
|
|
168
|
+
} = body
|
|
169
|
+
response.writeHead(200, {
|
|
170
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
171
|
+
'cache-control': 'no-store',
|
|
172
|
+
})
|
|
173
|
+
const stream = this.ctx.llm.stream({
|
|
174
|
+
...requestOptions,
|
|
175
|
+
provider: binding.provider,
|
|
176
|
+
model: binding.model,
|
|
177
|
+
...(binding.reasoning_effort === undefined ? {} : { reasoningEffort: binding.reasoning_effort }),
|
|
178
|
+
signal: controller.signal,
|
|
179
|
+
})
|
|
180
|
+
for await (const chunk of stream) {
|
|
181
|
+
if (!response.write(`${JSON.stringify(chunk)}\n`)) await once(response, 'drain')
|
|
182
|
+
}
|
|
183
|
+
response.end()
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (!response.headersSent) {
|
|
186
|
+
const statusCode = Number.isInteger(error?.statusCode) ? error.statusCode : 502
|
|
187
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
188
|
+
sendJson(response, statusCode, { error: message })
|
|
189
|
+
} else if (!response.writableEnded) {
|
|
190
|
+
response.destroy(error instanceof Error ? error : new Error(String(error)))
|
|
191
|
+
}
|
|
192
|
+
} finally {
|
|
193
|
+
controllers.delete(controller)
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const port = await listen(server, this.config.modelBrokerBindHost)
|
|
198
|
+
const endpoint = `http://${this.config.modelBrokerAdvertisedHost}:${port}${route}`
|
|
199
|
+
let closed = false
|
|
200
|
+
return {
|
|
201
|
+
protocol: MODEL_GATEWAY_PROTOCOL,
|
|
202
|
+
endpoint,
|
|
203
|
+
token,
|
|
204
|
+
candidateProvider: CANDIDATE_GATEWAY_PROVIDER,
|
|
205
|
+
modelInfo: binding.model_info,
|
|
206
|
+
async close() {
|
|
207
|
+
if (closed) return
|
|
208
|
+
closed = true
|
|
209
|
+
for (const controller of controllers) controller.abort(new Error('Harbor Job ended'))
|
|
210
|
+
const close = new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
|
211
|
+
server.closeAllConnections()
|
|
212
|
+
await close
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
package/lib/service.js
CHANGED
|
@@ -48,9 +48,10 @@ export async function resolveEvaluatorStackPath(config, governance, explicitPath
|
|
|
48
48
|
|
|
49
49
|
/** One Host-side boundary shared by Agent tools and the Web dashboard. */
|
|
50
50
|
export class EvolutionService {
|
|
51
|
-
constructor(config, metadata = {}) {
|
|
51
|
+
constructor(config, metadata = {}, modelRuntime) {
|
|
52
52
|
this.config = config
|
|
53
53
|
this.metadata = metadata
|
|
54
|
+
this.modelRuntime = modelRuntime
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
snapshot(args) {
|
|
@@ -61,8 +62,9 @@ export class EvolutionService {
|
|
|
61
62
|
return initializeProject(this.config, args)
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
run(args) {
|
|
65
|
-
|
|
65
|
+
async run(args) {
|
|
66
|
+
const candidateModelBinding = await this.modelRuntime.resolve(args)
|
|
67
|
+
return runEvaluation(this.config, { ...args, candidateModelBinding }, this.modelRuntime)
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
result(args) {
|
|
@@ -82,16 +84,19 @@ export class EvolutionService {
|
|
|
82
84
|
return compareCandidates(this.config, args)
|
|
83
85
|
}
|
|
84
86
|
|
|
85
|
-
doctor(args) {
|
|
86
|
-
|
|
87
|
+
async doctor(args) {
|
|
88
|
+
const candidateModelBinding = await this.modelRuntime.resolve(args)
|
|
89
|
+
const result = await runDoctor(this.config, args)
|
|
90
|
+
return { ...result, candidate_model_binding: candidateModelBinding }
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
validateDataset(args) {
|
|
90
94
|
return validateDataset(this.config, args)
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
previewContext(args) {
|
|
94
|
-
|
|
97
|
+
async previewContext(args) {
|
|
98
|
+
const candidateModelBinding = await this.modelRuntime.resolve(args)
|
|
99
|
+
return previewContext(this.config, { ...args, candidateModelBinding })
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
dashboard() {
|
package/package.json
CHANGED
|
@@ -33,7 +33,7 @@ Do not turn an inspection or diagnostic request into Agent mutation or deploymen
|
|
|
33
33
|
|
|
34
34
|
Inspect the current workspace before asking questions. Look for Agent entry files, package metadata, curl examples, Dataset instructions, existing Harbor configuration/Jobs, tests, and available Codex or Claude Code commands. Reuse reliable findings and say what was inferred; do not ask the user to transcribe information already present in files.
|
|
35
35
|
|
|
36
|
-
When no Harbor workspace exists, propose `./harbor-evolution/` under the current working directory as the managed evaluation workspace.
|
|
36
|
+
When no Harbor workspace exists, propose `./harbor-evolution/` under the current session working directory as the managed evaluation workspace. Agent-facing Harbor Tools derive `projectRoot` from the calling session for every invocation and keep imported snapshots and generated evaluation files inside that request-local root. Treat the Plugin's configured `projectRoot` only as the Web Workbench and non-Agent fallback; do not block initialization merely because it differs from the current session working directory.
|
|
37
37
|
|
|
38
38
|
Ask only for missing parts of the four-concept intake, using the user's language and short examples:
|
|
39
39
|
|
|
@@ -33,6 +33,17 @@
|
|
|
33
33
|
"The response maps the supplied paths and curl into the four-concept confirmation card.",
|
|
34
34
|
"The response does not run initialization or evaluation before confirmation."
|
|
35
35
|
]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": 4,
|
|
39
|
+
"prompt": "当前 Session 工作目录是 /Users/mac/Documents/Harness,但 Harbor Plugin 配置里显示的 projectRoot 是 /Users/mac/Library/Application Support/XiaoHui Harness/workspace。请在当前项目下初始化 ./harbor-evolution。",
|
|
40
|
+
"expected_output": "把当前 Session 工作目录作为 Agent Tool 的项目根目录,继续准备 /Users/mac/Documents/Harness/harbor-evolution 的初始化确认卡;说明配置值只供 Web Workbench 或非 Agent 场景回退使用,不要求修改配置,也不因路径不同而拒绝初始化。",
|
|
41
|
+
"files": [],
|
|
42
|
+
"assertions": [
|
|
43
|
+
"The response treats the calling session working directory as the Agent Tool project root.",
|
|
44
|
+
"The response does not block initialization or require a projectRoot configuration change because the fallback differs.",
|
|
45
|
+
"The proposed managed workspace stays under /Users/mac/Documents/Harness."
|
|
46
|
+
]
|
|
36
47
|
}
|
|
37
48
|
]
|
|
38
49
|
}
|
|
@@ -13,7 +13,7 @@ Keep onboarding anchored on four visible concepts:
|
|
|
13
13
|
| 评测器(评测标准) (Evaluator) | 怎样算好? | evaluator curl/path, or natural-language criteria | Evaluator, Rubric, Judge identity, Evaluation Contract |
|
|
14
14
|
| 优化器 (Optimizer) | 谁根据结果改进? | current Agent, Codex, Claude Code, or local command | Optimizer identity, mutation surface, rollback workflow |
|
|
15
15
|
|
|
16
|
-
Inspect first and prefill everything reliable. Ask only about missing rows. Use `./harbor-evolution/` as the proposed managed workspace when no project exists
|
|
16
|
+
Inspect first and prefill everything reliable. Ask only about missing rows. Use `./harbor-evolution/` as the proposed managed workspace when no project exists. Agent-facing Harbor Tools resolve every path inside the calling session's working directory, so a different configured `projectRoot` is only a Web Workbench or non-Agent fallback and must not block initialization.
|
|
17
17
|
|
|
18
18
|
Before any write, show the four rows plus inferred workspace, diagnostic/promotion scope, and deferred capabilities. The user may start initialization, modify the card, or open advanced configuration.
|
|
19
19
|
|