@chatpanel/events 0.88.0 → 0.89.1
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/agent.js +1 -0
- package/index.js +2 -0
- package/job.js +5 -4
- package/mcp-client.js +2 -2
- package/mcp-errors.js +1 -1
- package/model-candidates.js +2 -20
- package/model-ledger.js +27 -3
- package/package.json +3 -1
- package/recruit.js +424 -0
package/agent.js
CHANGED
|
@@ -236,6 +236,7 @@ export function agentFromForm(form) {
|
|
|
236
236
|
...(f.workdir ? { workdir: f.workdir } : {}),
|
|
237
237
|
createdBy: f.createdBy || 'person',
|
|
238
238
|
enabled: f.enabled !== false,
|
|
239
|
+
...(f.origin && isRecord(f.origin) ? { origin: f.origin } : {}),
|
|
239
240
|
...(f.createdAt ? { createdAt: f.createdAt } : {}),
|
|
240
241
|
};
|
|
241
242
|
const v = validateAgent(agent);
|
package/index.js
CHANGED
|
@@ -206,6 +206,8 @@ export { canonical, sha256, makeEntry, verifyChain, attest, verifyAttested, summ
|
|
|
206
206
|
export { ENGINE_KINDS as ENGINE_SPEC_KINDS, ROUTE_PREFERS, normalizePolicy, normalizeEngineSpec, validateEngineSpec, engineRef, engineKeyOf, describeEngine, tierOf } from './engine.js';
|
|
207
207
|
export { AGENT_ID_RE, APPLIES_TO, EGRESS_CLASSES, ASSISTANT_ID, AgentError, validateAgent, normalizeAgent, defineAgent, assistantAgent, engineOf, describeAgent, slugAgentId, resolveTeam, STARTER_AGENTS, starterAgents, blankAgent, agentFromForm, poolFor } from './agent.js';
|
|
208
208
|
export { LEDGER_VERSION, LEDGER_ENTRY_KINDS, DECLINE_REASONS, WITHDRAW_AFTER, ledgerKey, normalizeCall, makeLedgerEntry, summarizeEngine } from './model-ledger.js';
|
|
209
|
+
// Recruiting (F8 §12.2.4, pillars §13.4): the pool applies at once; an (agent, engine) pair is recruited; the evaluator is one optional structured call.
|
|
210
|
+
export { RECRUIT_SCHEMA, MIN_FIT, engineRow, engineRows, needForJob, routeFor, engineWorth, applications as jobApplications, evaluatorPrompt, parseEvaluation, decide as decideRecruit, proposalFromNeeds, proposalToAgent, carveBudget, recruitEvents, recruitJob } from './recruit.js';
|
|
209
211
|
export { DEFAULT_MIN_CALLS, cardOverride, applyCard } from './model-candidates.js';
|
|
210
212
|
export { SCM_KINDS, validateConnection, normalizeConnection, parseRemote, connectionFor, branchFor, worktreeDirFor, credentialEnv, describeConnection, blankConnection, connectionFromForm } from './scm-connection.js';
|
|
211
213
|
export { messagesFor, mergeTranscript, clipTranscript, clipMessage, newSteps, continuationNote, createControl, STEP_MAX_CHARS, TASK_TRANSCRIPT_MAX_CHARS } from './team-task.js';
|
package/job.js
CHANGED
|
@@ -96,12 +96,13 @@ export function defineJob(j) { return Object.freeze(normalizeJob(j)); }
|
|
|
96
96
|
export function canTransition(from, to) { return (NEXT[from] || []).includes(to); }
|
|
97
97
|
|
|
98
98
|
/** Applications are computed, not asked for: every eligible type in the pool applies at once. */
|
|
99
|
-
export function applyAll(job, pool, fitFn, { cards = {} } = {}) {
|
|
100
|
-
|
|
99
|
+
export function applyAll(job, pool, fitFn, { cards = {}, now = Date.now() } = {}) {
|
|
100
|
+
// A fit function may also say which ENGINE the agent would run on (recruit.js does); an
|
|
101
|
+
// application without one is not recruitable right now and sorts after those that are.
|
|
101
102
|
return (pool || [])
|
|
102
103
|
.filter((a) => a && a.enabled !== false && (a.appliesTo || ['jobs']).includes('jobs'))
|
|
103
|
-
.map((a) => { const f = fitFn(job, a, cards[a.id] || null); return { agentId: a.id, fit: f.score, reasons: f.reasons, pitch: '', at: now }; })
|
|
104
|
-
.sort((x, y) => y.fit - x.fit)
|
|
104
|
+
.map((a) => { const f = fitFn(job, a, cards[a.id] || null); return { agentId: a.id, ...(f.engine ? { engine: f.engine } : {}), fit: f.score, reasons: f.reasons, pitch: '', at: now }; })
|
|
105
|
+
.sort((x, y) => (!!y.engine - !!x.engine) || (y.fit - x.fit))
|
|
105
106
|
.slice(0, MAX_APPLICATIONS);
|
|
106
107
|
}
|
|
107
108
|
|
package/mcp-client.js
CHANGED
|
@@ -111,7 +111,7 @@ export class McpClient {
|
|
|
111
111
|
'Otherwise check that the command + args run in a terminal.',
|
|
112
112
|
);
|
|
113
113
|
}
|
|
114
|
-
throw new Error(`Can't reach the ChatPanel Bridge for local MCP (${e.message}).
|
|
114
|
+
throw new Error(`Can't reach the ChatPanel Bridge for local MCP (${e.message}). Install ChatPanel — the gateway brings the bridge (npm i -g @chatpanel/gateway && chatpanel-gateway --install) — or start one with \`npx @chatpanel/bridge\`.`);
|
|
115
115
|
}
|
|
116
116
|
if (message.id == null) return null; // notification → 202, no body
|
|
117
117
|
if (!res.ok) throw new Error(`Bridge MCP HTTP ${res.status}: ${(await res.text().catch(() => '')).slice(0, 200)}`);
|
|
@@ -137,7 +137,7 @@ export class McpClient {
|
|
|
137
137
|
if (e?.name === 'AbortError' || signal?.aborted) {
|
|
138
138
|
throw new Error('The MCP server didn’t respond in time (proxied through the bridge).');
|
|
139
139
|
}
|
|
140
|
-
throw new Error(`Can't reach the ChatPanel Bridge to proxy this server (${e.message}).
|
|
140
|
+
throw new Error(`Can't reach the ChatPanel Bridge to proxy this server (${e.message}). Install ChatPanel — the gateway brings the bridge — or start one with \`npx @chatpanel/bridge\`, or set this server to connect Directly.`);
|
|
141
141
|
}
|
|
142
142
|
if (!res.ok) {
|
|
143
143
|
const body = await res.text().catch(() => '');
|
package/mcp-errors.js
CHANGED
|
@@ -45,7 +45,7 @@ const RULES = [
|
|
|
45
45
|
explain: () => ({
|
|
46
46
|
summary: 'The ChatPanel Bridge is not running.',
|
|
47
47
|
detail: 'Local MCP servers are launched by the bridge, so nothing can start without it.',
|
|
48
|
-
fix: '
|
|
48
|
+
fix: 'Install ChatPanel — the gateway, which brings the bridge (`npm i -g @chatpanel/gateway && chatpanel-gateway --install`, or https://dl.chatpanel.net/install.sh) — or start a bridge with `npx @chatpanel/bridge`, then try again.',
|
|
49
49
|
blame: 'setup',
|
|
50
50
|
}),
|
|
51
51
|
},
|
package/model-candidates.js
CHANGED
|
@@ -262,28 +262,10 @@ export function applyOverride(inferred, override = {}) {
|
|
|
262
262
|
|
|
263
263
|
// ── The engine card over the guess (model-ledger.js, architecture-pillars.md §13.2) ──────
|
|
264
264
|
|
|
265
|
-
import { DEFAULT_MIN_CALLS } from './model-ledger.js';
|
|
265
|
+
import { DEFAULT_MIN_CALLS, cardOverride } from './model-ledger.js';
|
|
266
266
|
export { DEFAULT_MIN_CALLS };
|
|
267
|
-
const r3 = (v) => (v == null ? null : Math.round(v * 1000) / 1000);
|
|
268
267
|
|
|
269
|
-
|
|
270
|
-
* The override a card yields for model-candidates.js `applyOverride` — only the fields it
|
|
271
|
-
* has enough history for. `quality` is the mean rating (for `jobKind` when the card has
|
|
272
|
-
* ratings for it, else overall); `latencyMs` the observed p50 to first token (total when no
|
|
273
|
-
* ttft was recorded); `costPer1k` from the price when one is known; `available: false`
|
|
274
|
-
* only while it is declining right now. Returns `{ override, observed }`.
|
|
275
|
-
*/
|
|
276
|
-
export function cardOverride(card, { minCalls = DEFAULT_MIN_CALLS, jobKind = null } = {}) {
|
|
277
|
-
const override = {}; const observed = [];
|
|
278
|
-
if (!card) return { override, observed };
|
|
279
|
-
const q = (jobKind && card.quality?.byJobKind?.[jobKind]?.count >= minCalls) ? card.quality.byJobKind[jobKind] : card.quality?.overall;
|
|
280
|
-
if (q && q.count >= minCalls && q.avg != null) { override.quality = q.avg; observed.push('quality'); }
|
|
281
|
-
const lat = card.latency?.ttft?.n >= minCalls ? card.latency.ttft.p50 : card.latency?.total?.n >= minCalls ? card.latency.total.p50 : null;
|
|
282
|
-
if (lat != null) { override.latencyMs = lat; observed.push('latencyMs'); }
|
|
283
|
-
if (card.cost?.per1kIn != null && card.cost?.per1kOut != null) { override.costPer1k = r3((card.cost.per1kIn + card.cost.per1kOut) / 2); observed.push('costPer1k'); }
|
|
284
|
-
if (card.availability?.decliningNow) { override.available = false; observed.push('available'); }
|
|
285
|
-
return { override, observed };
|
|
286
|
-
}
|
|
268
|
+
export { cardOverride } from './model-ledger.js';
|
|
287
269
|
|
|
288
270
|
/**
|
|
289
271
|
* A router model with its card applied: `applyOverride` with what the card observed, then
|
package/model-ledger.js
CHANGED
|
@@ -196,9 +196,33 @@ export function summarizeEngine(entries, { minCalls = DEFAULT_MIN_CALLS, now = D
|
|
|
196
196
|
};
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
199
|
+
/**
|
|
200
|
+
* The override a card yields for model-candidates.js `applyOverride` — only the fields it
|
|
201
|
+
* has enough history for. `quality` is the mean rating (for `jobKind` when the card has
|
|
202
|
+
* ratings for it, else overall); `latencyMs` the observed p50 to first token (total when no
|
|
203
|
+
* ttft was recorded); `costPer1k` from the price when one is known; `available: false`
|
|
204
|
+
* only while it is declining right now. Returns `{ override, observed }`.
|
|
205
|
+
*
|
|
206
|
+
* Lives here, beside the card it reads, so recruit.js and a store without a router can use
|
|
207
|
+
* it; `applyCard` (the override over the guess) stays in model-candidates.js beside
|
|
208
|
+
* `applyOverride`, the seam it feeds.
|
|
209
|
+
*/
|
|
210
|
+
export function cardOverride(card, { minCalls = DEFAULT_MIN_CALLS, jobKind = null } = {}) {
|
|
211
|
+
const override = {}; const observed = [];
|
|
212
|
+
if (!card) return { override, observed };
|
|
213
|
+
const q = (jobKind && card.quality?.byJobKind?.[jobKind]?.count >= minCalls) ? card.quality.byJobKind[jobKind] : card.quality?.overall;
|
|
214
|
+
if (q && q.count >= minCalls && q.avg != null) { override.quality = q.avg; observed.push('quality'); }
|
|
215
|
+
const lat = card.latency?.ttft?.n >= minCalls ? card.latency.ttft.p50 : card.latency?.total?.n >= minCalls ? card.latency.total.p50 : null;
|
|
216
|
+
if (lat != null) { override.latencyMs = lat; observed.push('latencyMs'); }
|
|
217
|
+
// Six places, not three: a per-1k price is often 0.0004, and rounding it to 0 made a paid model read as free.
|
|
218
|
+
if (card.cost?.per1kIn != null && card.cost?.per1kOut != null) { override.costPer1k = Math.round(((card.cost.per1kIn + card.cost.per1kOut) / 2) * 1e6) / 1e6; observed.push('costPer1k'); }
|
|
219
|
+
if (card.availability?.decliningNow) { override.available = false; observed.push('available'); }
|
|
220
|
+
return { override, observed };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// `applyCard` — the card over the name-based guess — lives in model-candidates.js beside
|
|
224
|
+
// `applyOverride`, the seam it feeds; this module stays importable by a store that has no
|
|
225
|
+
// router (the gateway vendors it with scorecard.js only).
|
|
202
226
|
// Agent scores normalised by engine (§13.3) live beside the card they adjust: scorecard.js
|
|
203
227
|
// `adjustSummary` and `fit(job, type, summary, { qualityOf })`.
|
|
204
228
|
export { adjustSummary } from './scorecard.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.89.1",
|
|
4
4
|
"description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"./reach.js": "./reach.js",
|
|
67
67
|
"./recipe-tool.js": "./recipe-tool.js",
|
|
68
68
|
"./recipe.js": "./recipe.js",
|
|
69
|
+
"./recruit.js": "./recruit.js",
|
|
69
70
|
"./record-list.js": "./record-list.js",
|
|
70
71
|
"./redaction-tokens.js": "./redaction-tokens.js",
|
|
71
72
|
"./ref.js": "./ref.js",
|
|
@@ -196,6 +197,7 @@
|
|
|
196
197
|
"reach.js",
|
|
197
198
|
"recipe-tool.js",
|
|
198
199
|
"recipe.js",
|
|
200
|
+
"recruit.js",
|
|
199
201
|
"record-list.js",
|
|
200
202
|
"redaction-tokens.js",
|
|
201
203
|
"ref.js",
|
package/recruit.js
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
// Recruiting — applying and evaluating, the step between a posted job and a run (F8 §12.2.4,
|
|
2
|
+
// architecture-pillars.md §13.4).
|
|
3
|
+
//
|
|
4
|
+
// Applications are COMPUTED, not asked for: every agent in the pool applies to every job at
|
|
5
|
+
// once, scored by `fit` (scorecard.js) on its skills, tools, grants and its attested record —
|
|
6
|
+
// the model-ADJUSTED rating when the engine rows say what its engines were worth. Recruiting
|
|
7
|
+
// costs one model turn per job, not one per applicant, and that turn is optional.
|
|
8
|
+
//
|
|
9
|
+
// What is recruited is an (agent, engine) PAIR. An agent's engine may be fixed (`model`,
|
|
10
|
+
// `harness`), the chat's (`assistant`), or `auto` with a policy; `routeFor` resolves it
|
|
11
|
+
// against the ENGINE ROWS the host knows — every model or harness it can run, with reach,
|
|
12
|
+
// capabilities, quality / latency / cost (the engine card's observed values over the router's
|
|
13
|
+
// guess, `engineRow`) and whether it is available right now. Requirements eliminate first
|
|
14
|
+
// (reach from the project's privacy setting — never learned, only typed; a work grant needs a
|
|
15
|
+
// harness; tools need `tools`), the policy orders what survives over observed values, and the
|
|
16
|
+
// agent's own record on each engine breaks ties. An agent whose engine does not clear is
|
|
17
|
+
// still an applicant — a person should see it — but is not recruitable now.
|
|
18
|
+
//
|
|
19
|
+
// The EVALUATOR is one structured call (RECRUIT_SCHEMA) over the top applications: the pick
|
|
20
|
+
// and why, or "none fits" with the agent that should exist. The call is the host's
|
|
21
|
+
// (`runStructured` in a client; a gateway without a model skips it): `decide` takes its parsed
|
|
22
|
+
// answer when there is one and falls back to the best fit above a floor when there is not, so
|
|
23
|
+
// a job is recruited with or without a model. The decision lands on the project record as
|
|
24
|
+
// events (`recruitEvents`) — the pick and the reasons on the job, a proposal as a decision a
|
|
25
|
+
// person reads — never as a mutation.
|
|
26
|
+
//
|
|
27
|
+
// Pure, dependency-free; imports only what the gateway already vendors (scorecard.js,
|
|
28
|
+
// model-ledger.js, engine.js, agent.js, job.js, team.js, structured.js, budget.js).
|
|
29
|
+
|
|
30
|
+
import { fit, engineKey, normalizeEngine } from './scorecard.js';
|
|
31
|
+
import { cardOverride, DEFAULT_MIN_CALLS } from './model-ledger.js';
|
|
32
|
+
import { normalizeEngineSpec, engineKeyOf, describeEngine } from './engine.js';
|
|
33
|
+
import { engineOf, agentFromForm } from './agent.js';
|
|
34
|
+
import { applyAll } from './job.js';
|
|
35
|
+
import { WORK_GRANTS } from './team.js';
|
|
36
|
+
import { defineSchema, describeSchema, coerce } from './structured.js';
|
|
37
|
+
import { normalizeBudget } from './budget.js';
|
|
38
|
+
|
|
39
|
+
export const MIN_FIT = 0.5;
|
|
40
|
+
export const TOP_APPLICANTS = 5;
|
|
41
|
+
export const MAX_BRIEF_IN_PROMPT = 1500;
|
|
42
|
+
const REACH_RANK = { device: 0, trusted: 1, any: 2 };
|
|
43
|
+
|
|
44
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
45
|
+
const r3 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 1000) / 1000);
|
|
46
|
+
const clip = (s, n) => String(s || '').trim().slice(0, n);
|
|
47
|
+
const lower = (xs) => (Array.isArray(xs) ? xs : []).map((x) => String(x).toLowerCase());
|
|
48
|
+
|
|
49
|
+
// ── The evaluator's answer ────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export const RECRUIT_SCHEMA = defineSchema({
|
|
52
|
+
name: 'recruit',
|
|
53
|
+
purpose: 'which applicant gets the job, or that none fits and what agent should exist',
|
|
54
|
+
fields: {
|
|
55
|
+
// Not `required`: an emptied required field reads as "nothing" and would drop the proposal that rides beside it.
|
|
56
|
+
pick: { type: 'string', max: 64, describe: 'the id of the ONE applicant to recruit, or "" when none fits' },
|
|
57
|
+
why: { type: 'string', required: true, max: 400, describe: 'one or two sentences a person reads on the job page' },
|
|
58
|
+
confidence: { type: 'number', describe: '0 to 1' },
|
|
59
|
+
proposalName: { type: 'string', max: 60, describe: 'when none fits: the agent that should exist' },
|
|
60
|
+
proposalPurpose: { type: 'string', max: 300 },
|
|
61
|
+
proposalSkills: { type: 'string[]', maxItems: 12 },
|
|
62
|
+
proposalGrants: { type: 'string[]', maxItems: 8, describe: 'from: data, web, history, mcp, shell, fs:write, scm:read, scm:push, scm:pr' },
|
|
63
|
+
},
|
|
64
|
+
nothing: { pick: '', why: 'none fits' },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ── Engine rows: what the host can run, as the recruiter reads it ─────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One row from a host's candidate (a router model — `inferCandidate`'s shape: `id`, `model`,
|
|
71
|
+
* `reach`, `capabilities`, `quality`, `latencyMs`, `costPer1k`, `available`, `classUsed`;
|
|
72
|
+
* or anything carrying an `engine` ref) with its engine CARD applied: observed quality,
|
|
73
|
+
* latency, cost and availability replace the guess where there is enough history, and a
|
|
74
|
+
* withdrawn capability is gone. A bridge agent (`classUsed 'A'` / `kind 'bridge'`) is a
|
|
75
|
+
* harness — `claude/opus` is the harness `claude` asked to run `opus` — keyed the way the
|
|
76
|
+
* desktop's appointer and the engine ledger key it.
|
|
77
|
+
*/
|
|
78
|
+
export function engineRow(candidate, { card = null, minCalls = DEFAULT_MIN_CALLS, jobKind = null } = {}) {
|
|
79
|
+
if (!isRecord(candidate)) return null;
|
|
80
|
+
const c = candidate;
|
|
81
|
+
let engine = c.engine ? normalizeEngine(c.engine) : null;
|
|
82
|
+
if (!engine) {
|
|
83
|
+
const name = String(c.model || c.id || '');
|
|
84
|
+
if (!name) return null;
|
|
85
|
+
if (c.kind === 'bridge' || c.kind === 'harness' || c.classUsed === 'A') {
|
|
86
|
+
const slash = name.indexOf('/');
|
|
87
|
+
engine = slash > 0 ? { kind: 'harness', id: name.slice(0, slash), model: name.slice(slash + 1) } : { kind: 'harness', id: name };
|
|
88
|
+
} else engine = { kind: 'model', id: name };
|
|
89
|
+
}
|
|
90
|
+
const key = engineKey(engine);
|
|
91
|
+
if (!key) return null;
|
|
92
|
+
const { override, observed } = cardOverride(card, { minCalls, jobKind });
|
|
93
|
+
const withdrawn = new Set(card?.capabilities?.withdrawn || []);
|
|
94
|
+
const capabilities = [...new Set(lower(c.capabilities))].filter((x) => !withdrawn.has(x));
|
|
95
|
+
if (withdrawn.size && lower(c.capabilities).some((x) => withdrawn.has(x))) observed.push('capabilities');
|
|
96
|
+
const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
|
|
97
|
+
const reach = REACH_RANK[c.reach] != null ? c.reach : 'any';
|
|
98
|
+
// A model on this machine costs nothing per token — the router's own rule (`costOf`), kept
|
|
99
|
+
// here for a host that has no guess to offer; an unknown cost anywhere else stays unknown
|
|
100
|
+
// and orders as the dearest, so "we did not price it" never reads as "free".
|
|
101
|
+
const costPer1k = override.costPer1k ?? num(c.costPer1k) ?? (reach === 'device' && engine.kind === 'model' ? 0 : null);
|
|
102
|
+
return {
|
|
103
|
+
key,
|
|
104
|
+
engine,
|
|
105
|
+
label: clip(c.label || c.name || engineName(engine), 120),
|
|
106
|
+
reach,
|
|
107
|
+
capabilities,
|
|
108
|
+
quality: override.quality ?? num(c.quality),
|
|
109
|
+
latencyMs: override.latencyMs ?? num(c.latencyMs),
|
|
110
|
+
costPer1k,
|
|
111
|
+
costPerTask: num(card?.cost?.perTask),
|
|
112
|
+
availability: num(card?.availability?.rate),
|
|
113
|
+
available: override.available ?? (c.available !== false && c.usable !== false),
|
|
114
|
+
observed,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const engineName = (e) => `${e.id}${e.model && e.model !== e.id ? `/${e.model}` : ''}`;
|
|
118
|
+
|
|
119
|
+
/** Rows from a host's candidates and the cards it holds (by key). */
|
|
120
|
+
export function engineRows(candidates, { cards = {}, minCalls, jobKind } = {}) {
|
|
121
|
+
const out = []; const seen = new Set();
|
|
122
|
+
for (const c of Array.isArray(candidates) ? candidates : []) {
|
|
123
|
+
const row = engineRow(c, { card: null, minCalls, jobKind });
|
|
124
|
+
if (!row || seen.has(row.key)) continue;
|
|
125
|
+
seen.add(row.key);
|
|
126
|
+
out.push(cards[row.key] ? engineRow(c, { card: cards[row.key], minCalls, jobKind }) : row);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── What the job needs of an engine ───────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Requirements eliminate; they are never traded for cost or speed. A work grant (`shell`,
|
|
135
|
+
* `fs:write`, `scm:*`) can only be exercised by a harness — a chat model has no shell. Tools
|
|
136
|
+
* or any grant beyond `none` mean the turn may call tools. Reach is the project's privacy
|
|
137
|
+
* ceiling, typed, never learned.
|
|
138
|
+
*/
|
|
139
|
+
export function needForJob(job, { reach = 'any' } = {}) {
|
|
140
|
+
const grants = lower(job?.needs?.grants).filter((g) => g !== 'none');
|
|
141
|
+
const tools = lower(job?.needs?.tools);
|
|
142
|
+
const harness = grants.some((g) => WORK_GRANTS.includes(g));
|
|
143
|
+
const capabilities = [];
|
|
144
|
+
const why = [];
|
|
145
|
+
if (tools.length || grants.length) { capabilities.push('tools'); why.push('the job uses tools'); }
|
|
146
|
+
if (harness) why.push(`a work grant (${grants.filter((g) => WORK_GRANTS.includes(g)).join(', ')}) needs a harness`);
|
|
147
|
+
const r = REACH_RANK[reach] != null ? reach : 'any';
|
|
148
|
+
if (r !== 'any') why.push(`reach ≤ ${r} (the project's privacy setting)`);
|
|
149
|
+
return { capabilities, harness, reach: r, why };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Routing: the engine for this agent on this job ────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
const meets = (row, need, policy) => {
|
|
155
|
+
const why = [];
|
|
156
|
+
if (row.available === false) why.push('unavailable right now');
|
|
157
|
+
if (REACH_RANK[row.reach] > REACH_RANK[need.reach]) why.push(`reach ${row.reach} exceeds ${need.reach}`);
|
|
158
|
+
if (need.harness && row.engine.kind !== 'harness') why.push('not a harness');
|
|
159
|
+
const missing = need.capabilities.filter((c) => !row.capabilities.includes(c));
|
|
160
|
+
// A harness brings its own tools; the capability list of a bridge agent is the host's guess.
|
|
161
|
+
if (missing.length && !(row.engine.kind === 'harness' && missing.every((c) => c === 'tools'))) why.push(`lacks ${missing.join(', ')}`);
|
|
162
|
+
if (policy) {
|
|
163
|
+
const matches = (refs) => (refs || []).some((k) => k === row.key || k === `${row.engine.kind}:${row.engine.id}` || k === row.engine.id || k === row.engine.model);
|
|
164
|
+
if (policy.allow?.length && !matches(policy.allow)) why.push('not on the policy\'s allow list');
|
|
165
|
+
if (policy.deny?.length && matches(policy.deny)) why.push('on the policy\'s deny list');
|
|
166
|
+
if (policy.floor?.quality != null && row.quality != null && row.quality < policy.floor.quality) why.push(`quality ${row.quality} under the floor ${policy.floor.quality}`);
|
|
167
|
+
if (policy.floor?.availability != null && row.availability != null && row.availability < policy.floor.availability) why.push(`availability ${row.availability} under the floor ${policy.floor.availability}`);
|
|
168
|
+
if (policy.ceiling?.costPerTask != null && row.costPerTask != null && row.costPerTask > policy.ceiling.costPerTask) why.push(`$${row.costPerTask}/task over the ceiling`);
|
|
169
|
+
if (policy.ceiling?.latencyMs != null && row.latencyMs != null && row.latencyMs > policy.ceiling.latencyMs) why.push(`${row.latencyMs} ms over the ceiling`);
|
|
170
|
+
}
|
|
171
|
+
return why;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const ownRating = (summary, key) => (summary?.byEngine || []).find((r) => r.key === key)?.rating?.avg ?? null;
|
|
175
|
+
|
|
176
|
+
/** Order the rows that clear by the policy's preference; the agent's own record on an engine breaks ties. */
|
|
177
|
+
function orderByPolicy(rows, prefer, summary) {
|
|
178
|
+
const q = (r) => r.quality ?? 0.5;
|
|
179
|
+
const cost = (r) => r.costPerTask ?? r.costPer1k ?? null;
|
|
180
|
+
const maxCost = Math.max(...rows.map((r) => cost(r) ?? 0), 0) || 1;
|
|
181
|
+
const maxLat = Math.max(...rows.map((r) => r.latencyMs ?? 0), 0) || 1;
|
|
182
|
+
const own = (r) => { const v = ownRating(summary, r.key); return v == null ? 0 : v - 0.5; };
|
|
183
|
+
const score = (r) => {
|
|
184
|
+
switch (prefer) {
|
|
185
|
+
case 'cheapest-that-clears': return -((cost(r) ?? maxCost) / maxCost) + q(r) * 0.01;
|
|
186
|
+
case 'best-quality': return q(r) - ((cost(r) ?? maxCost) / maxCost) * 0.01;
|
|
187
|
+
case 'fastest': return -((r.latencyMs ?? maxLat) / maxLat) + q(r) * 0.01;
|
|
188
|
+
default: return q(r) * 0.5 + (1 - (cost(r) ?? maxCost) / maxCost) * 0.25 + (1 - (r.latencyMs ?? maxLat) / maxLat) * 0.25;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
return rows.map((r) => ({ row: r, score: score(r) + own(r) * 0.05 })).sort((a, b) => b.score - a.score).map((x) => x.row);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const rowLine = (r) => `${r.key} (quality ${r.quality ?? '?'}${r.observed.includes('quality') ? ' observed' : ''}${r.costPerTask != null ? `, $${r.costPerTask}/task` : r.costPer1k != null ? `, $${r.costPer1k}/1k` : ''}${r.latencyMs != null ? `, ${r.latencyMs} ms` : ''})`;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The engine this agent would run this job on, and why — `{ engine, key, reasons,
|
|
198
|
+
* alternatives, exploration, clears }`; `clears: false` (engine null) when nothing does, with
|
|
199
|
+
* the reasons. `rows` are the host's `engineRows`; an empty roster trusts a fixed spec and
|
|
200
|
+
* refuses `auto` (nothing to pick from). `explore` takes one tier cheaper than the policy's
|
|
201
|
+
* pick when a cheaper row clears — the project loop's bounded exploration (§13.4); never for
|
|
202
|
+
* a harness.
|
|
203
|
+
*/
|
|
204
|
+
export function routeFor(agent, job, { rows = [], summary = null, need = null, reach = 'any', chatModel = null, explore = false } = {}) {
|
|
205
|
+
const n = need || needForJob(job, { reach });
|
|
206
|
+
const spec = engineOf(agent, { chatModel });
|
|
207
|
+
const list = Array.isArray(rows) ? rows.filter(Boolean) : [];
|
|
208
|
+
const none = (reasons) => ({ engine: null, key: null, reasons, alternatives: [], exploration: false, clears: false });
|
|
209
|
+
if (spec.kind !== 'auto') {
|
|
210
|
+
const key = engineKeyOf(spec);
|
|
211
|
+
const exact = list.find((r) => r.key === key);
|
|
212
|
+
// A harness card without a model matches any row of that harness; a model spec without a
|
|
213
|
+
// provider matches the row that runs that model anywhere.
|
|
214
|
+
const near = exact || list.find((r) => r.engine.kind === spec.kind && (spec.kind === 'harness' ? r.engine.id === spec.harnessId && !spec.model : (r.engine.id === spec.model || r.engine.model === spec.model) && !spec.providerId));
|
|
215
|
+
if (!list.length) return { engine: normalizeEngine({ kind: spec.kind, id: spec.kind === 'harness' ? spec.harnessId : (spec.providerId || spec.model), model: spec.model }), key, reasons: [`${describeEngine(spec)} — pinned by the agent; no roster to check it against`], alternatives: [], exploration: false, clears: true };
|
|
216
|
+
if (!near) return none([`${describeEngine(spec)} is pinned by the agent but is not installed or configured here`]);
|
|
217
|
+
const why = meets(near, n, null);
|
|
218
|
+
if (why.length) return none([`${describeEngine(spec)} is pinned by the agent but ${why.join('; ')}`]);
|
|
219
|
+
return { engine: near.engine, key: near.key, reasons: ['pinned by the agent', ...n.why], alternatives: [], exploration: false, clears: true };
|
|
220
|
+
}
|
|
221
|
+
if (!list.length) return none(['engine is auto and the roster is empty']);
|
|
222
|
+
const policy = spec.policy || {};
|
|
223
|
+
const rejected = [];
|
|
224
|
+
const cleared = list.filter((r) => { const why = meets(r, n, policy); if (why.length) rejected.push(`${r.key}: ${why.join('; ')}`); return !why.length; });
|
|
225
|
+
if (!cleared.length) return none([`no engine clears ${n.harness ? 'a harness with ' : ''}${n.capabilities.join(', ') || 'the requirements'}${n.reach !== 'any' ? ` within reach ${n.reach}` : ''}${policy.prefer ? ` under ${policy.prefer}` : ''}`, ...rejected.slice(0, 4)]);
|
|
226
|
+
const ordered = orderByPolicy(cleared, policy.prefer || 'balanced', summary);
|
|
227
|
+
let pick = ordered[0];
|
|
228
|
+
let exploration = false;
|
|
229
|
+
const cost = (r) => r.costPerTask ?? r.costPer1k ?? null;
|
|
230
|
+
if (explore && pick.engine.kind !== 'harness') {
|
|
231
|
+
const cheaper = ordered.filter((r) => r.engine.kind !== 'harness' && cost(r) != null && cost(pick) != null && cost(r) < cost(pick)).sort((a, b) => cost(b) - cost(a));
|
|
232
|
+
if (cheaper.length) { exploration = true; pick = cheaper[0]; }
|
|
233
|
+
}
|
|
234
|
+
const reasons = [
|
|
235
|
+
exploration ? `exploration: one tier cheaper than the policy's pick (${ordered[0].key})` : `${(policy.prefer || 'balanced').replace(/-/g, ' ')}: ${rowLine(pick)}`,
|
|
236
|
+
...n.why,
|
|
237
|
+
];
|
|
238
|
+
const own = ownRating(summary, pick.key);
|
|
239
|
+
if (own != null) reasons.push(`this agent rated ${Math.round(own * 100)}% on it before`);
|
|
240
|
+
if (policy.floor?.quality != null || policy.ceiling?.costPerTask != null || policy.ceiling?.latencyMs != null) reasons.push(`${cleared.length} of ${list.length} engines clear the policy`);
|
|
241
|
+
return { engine: pick.engine, key: pick.key, reasons, alternatives: ordered.filter((r) => r !== pick).slice(0, 4).map((r) => r.engine), exploration, clears: true };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Applying: the whole pool at once ──────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/** `qualityOf` / `costOf` for `adjustSummary`, read from the rows: what each engine is worth. */
|
|
247
|
+
export function engineWorth(rows) {
|
|
248
|
+
const byKey = new Map((rows || []).filter(Boolean).map((r) => [r.key, r]));
|
|
249
|
+
return {
|
|
250
|
+
qualityOf: (key) => byKey.get(key)?.quality ?? null,
|
|
251
|
+
costOf: (key) => { const r = byKey.get(key); return r ? (r.costPerTask ?? null) : null; },
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Every eligible agent applies: `fit` (needs → adjusted record → size) plus the engine it
|
|
257
|
+
* would run on. `summaries` are scorecard cards by agent id (`summarize()`); `rows` are
|
|
258
|
+
* `engineRows`. Best first, recruitable (an engine clears) before not. Each application is
|
|
259
|
+
* `{ agentId, engine?, fit, reasons, pitch, at, covers }` — job.js's shape, the record's, plus
|
|
260
|
+
* `covers` (has a skill the job names) for `decide`; the record drops it.
|
|
261
|
+
*/
|
|
262
|
+
export function applications(job, pool, { summaries = {}, rows = [], reach = 'any', chatModel = null, adjust = true, now = Date.now() } = {}) {
|
|
263
|
+
const need = needForJob(job, { reach });
|
|
264
|
+
const worth = engineWorth(rows);
|
|
265
|
+
const fitFn = (j, agent, summary) => {
|
|
266
|
+
const f = fit(j, { ...agent, tools: agent.tools || agent.grants }, summary, adjust ? worth : { adjust: false });
|
|
267
|
+
const route = routeFor(agent, j, { rows, summary, need, chatModel });
|
|
268
|
+
return { score: f.score, reasons: [...f.reasons, ...route.reasons].slice(0, 8), ...(route.clears ? { engine: route.engine } : {}), covers: coversSkills(j, agent) };
|
|
269
|
+
};
|
|
270
|
+
// `covers` — has at least one skill the job names (or the job names none) — rides on the
|
|
271
|
+
// live application for `decide`; the record keeps job.js's shape and drops it.
|
|
272
|
+
const covered = new Map((pool || []).map((a) => [a?.id, coversSkills(job, a)]));
|
|
273
|
+
return applyAll(job, pool, fitFn, { cards: summaries, now }).map((a) => ({ ...a, covers: covered.get(a.agentId) !== false }));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** A job that names skills is not given to an agent with none of them on fit alone: tools and grants it never asked for count for nothing. */
|
|
277
|
+
function coversSkills(job, agent) {
|
|
278
|
+
const want = lower(job?.needs?.skills);
|
|
279
|
+
if (!want.length) return true;
|
|
280
|
+
const has = new Set(lower(agent?.skills));
|
|
281
|
+
return want.some((s) => has.has(s));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Evaluating: one structured call, or none ──────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/** The evaluator's instruction: the job, the top applicants with their fit, engine and reasons, the shape to answer in. */
|
|
287
|
+
export function evaluatorPrompt(job, apps, pool = [], { rows = [], top = TOP_APPLICANTS } = {}) {
|
|
288
|
+
const byId = new Map((pool || []).map((a) => [a.id, a]));
|
|
289
|
+
const rowOf = new Map((rows || []).filter(Boolean).map((r) => [r.key, r]));
|
|
290
|
+
const needs = job?.needs || {};
|
|
291
|
+
const lines = (apps || []).slice(0, top).map((a) => {
|
|
292
|
+
const agent = byId.get(a.agentId) || {};
|
|
293
|
+
const key = a.engine ? engineKey(a.engine) : null;
|
|
294
|
+
const row = key ? rowOf.get(key) : null;
|
|
295
|
+
return `- ${a.agentId} (fit ${Math.round((a.fit || 0) * 100)}%)${agent.purpose ? ` — ${clip(agent.purpose, 160)}` : ''}; skills: ${(agent.skills || []).join(', ') || 'none'}; grants: ${(agent.grants || []).join(', ') || 'none'}; ${key ? `engine: ${row ? rowLine(row) : key}` : 'NOT RECRUITABLE NOW — no engine clears'}; ${(a.reasons || []).slice(0, 4).join('; ')}`;
|
|
296
|
+
});
|
|
297
|
+
return [
|
|
298
|
+
`You are the evaluator for the job "${clip(job?.title, 200)}" on project ${job?.projectId || '?'}. Recruit ONE applicant, or say none fits.`,
|
|
299
|
+
`Brief: ${clip(job?.brief, MAX_BRIEF_IN_PROMPT)}`,
|
|
300
|
+
`Needs — skills: ${(needs.skills || []).join(', ') || 'none named'}; tools: ${(needs.tools || []).join(', ') || 'none named'}; grants: ${(needs.grants || []).join(', ') || 'none'}${job?.budget ? `; budget: ${Object.entries(job.budget).map(([k, v]) => `${v} ${k}`).join(', ')}` : ''}.`,
|
|
301
|
+
'',
|
|
302
|
+
'Applicants, best computed fit first (fit = the skills, tools and grants the job names, then the attested record, then size):',
|
|
303
|
+
...(lines.length ? lines : ['- (no one applied)']),
|
|
304
|
+
'',
|
|
305
|
+
'Rules: prefer the applicant that has what the job names and a record of clearing work like it on the engine shown; never pick one marked NOT RECRUITABLE NOW; a lower fit is right only when its reasons show the higher one lacks something the brief needs. When no applicant has the skills the job names, answer pick "" and propose the agent that should exist.',
|
|
306
|
+
'',
|
|
307
|
+
describeSchema(RECRUIT_SCHEMA),
|
|
308
|
+
].join('\n');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The evaluator's answer, read through the schema and checked against the applications: a
|
|
313
|
+
* pick must be a recruitable applicant, else it is "none". Returns `{ pick, why, confidence,
|
|
314
|
+
* proposal }` or null when the text is unreadable.
|
|
315
|
+
*/
|
|
316
|
+
export function parseEvaluation(text, apps = []) {
|
|
317
|
+
const got = coerce(text, RECRUIT_SCHEMA);
|
|
318
|
+
if (!got) return null;
|
|
319
|
+
const v = got.value;
|
|
320
|
+
const pick = String(v.pick || '').trim();
|
|
321
|
+
const app = pick ? (apps || []).find((a) => a.agentId === pick) : null;
|
|
322
|
+
const proposal = v.proposalName ? { name: clip(v.proposalName, 60), purpose: clip(v.proposalPurpose, 300), skills: (v.proposalSkills || []).map((s) => clip(s, 80)).filter(Boolean), grants: (v.proposalGrants || []).map((g) => clip(g, 64)).filter(Boolean) } : null;
|
|
323
|
+
if (app && app.engine) return { pick, why: clip(v.why, 400) || 'the evaluator\'s pick', confidence: r3(v.confidence), proposal: null };
|
|
324
|
+
return { pick: '', why: app ? `the evaluator picked ${pick}, which no engine can run right now` : clip(v.why, 400) || 'none fits', confidence: r3(v.confidence), proposal };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The decision: the evaluator's when one was made; else the best recruitable fit at or above
|
|
329
|
+
* `minFit`; else none, with the agent the job's needs describe as the proposal. Returns
|
|
330
|
+
* `{ kind: 'recruit', agentId, engine, fit, why, by }` or `{ kind: 'none', why, proposal, by }`.
|
|
331
|
+
*/
|
|
332
|
+
export function decide(job, apps, { evaluation = null, minFit = MIN_FIT } = {}) {
|
|
333
|
+
const list = apps || [];
|
|
334
|
+
if (evaluation && evaluation.pick) {
|
|
335
|
+
const app = list.find((a) => a.agentId === evaluation.pick && a.engine);
|
|
336
|
+
if (app) return { kind: 'recruit', agentId: app.agentId, engine: app.engine, fit: app.fit, why: evaluation.why, by: 'evaluator' };
|
|
337
|
+
}
|
|
338
|
+
if (evaluation && !evaluation.pick) return { kind: 'none', why: evaluation.why, proposal: evaluation.proposal || proposalFromNeeds(job), by: 'evaluator' };
|
|
339
|
+
const best = list.find((a) => a.engine && a.covers !== false);
|
|
340
|
+
if (best && best.fit >= minFit) return { kind: 'recruit', agentId: best.agentId, engine: best.engine, fit: best.fit, why: `best fit (${Math.round(best.fit * 100)}%): ${(best.reasons || [])[0] || 'meets the needs'}`, by: 'fit' };
|
|
341
|
+
const why = !list.length ? 'no one in the pool applies to jobs'
|
|
342
|
+
: !best ? (list.some((a) => a.engine) ? `no applicant has a skill the job names (${(job?.needs?.skills || []).join(', ')})` : `${list.length} applied but no engine clears for any of them: ${(list[0].reasons || []).find((r) => /engine|pinned|roster/.test(r)) || 'see the applications'}`)
|
|
343
|
+
: `the best fit is ${Math.round(best.fit * 100)}%, under the ${Math.round(minFit * 100)}% floor: ${(best.reasons || []).find((r) => /missing/.test(r)) || best.reasons?.[0] || ''}`;
|
|
344
|
+
return { kind: 'none', why, proposal: proposalFromNeeds(job), by: 'fit' };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The agent a job's needs describe — what to propose when no one fits. */
|
|
348
|
+
export function proposalFromNeeds(job) {
|
|
349
|
+
const needs = job?.needs || {};
|
|
350
|
+
return { name: clip(job?.title, 60) || 'New agent', purpose: `Does jobs like "${clip(job?.title, 80)}".`, skills: [...(needs.skills || [])], grants: (needs.grants || []).length ? [...needs.grants] : ['none'] };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* A proposal as an agent card for a person to approve — validated by the pool's own form
|
|
355
|
+
* (agentFromForm), engine `auto`, `createdBy: 'evaluator'`, its origin the job. Nothing
|
|
356
|
+
* joins the pool without a decision (D-A2): this returns the card, it does not store it.
|
|
357
|
+
*/
|
|
358
|
+
export function proposalToAgent(proposal, job, { by = 'evaluator' } = {}) {
|
|
359
|
+
const p = proposal || proposalFromNeeds(job);
|
|
360
|
+
const known = new Set(['data', 'web', 'history', 'mcp', ...WORK_GRANTS]);
|
|
361
|
+
const grants = (p.grants || []).map((g) => String(g).toLowerCase()).filter((g) => known.has(g) || /^mcp:/.test(g));
|
|
362
|
+
return agentFromForm({
|
|
363
|
+
name: p.name, purpose: p.purpose, skills: p.skills,
|
|
364
|
+
prompt: `You are ${p.name}. ${p.purpose || ''}\n\nYou were proposed for the job "${clip(job?.title, 200)}" because no agent in the pool fit it. Do work like it well; ask on the thread when the brief is unclear.`,
|
|
365
|
+
grants: grants.length ? grants : ['none'], engine: { kind: 'auto', prefer: 'balanced' }, appliesTo: ['jobs'], createdBy: by,
|
|
366
|
+
origin: { kind: 'proposal', projectId: job?.projectId, jobId: job?.id },
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Landing it on the record ──────────────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The budget a recruit gets: the job's own when it has one, else an equal share of what the
|
|
374
|
+
* project has left (its budget minus its spend) across the jobs still to be recruited — so
|
|
375
|
+
* one unbudgeted job cannot take the whole project. `record` is the project record
|
|
376
|
+
* (project.js `foldProject`).
|
|
377
|
+
*/
|
|
378
|
+
export function carveBudget(job, record = null) {
|
|
379
|
+
if (job?.budget && Object.keys(job.budget).length) return normalizeBudget(job.budget);
|
|
380
|
+
const cap = record?.page?.budget || {};
|
|
381
|
+
const spent = record?.spend || {};
|
|
382
|
+
const waiting = Math.max(1, (record?.jobs || []).filter((j) => ['open', 'evaluating'].includes(j.status)).length);
|
|
383
|
+
const out = {};
|
|
384
|
+
for (const k of ['tokens', 'calls', 'ms', 'usd']) {
|
|
385
|
+
if (!(Number(cap[k]) > 0)) continue;
|
|
386
|
+
const left = Math.max(0, Number(cap[k]) - (Number(spent[k]) || 0));
|
|
387
|
+
if (left > 0) out[k] = k === 'usd' ? Math.round((left / waiting) * 100) / 100 : Math.max(1, Math.floor(left / waiting));
|
|
388
|
+
}
|
|
389
|
+
return Object.keys(out).length ? out : null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The events that record a recruiting pass on the project (project.js fold): the job moves
|
|
394
|
+
* to `evaluating` with its applications, then to `recruited` with the pair, the budget and
|
|
395
|
+
* the why — or back to `open`, with the proposal as a decision a person reads.
|
|
396
|
+
*/
|
|
397
|
+
export function recruitEvents(job, apps, decision, { by = 'evaluator', at = Date.now(), record = null } = {}) {
|
|
398
|
+
const events = [{ type: 'job.updated', at, job: { id: job.id, status: 'evaluating', applications: (apps || []).map(({ covers: _c, ...a }) => a) }, by }];
|
|
399
|
+
if (decision?.kind === 'recruit') {
|
|
400
|
+
const budget = carveBudget(job, record);
|
|
401
|
+
events.push({ type: 'job.updated', at, job: { id: job.id, status: 'recruited', recruited: { agentId: decision.agentId, engine: decision.engine, ...(budget ? { budget } : {}), by: decision.by || by, at, why: clip(decision.why, 600) } }, by });
|
|
402
|
+
} else {
|
|
403
|
+
events.push({ type: 'job.updated', at, job: { id: job.id, status: 'open' }, by });
|
|
404
|
+
const p = decision?.proposal;
|
|
405
|
+
events.push({ type: 'project.decision', at, by, kind: 'proposal', text: `No one in the pool fits "${clip(job.title, 120)}": ${clip(decision?.why, 400)}${p ? ` Proposed: ${p.name}${p.skills?.length ? ` — skills ${p.skills.join(', ')}` : ''}${p.grants?.length ? `; grants ${p.grants.join(', ')}` : ''}.` : ''}`, refs: [`job:${job.id}`] });
|
|
406
|
+
}
|
|
407
|
+
return events;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* One pass, end to end: apply → (evaluate) → decide → the events. `ask(prompt) → text |
|
|
412
|
+
* null` is the host's structured call; absent or failing, the fit decides. Returns
|
|
413
|
+
* `{ applications, evaluation, decision, events, prompt }`.
|
|
414
|
+
*/
|
|
415
|
+
export async function recruitJob(job, pool, { summaries = {}, rows = [], reach = 'any', chatModel = null, record = null, ask = null, minFit = MIN_FIT, by = 'evaluator', now = Date.now() } = {}) {
|
|
416
|
+
const apps = applications(job, pool, { summaries, rows, reach, chatModel, now });
|
|
417
|
+
const prompt = evaluatorPrompt(job, apps, pool, { rows });
|
|
418
|
+
let evaluation = null;
|
|
419
|
+
if (ask && apps.some((a) => a.engine)) {
|
|
420
|
+
try { const text = await ask(prompt, RECRUIT_SCHEMA); evaluation = text == null ? null : (typeof text === 'string' ? parseEvaluation(text, apps) : parseEvaluation(JSON.stringify(text), apps)); } catch { evaluation = null; }
|
|
421
|
+
}
|
|
422
|
+
const decision = decide(job, apps, { evaluation, minFit });
|
|
423
|
+
return { applications: apps, evaluation, decision, events: recruitEvents(job, apps, decision, { by: decision.by === 'evaluator' ? by : 'fit', at: now, record }), prompt };
|
|
424
|
+
}
|