@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.
- package/dist/Connectors.d.ts +2 -0
- package/dist/Connectors.d.ts.map +1 -1
- package/dist/Connectors.js +26 -24
- package/dist/Connectors.js.map +1 -1
- package/dist/ExecutorRoster.d.ts +54 -0
- package/dist/ExecutorRoster.d.ts.map +1 -0
- package/dist/ExecutorRoster.js +206 -0
- package/dist/ExecutorRoster.js.map +1 -0
- package/dist/FlowRunner.d.ts +14 -4
- package/dist/FlowRunner.d.ts.map +1 -1
- package/dist/FlowRunner.js +201 -12
- package/dist/FlowRunner.js.map +1 -1
- package/dist/Terminal.d.ts +27 -1
- package/dist/Terminal.d.ts.map +1 -1
- package/dist/Terminal.js +152 -30
- package/dist/Terminal.js.map +1 -1
- package/package.json +4 -3
- package/src/Connectors.ts +27 -24
- package/src/ExecutorRoster.ts +318 -0
- package/src/FlowRunner.ts +306 -14
- package/src/Terminal.ts +209 -31
|
@@ -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
|
+
})
|