@llm4ts/runner 2.8.0 → 2.9.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.
@@ -0,0 +1,318 @@
1
+ // Loading the executor roster (ADR 0019): the user's file, the repository's
2
+ // override, the per-run choice (`LLM4TS_ROSTER`, `LLM4TS_EXECUTORS`), and the
3
+ // mapping from an executor to the connector config the registry resolves.
4
+ // Harness knowledge lives here, beside the connector presets; the roster
5
+ // itself (`@llm4ts/flow/Roster`) knows only ids, roles and slots.
6
+ import { homedir } from "node:os"
7
+ import { join } from "node:path"
8
+ import * as Clock from "effect/Clock"
9
+ import * as Duration from "effect/Duration"
10
+ import * as Effect from "effect/Effect"
11
+ import * as Schema from "effect/Schema"
12
+ import {
13
+ ApiConnectorConfig,
14
+ CliConnectorConfig,
15
+ type ConnectorConfig
16
+ } from "@llm4ts/core/ConnectorConfig"
17
+ import { RosterInvalid, type FlowError } from "@llm4ts/flow/FlowError"
18
+ import type { PlainFileStoreShape } from "@llm4ts/flow/Persistence"
19
+ import {
20
+ Exclusion,
21
+ RosterDocument,
22
+ coderSlotsOf,
23
+ describeExclusion,
24
+ hasRole,
25
+ makeRosterStateStore,
26
+ mergeRosterDocuments,
27
+ narrowRoster,
28
+ parseDuration,
29
+ rolesOf,
30
+ rosterViolations,
31
+ slotsOf,
32
+ type ExecutorSpec
33
+ } from "@llm4ts/flow/Roster"
34
+ import { apiPresetFor, asReadOnly, coderFor, withEnvironment, withModel } from "./Connectors.ts"
35
+
36
+ type Environment = Readonly<Record<string, string | undefined>>
37
+
38
+ const home = (environment: Environment): string => environment.HOME ?? homedir()
39
+
40
+ /** `${XDG_CONFIG_HOME:-~/.config}/llm4ts/roster.json`. */
41
+ export const userRosterPath = (environment: Environment): string =>
42
+ join(environment.XDG_CONFIG_HOME ?? join(home(environment), ".config"), "llm4ts", "roster.json")
43
+
44
+ /** `<repo>/.llm4ts/roster.json`: overrides the user's entries by id. */
45
+ export const repoRosterPath = (workDir: string): string => join(workDir, ".llm4ts", "roster.json")
46
+
47
+ /** `${XDG_STATE_HOME:-~/.local/state}/llm4ts/roster-state.json`: exclusions that outlive a run. */
48
+ export const rosterStatePath = (environment: Environment): string =>
49
+ join(
50
+ environment.XDG_STATE_HOME ?? join(home(environment), ".local", "state"),
51
+ "llm4ts",
52
+ "roster-state.json"
53
+ )
54
+
55
+ const reference = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g
56
+
57
+ /** `${VAR}` references resolved from the environment; the names of missing ones. */
58
+ export const substituteEnvironment = (
59
+ values: Readonly<Record<string, string>>,
60
+ environment: Environment
61
+ ): {
62
+ readonly values: Readonly<Record<string, string>>
63
+ readonly missing: ReadonlyArray<string>
64
+ } => {
65
+ const missing: Array<string> = []
66
+ const resolved: Record<string, string> = {}
67
+ for (const [key, value] of Object.entries(values)) {
68
+ resolved[key] = value.replace(reference, (_match, name: string) => {
69
+ const found = environment[name]
70
+ if (found === undefined) {
71
+ missing.push(name)
72
+ return ""
73
+ }
74
+ return found
75
+ })
76
+ }
77
+ return { values: resolved, missing: [...new Set(missing)] }
78
+ }
79
+
80
+ /** Whether a harness name is an API provider (reasoning roles only). */
81
+ export const isApiHarness = (harness: string): boolean =>
82
+ coderFor(harness) === undefined && apiPresetFor(harness) !== undefined
83
+
84
+ /**
85
+ * The connector config an executor runs with: its harness preset, model,
86
+ * flags, endpoint and environment, read-only for a reasoning role.
87
+ */
88
+ export const executorConfig = (
89
+ spec: ExecutorSpec,
90
+ readOnly: boolean,
91
+ environment: Environment
92
+ ): ConnectorConfig | undefined => {
93
+ const cli = coderFor(spec.harness)
94
+ if (cli !== undefined) {
95
+ const modelled = spec.model === undefined ? cli : withModel(cli, spec.model)
96
+ const flagged =
97
+ spec.flags === undefined
98
+ ? modelled
99
+ : CliConnectorConfig.make({ ...modelled, flags: { ...modelled.flags, ...spec.flags } })
100
+ const withEnv =
101
+ spec.env === undefined
102
+ ? flagged
103
+ : withEnvironment(flagged, substituteEnvironment(spec.env, environment).values)
104
+ return readOnly ? asReadOnly(withEnv) : withEnv
105
+ }
106
+ const api = apiPresetFor(spec.harness)
107
+ if (api === undefined) {
108
+ return undefined
109
+ }
110
+ const modelled = spec.model === undefined ? api : withModel(api, spec.model)
111
+ return spec.baseUrl === undefined
112
+ ? modelled
113
+ : ApiConnectorConfig.make({ ...modelled, baseUrl: spec.baseUrl })
114
+ }
115
+
116
+ /** The flow-level violations plus what needs the harness table and the environment. */
117
+ export const rosterLoadViolations = (
118
+ document: RosterDocument,
119
+ environment: Environment
120
+ ): ReadonlyArray<string> => {
121
+ const violations = [...rosterViolations(document)]
122
+ for (const spec of document.executors) {
123
+ const where = `executor '${spec.id}'`
124
+ if (coderFor(spec.harness) === undefined && apiPresetFor(spec.harness) === undefined) {
125
+ violations.push(`${where} has an unknown harness '${spec.harness}'`)
126
+ } else if (isApiHarness(spec.harness) && hasRole(spec, "coder")) {
127
+ violations.push(
128
+ `${where} is an API provider ('${spec.harness}'): it may reason, but coding needs a CLI harness`
129
+ )
130
+ }
131
+ const missing = substituteEnvironment(spec.env ?? {}, environment).missing
132
+ if (missing.length > 0) {
133
+ violations.push(`${where} references unset variable(s) ${missing.join(", ")}`)
134
+ }
135
+ }
136
+ return violations
137
+ }
138
+
139
+ const parseRoster = (path: string, text: string): Effect.Effect<RosterDocument, RosterInvalid> =>
140
+ Effect.try({
141
+ try: (): unknown => JSON.parse(text),
142
+ catch: (error) =>
143
+ RosterInvalid.make({
144
+ path,
145
+ violations: [`not JSON: ${error instanceof Error ? error.message : String(error)}`]
146
+ })
147
+ }).pipe(
148
+ Effect.flatMap((json) =>
149
+ Schema.decodeUnknownEffect(RosterDocument)(json).pipe(
150
+ Effect.mapError((error) => RosterInvalid.make({ path, violations: [error.message] }))
151
+ )
152
+ )
153
+ )
154
+
155
+ const readRoster = (
156
+ files: PlainFileStoreShape,
157
+ path: string
158
+ ): Effect.Effect<RosterDocument | undefined, FlowError> =>
159
+ Effect.flatMap(files.read(path), (text) =>
160
+ text === undefined ? Effect.succeed(undefined) : parseRoster(path, text)
161
+ )
162
+
163
+ export interface RosterSource {
164
+ readonly files: PlainFileStoreShape
165
+ readonly environment: Environment
166
+ readonly workDir: string
167
+ }
168
+
169
+ /**
170
+ * The roster in force for a run, or `undefined` for today's one executor per
171
+ * seat: `LLM4TS_ROSTER=none` ignores rosters, `LLM4TS_ROSTER=<path>` uses that
172
+ * file alone, otherwise the user's file with the repository's over it.
173
+ * `LLM4TS_EXECUTORS=a,b` narrows the result to those ids.
174
+ */
175
+ export const loadRosterDocument = Effect.fn("@llm4ts/runner/ExecutorRoster.load")(function* (
176
+ source: RosterSource
177
+ ): Effect.fn.Return<RosterDocument | undefined, FlowError> {
178
+ const chosen = source.environment.LLM4TS_ROSTER?.trim()
179
+ if (chosen === "none") {
180
+ return undefined
181
+ }
182
+ const loaded = yield* chosen !== undefined && chosen.length > 0
183
+ ? Effect.flatMap(readRoster(source.files, chosen), (document) =>
184
+ document === undefined
185
+ ? Effect.fail(RosterInvalid.make({ path: chosen, violations: ["no roster file there"] }))
186
+ : Effect.succeed({ document, where: chosen })
187
+ )
188
+ : Effect.gen(function* () {
189
+ const user = userRosterPath(source.environment)
190
+ const repo = repoRosterPath(source.workDir)
191
+ const userDocument = yield* readRoster(source.files, user)
192
+ const repoDocument = yield* readRoster(source.files, repo)
193
+ if (userDocument === undefined && repoDocument === undefined) {
194
+ return undefined
195
+ }
196
+ const where = [
197
+ userDocument === undefined ? undefined : user,
198
+ repoDocument === undefined ? undefined : repo
199
+ ]
200
+ .filter((path): path is string => path !== undefined)
201
+ .join(" + ")
202
+ return { document: mergeRosterDocuments(userDocument, repoDocument), where }
203
+ })
204
+ if (loaded === undefined) {
205
+ return undefined
206
+ }
207
+ const { document, where } = loaded
208
+ const ids = (source.environment.LLM4TS_EXECUTORS ?? "")
209
+ .split(",")
210
+ .map((id) => id.trim())
211
+ .filter((id) => id.length > 0)
212
+ const unknown = ids.filter((id) => !document.executors.some((spec) => spec.id === id))
213
+ const narrowed = narrowRoster(document, ids)
214
+ const violations = [
215
+ ...rosterLoadViolations(narrowed, source.environment),
216
+ ...unknown.map((id) => `LLM4TS_EXECUTORS names '${id}', which the roster does not have`),
217
+ ...(narrowed.executors.length === 0 ? ["no executor left to run with"] : [])
218
+ ]
219
+ if (violations.length > 0) {
220
+ return yield* RosterInvalid.make({ path: where, violations })
221
+ }
222
+ return narrowed
223
+ })
224
+
225
+ /** Whether `url` answers 2xx within five seconds: a roster executor's health check. */
226
+ export const httpProbe = (url: string): Effect.Effect<boolean> =>
227
+ Effect.tryPromise(() => fetch(url, { signal: AbortSignal.timeout(5_000) })).pipe(
228
+ Effect.map((response) => response.ok),
229
+ Effect.catch(() => Effect.succeed(false))
230
+ )
231
+
232
+ // ---- `llm4ts roster` -------------------------------------------------------------
233
+
234
+ export interface RosterReport {
235
+ readonly document: RosterDocument | undefined
236
+ readonly exclusions: ReadonlyArray<Exclusion>
237
+ readonly statePath: string
238
+ }
239
+
240
+ /** The roster in force here and the exclusions persisted for it. */
241
+ export const rosterReport = Effect.fn("@llm4ts/runner/ExecutorRoster.report")(function* (
242
+ source: RosterSource
243
+ ): Effect.fn.Return<RosterReport, FlowError> {
244
+ const document = yield* loadRosterDocument(source)
245
+ const statePath = rosterStatePath(source.environment)
246
+ const exclusions = yield* makeRosterStateStore(source.files, statePath).load
247
+ return { document, exclusions, statePath }
248
+ })
249
+
250
+ const priorityText = (spec: ExecutorSpec): string => {
251
+ const priority = spec.priority
252
+ if (priority === undefined) {
253
+ return "priority 1"
254
+ }
255
+ if (typeof priority === "number") {
256
+ return `priority ${priority}`
257
+ }
258
+ return `priority ${Object.entries(priority)
259
+ .map(([role, value]) => `${role} ${value}`)
260
+ .join(", ")}`
261
+ }
262
+
263
+ export const renderRosterReport = (report: RosterReport): string => {
264
+ if (report.document === undefined) {
265
+ return [
266
+ "no roster: every run uses one executor per seat (LLM4TS_CODER and the flow's defaults).",
267
+ "Write ~/.config/llm4ts/roster.json (or <repo>/.llm4ts/roster.json) to pool executors."
268
+ ].join("\n")
269
+ }
270
+ const lines = [`executors (${report.document.executors.length}):`]
271
+ for (const spec of report.document.executors) {
272
+ const exclusion = report.exclusions.find((candidate) => candidate.id === spec.id)
273
+ const slots = slotsOf(spec)
274
+ const coders = hasRole(spec, "coder") ? `, ${coderSlotsOf(spec)} for coding` : ""
275
+ lines.push(
276
+ `- ${spec.id}: ${spec.harness}${spec.model === undefined ? "" : ` ${spec.model}`} · ${rolesOf(spec).join(", ")} · ${slots} slot(s)${coders} · ${priorityText(spec)}`,
277
+ ` ${exclusion === undefined ? "in the round" : `out ${describeExclusion(exclusion)}`}`
278
+ )
279
+ }
280
+ lines.push(`state: ${report.statePath}`)
281
+ return lines.join("\n")
282
+ }
283
+
284
+ /** Takes an executor out of every run's pick-up round, for a while or until resumed. */
285
+ export const pauseExecutor = Effect.fn("@llm4ts/runner/ExecutorRoster.pause")(function* (
286
+ source: RosterSource,
287
+ id: string,
288
+ duration: string | undefined
289
+ ): Effect.fn.Return<Exclusion, FlowError> {
290
+ const document = yield* loadRosterDocument(source)
291
+ if (document === undefined || !document.executors.some((spec) => spec.id === id)) {
292
+ return yield* RosterInvalid.make({ violations: [`no executor '${id}' in the roster`] })
293
+ }
294
+ const span = duration === undefined ? undefined : parseDuration(duration)
295
+ if (duration !== undefined && span === undefined) {
296
+ return yield* RosterInvalid.make({ violations: [`cannot read the duration '${duration}'`] })
297
+ }
298
+ const now = yield* Clock.currentTimeMillis
299
+ const exclusion = Exclusion.make({
300
+ id,
301
+ kind: "manual",
302
+ reason: "paused by the operator",
303
+ ...(span === undefined ? {} : { until: now + Duration.toMillis(span) })
304
+ })
305
+ yield* makeRosterStateStore(source.files, rosterStatePath(source.environment)).save(
306
+ [id],
307
+ [exclusion]
308
+ )
309
+ return exclusion
310
+ })
311
+
312
+ /** Puts an executor back in the round, whatever took it out. */
313
+ export const resumeExecutor = Effect.fn("@llm4ts/runner/ExecutorRoster.resume")(function* (
314
+ source: RosterSource,
315
+ id: string
316
+ ): Effect.fn.Return<void, FlowError> {
317
+ yield* makeRosterStateStore(source.files, rosterStatePath(source.environment)).save([id], [])
318
+ })