premanmcp 1.1.7 → 1.1.9
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/bin/agent.js +315 -36
- package/bin/desktop.js +2 -2
- package/bin/detect.js +29 -0
- package/bin/eval.js +430 -22
- package/bin/eval_entry.js +762 -0
- package/bin/eval_harness.py +166 -5
- package/bin/eval_runtime.js +156 -0
- package/bin/eval_target.js +160 -113
- package/bin/eval_templates.js +684 -0
- package/bin/runner.js +42 -3
- package/bin/shared.js +2 -1
- package/bin/target.js +105 -3
- package/package.json +2 -2
package/bin/eval_target.js
CHANGED
|
@@ -33,12 +33,27 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import { createServer } from "node:http";
|
|
36
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
37
|
+
import path from "node:path";
|
|
36
38
|
|
|
37
39
|
import { askAgent } from "./agent_dialects.js";
|
|
38
|
-
import { REPO_CONFIG, savedAgent } from "./target.js";
|
|
40
|
+
import { REPO_CONFIG, savedAgent, savedEntry } from "./target.js";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The placeholder the dashboard puts in the copyable command.
|
|
44
|
+
*
|
|
45
|
+
* Pasted unedited it has to mean "I did not answer this", not "look for a file
|
|
46
|
+
* called FILE" -- the command is built to be run verbatim, and refusing it
|
|
47
|
+
* would make the suggested line the one line that cannot work. Matched exactly
|
|
48
|
+
* rather than by shape, because a mistyped real path is a mistake worth
|
|
49
|
+
* reporting and must not be quietly turned into a search.
|
|
50
|
+
*
|
|
51
|
+
* Kept in step with `routes/eval_sessions.py::_command`, which writes it.
|
|
52
|
+
*/
|
|
53
|
+
const ENTRY_PLACEHOLDER = "FILE:FUNCTION";
|
|
39
54
|
import { createHash } from "node:crypto";
|
|
40
55
|
|
|
41
|
-
import {
|
|
56
|
+
import { resolveApiKey, storedKeyOrigin } from "./shared.js";
|
|
42
57
|
|
|
43
58
|
/**
|
|
44
59
|
* The credential to present to the agent under test.
|
|
@@ -81,74 +96,23 @@ function sameDeployment(a, b) {
|
|
|
81
96
|
|
|
82
97
|
export class TargetError extends Error {}
|
|
83
98
|
|
|
84
|
-
/** Ports a locally-running PreMan backend is plausibly on, in order of likelihood. */
|
|
85
|
-
const LOCAL_PORTS = [8000, 8001, 8010, 8011, 8002, 8080];
|
|
86
|
-
|
|
87
|
-
const PROBE_TIMEOUT_MS = 1_500;
|
|
88
|
-
|
|
89
|
-
/** The route that makes a service *the agent* rather than merely a PreMan service. */
|
|
90
|
-
const AGENT_ROUTE = "/workbench/conversations/{conversation_id}/messages";
|
|
91
|
-
|
|
92
|
-
async function get(url, { timeout = PROBE_TIMEOUT_MS } = {}) {
|
|
93
|
-
const controller = new AbortController();
|
|
94
|
-
const timer = setTimeout(() => controller.abort(), timeout);
|
|
95
|
-
try {
|
|
96
|
-
return await fetch(url, { signal: controller.signal });
|
|
97
|
-
} finally {
|
|
98
|
-
clearTimeout(timer);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Is the agent we know how to drive answering here?
|
|
104
|
-
*
|
|
105
|
-
* Two questions, and asking only the first is what makes discovery pick the
|
|
106
|
-
* wrong thing. `/health` says a PreMan-shaped service is on this port; it does
|
|
107
|
-
* not say the service has a chat agent. A developer's machine can easily have an
|
|
108
|
-
* old build, a cut-down demo API, or a differently-scoped deployment answering
|
|
109
|
-
* `/health` with the right name — and pointing an eval at one produces a run
|
|
110
|
-
* where every case fails with a 404 that looks like the agent misbehaving.
|
|
111
|
-
*
|
|
112
|
-
* So the second question is asked of the service's own OpenAPI document: does it
|
|
113
|
-
* publish the route the adapter is about to POST to. That is a capability check
|
|
114
|
-
* rather than a version check, which means it keeps working when the route moves
|
|
115
|
-
* behind a different service and stops working the moment it is renamed — both
|
|
116
|
-
* of which are the answers you want.
|
|
117
|
-
*/
|
|
118
|
-
async function probeAgent(url) {
|
|
119
|
-
try {
|
|
120
|
-
const health = await get(`${url}/health`);
|
|
121
|
-
if (!health.ok) return { url, ok: false, why: `health answered ${health.status}` };
|
|
122
|
-
const body = await health.json().catch(() => ({}));
|
|
123
|
-
const service = String(body?.service || "");
|
|
124
|
-
if (!/preman/i.test(service)) {
|
|
125
|
-
return { url, ok: false, why: `something else is on this port (${service || "no service name"})` };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const spec = await get(`${url}/openapi.json`, { timeout: 6_000 });
|
|
129
|
-
if (!spec.ok) return { url, ok: false, why: `a PreMan service, but it publishes no API document` };
|
|
130
|
-
const document = await spec.json().catch(() => ({}));
|
|
131
|
-
if (!document?.paths?.[AGENT_ROUTE]) {
|
|
132
|
-
return { url, ok: false, why: `a PreMan service, but it has no chat agent on it` };
|
|
133
|
-
}
|
|
134
|
-
return { url, ok: true, service };
|
|
135
|
-
} catch (error) {
|
|
136
|
-
return { url, ok: false, why: error.name === "AbortError" ? "no answer" : error.message };
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
99
|
/**
|
|
141
100
|
* Work out which agent this eval is about.
|
|
142
101
|
*
|
|
143
|
-
* The order is
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
102
|
+
* The order is "most explicit first", and every step of it names something
|
|
103
|
+
* rather than guessing at it. There used to be a step that swept localhost for
|
|
104
|
+
* a PreMan `/health`, and it is gone: it could only ever match PreMan's own
|
|
105
|
+
* backend -- a customer's agent on port 3000 was rejected as "something else is
|
|
106
|
+
* on this port" -- so in any repository but this one it was not discovery, it
|
|
107
|
+
* was a way to silently evaluate the wrong process and report the score as if
|
|
108
|
+
* it were the customer's. Evaluating PreMan's own agent is still possible, but
|
|
109
|
+
* it has to be asked for now.
|
|
110
|
+
*
|
|
111
|
+
* What replaces it is the entry point somebody approved: the file in this
|
|
112
|
+
* repository that runs its agent, called in-process. That is the thing an eval
|
|
113
|
+
* was always about, and it cannot be confused with a neighbouring service.
|
|
150
114
|
*/
|
|
151
|
-
export async function discoverTarget(args, { log = () => {} } = {}) {
|
|
115
|
+
export async function discoverTarget(args, { log = () => {}, allowSearch = true } = {}) {
|
|
152
116
|
const explicit = String(args.value("--target", "") || "").trim();
|
|
153
117
|
if (explicit) {
|
|
154
118
|
return {
|
|
@@ -160,64 +124,138 @@ export async function discoverTarget(args, { log = () => {} } = {}) {
|
|
|
160
124
|
};
|
|
161
125
|
}
|
|
162
126
|
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (saved) {
|
|
127
|
+
// PreMan's own chat agent, which used to be found by sweeping and is now
|
|
128
|
+
// named. Not probed, for the same reason `--target` is not: an address the
|
|
129
|
+
// caller typed is an answer, and checking it would only turn a clear failure
|
|
130
|
+
// at the first turn into a different message here.
|
|
131
|
+
const premanAgent = String(args.value("--preman-agent", "") || "").trim();
|
|
132
|
+
if (premanAgent) {
|
|
170
133
|
return {
|
|
171
|
-
kind: "
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
134
|
+
kind: "preman-chat",
|
|
135
|
+
baseUrl: premanAgent,
|
|
136
|
+
// Held here and never sent to PreMan's control plane. The adapter is the
|
|
137
|
+
// only thing that presents it, which is why the adapter runs locally.
|
|
138
|
+
token: agentToken(args, premanAgent, log),
|
|
139
|
+
label: `PreMan agent (${premanAgent})`,
|
|
140
|
+
how: "you named it with --preman-agent",
|
|
176
141
|
probed: [],
|
|
177
142
|
};
|
|
178
143
|
}
|
|
179
144
|
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
145
|
+
const projectPath = String(args.value("--path", "") || process.cwd());
|
|
146
|
+
|
|
147
|
+
// An entry point named for this invocation. Still generates and still asks:
|
|
148
|
+
// what `--entry` skips is the search, not the review.
|
|
149
|
+
const named = String(args.value("--entry", process.env.PREMAN_EVAL_ENTRY || "") || "").trim();
|
|
150
|
+
if (named && named !== ENTRY_PLACEHOLDER) {
|
|
151
|
+
const { entryFromNamedSymbol } = await import("./eval_entry.js");
|
|
152
|
+
return entryFromNamedSymbol(named, { projectPath, args, log });
|
|
153
|
+
}
|
|
154
|
+
if (named === ENTRY_PLACEHOLDER) {
|
|
155
|
+
log(
|
|
156
|
+
`--entry still has ${ENTRY_PLACEHOLDER} in it, so PreMan will look for the entry point itself.`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const saved = savedEntry(projectPath);
|
|
161
|
+
if (saved) {
|
|
162
|
+
const verified = verifyEntry(saved);
|
|
163
|
+
if (verified) return verified;
|
|
164
|
+
log(
|
|
165
|
+
`The approved entry point ${saved.file} has changed since it was approved, ` +
|
|
166
|
+
`so it is being re-checked rather than trusted.`
|
|
167
|
+
);
|
|
198
168
|
}
|
|
199
169
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
170
|
+
// What the repository says its agent is over HTTP, saved by
|
|
171
|
+
// `preman target detect`. Below the code entry point because a repository
|
|
172
|
+
// carrying both has deliberately run the search, and calling the agent is a
|
|
173
|
+
// more faithful measurement than calling a server that happens to be up.
|
|
174
|
+
const savedHttp = savedAgent(projectPath);
|
|
175
|
+
if (savedHttp) {
|
|
205
176
|
return {
|
|
206
|
-
kind: "
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
label: `
|
|
210
|
-
how:
|
|
211
|
-
probed,
|
|
177
|
+
kind: "external",
|
|
178
|
+
agent: savedHttp,
|
|
179
|
+
url: `${savedHttp.url}${savedHttp.path}`,
|
|
180
|
+
label: `your agent (${savedHttp.url}${savedHttp.path})`,
|
|
181
|
+
how: `${REPO_CONFIG} names it, and it speaks ${savedHttp.dialect}`,
|
|
182
|
+
probed: [],
|
|
212
183
|
};
|
|
213
184
|
}
|
|
214
185
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
186
|
+
// Writing a file into somebody's repository and running model-written code
|
|
187
|
+
// against their agent is not a thing to do because a job frame arrived. A
|
|
188
|
+
// daemon, a CI job and `--headless` all land here and stop.
|
|
189
|
+
if (!allowSearch || !process.stdout.isTTY || args.has("--headless") || args.has("--no-wait")) {
|
|
190
|
+
throw new TargetError(
|
|
191
|
+
`No approved entry point for this project, and nothing can be set up without ` +
|
|
192
|
+
`somebody to review it. Run \`preman eval target\` here once, approve the entry ` +
|
|
193
|
+
`point it finds, and this will work unattended afterwards. Or name one directly ` +
|
|
194
|
+
`with --entry <file>:<symbol>, or point at a running agent with --target <url>.`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const { searchForEntry } = await import("./eval_entry.js");
|
|
199
|
+
return searchForEntry({ projectPath, args, log });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The approved shim, if it is still the file that was approved.
|
|
204
|
+
*
|
|
205
|
+
* The digest is the whole point of recording one. An approval is of a specific
|
|
206
|
+
* text, and a shim edited afterwards -- by a person, a merge, or a tool -- has
|
|
207
|
+
* not been reviewed. Returning null sends the caller back through the gate
|
|
208
|
+
* rather than failing, because a hand-edited shim is usually an improvement
|
|
209
|
+
* somebody made on purpose and re-approving it takes one keystroke.
|
|
210
|
+
*/
|
|
211
|
+
export function verifyEntry(saved) {
|
|
212
|
+
const file = path.isAbsolute(saved.file) ? saved.file : path.join(saved.root, saved.file);
|
|
213
|
+
if (!existsSync(file)) return null;
|
|
214
|
+
const source = readFileSync(file, "utf8");
|
|
215
|
+
const digest = createHash("sha256").update(source).digest("hex");
|
|
216
|
+
if (saved.sha256 && digest !== saved.sha256) return null;
|
|
217
|
+
|
|
218
|
+
// Every file the approval produced, not only the wrapper.
|
|
219
|
+
//
|
|
220
|
+
// A `node_bridge` target is two files: the Python wrapper and the `.mjs` it
|
|
221
|
+
// spawns. Checking one of them means the other can be edited after approval
|
|
222
|
+
// and still run, while the digest that was supposed to notice says the target
|
|
223
|
+
// is unchanged. An older record has no `files` and falls back to the single
|
|
224
|
+
// hash above, so an approval made before this check keeps working.
|
|
225
|
+
const extra = Array.isArray(saved.files) ? saved.files : [];
|
|
226
|
+
for (const item of extra) {
|
|
227
|
+
const rel = String(item?.path || "");
|
|
228
|
+
const want = String(item?.sha256 || "");
|
|
229
|
+
if (!rel || !want) return null;
|
|
230
|
+
const full = path.isAbsolute(rel) ? rel : path.join(saved.root, rel);
|
|
231
|
+
if (!existsSync(full)) return null;
|
|
232
|
+
const got = createHash("sha256").update(readFileSync(full, "utf8")).digest("hex");
|
|
233
|
+
if (got !== want) return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const sysPath = saved.sysPath
|
|
237
|
+
? path.isAbsolute(saved.sysPath)
|
|
238
|
+
? saved.sysPath
|
|
239
|
+
: path.join(saved.root, saved.sysPath)
|
|
240
|
+
: path.dirname(file);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
kind: "callable",
|
|
244
|
+
ref: saved.ref,
|
|
245
|
+
file,
|
|
246
|
+
sysPath,
|
|
247
|
+
sha256: digest,
|
|
248
|
+
files: extra,
|
|
249
|
+
shape: saved.shape || "",
|
|
250
|
+
language: saved.language,
|
|
251
|
+
framework: saved.framework,
|
|
252
|
+
entryFile: saved.entryFile,
|
|
253
|
+
label: `your agent (${saved.entryFile || saved.ref})`,
|
|
254
|
+
how:
|
|
255
|
+
`${REPO_CONFIG} names ${saved.file}, you approved it` +
|
|
256
|
+
`${saved.approvedAt ? ` on ${saved.approvedAt}` : ""}, and it is unchanged since`,
|
|
257
|
+
probed: [],
|
|
258
|
+
};
|
|
221
259
|
}
|
|
222
260
|
|
|
223
261
|
// ── What the agent said it was doing ────────────────────────────────────
|
|
@@ -582,6 +620,15 @@ export async function startAdapter(target, args, { log = () => {}, onStep = null
|
|
|
582
620
|
return { url: target.url, close: async () => {}, translated: false };
|
|
583
621
|
}
|
|
584
622
|
|
|
623
|
+
if (target.kind === "callable") {
|
|
624
|
+
// There is no socket. The harness imports the entry point and calls it in
|
|
625
|
+
// the same process, so there is nothing to listen on and nothing to
|
|
626
|
+
// translate -- the shim already returns what an adapter would have sent.
|
|
627
|
+
// Per-turn narration still happens, but it comes from the shim's own log
|
|
628
|
+
// rather than from requests arriving here; see `targetStats`.
|
|
629
|
+
return { url: "", close: async () => {}, translated: false };
|
|
630
|
+
}
|
|
631
|
+
|
|
585
632
|
const state = {
|
|
586
633
|
conversations: new Map(),
|
|
587
634
|
turns: 0,
|