@llm4ts/runner 3.2.6 → 3.4.0

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.
Files changed (93) hide show
  1. package/README.md +1 -1
  2. package/dist/AgentTree.d.ts +2 -3
  3. package/dist/AgentTree.d.ts.map +1 -1
  4. package/dist/AgentTree.js +8 -20
  5. package/dist/AgentTree.js.map +1 -1
  6. package/dist/AgentTreeSurface.d.ts +1 -1
  7. package/dist/AgentTreeSurface.d.ts.map +1 -1
  8. package/dist/AgentTreeSurface.js +2 -5
  9. package/dist/AgentTreeSurface.js.map +1 -1
  10. package/dist/Cli.d.ts.map +1 -1
  11. package/dist/Cli.js +2 -1
  12. package/dist/Cli.js.map +1 -1
  13. package/dist/Connectors.d.ts +33 -2
  14. package/dist/Connectors.d.ts.map +1 -1
  15. package/dist/Connectors.js +85 -89
  16. package/dist/Connectors.js.map +1 -1
  17. package/dist/Doctor.d.ts.map +1 -1
  18. package/dist/Doctor.js +8 -11
  19. package/dist/Doctor.js.map +1 -1
  20. package/dist/EventPresentation.d.ts +35 -0
  21. package/dist/EventPresentation.d.ts.map +1 -0
  22. package/dist/EventPresentation.js +133 -0
  23. package/dist/EventPresentation.js.map +1 -0
  24. package/dist/ExecutorRoster.d.ts.map +1 -1
  25. package/dist/ExecutorRoster.js +4 -6
  26. package/dist/ExecutorRoster.js.map +1 -1
  27. package/dist/FlowArgs.d.ts +1 -1
  28. package/dist/FlowArgs.d.ts.map +1 -1
  29. package/dist/FlowArgs.js +4 -2
  30. package/dist/FlowArgs.js.map +1 -1
  31. package/dist/FlowRunner.d.ts +3 -1
  32. package/dist/FlowRunner.d.ts.map +1 -1
  33. package/dist/FlowRunner.js +31 -38
  34. package/dist/FlowRunner.js.map +1 -1
  35. package/dist/NodeGeminiCliExecutor.d.ts.map +1 -1
  36. package/dist/NodeGeminiCliExecutor.js +6 -4
  37. package/dist/NodeGeminiCliExecutor.js.map +1 -1
  38. package/dist/NodeGeminiModelBridge.d.ts.map +1 -1
  39. package/dist/NodeGeminiModelBridge.js +3 -1
  40. package/dist/NodeGeminiModelBridge.js.map +1 -1
  41. package/dist/Otel.d.ts.map +1 -1
  42. package/dist/Otel.js +4 -5
  43. package/dist/Otel.js.map +1 -1
  44. package/dist/Packs.d.ts.map +1 -1
  45. package/dist/Packs.js +2 -1
  46. package/dist/Packs.js.map +1 -1
  47. package/dist/PiModels.d.ts.map +1 -1
  48. package/dist/PiModels.js +6 -9
  49. package/dist/PiModels.js.map +1 -1
  50. package/dist/Profile.d.ts +4 -0
  51. package/dist/Profile.d.ts.map +1 -1
  52. package/dist/Profile.js +45 -75
  53. package/dist/Profile.js.map +1 -1
  54. package/dist/RunReaders.d.ts +1 -0
  55. package/dist/RunReaders.d.ts.map +1 -1
  56. package/dist/RunReaders.js +4 -51
  57. package/dist/RunReaders.js.map +1 -1
  58. package/dist/Terminal.d.ts +14 -11
  59. package/dist/Terminal.d.ts.map +1 -1
  60. package/dist/Terminal.js +25 -78
  61. package/dist/Terminal.js.map +1 -1
  62. package/dist/Tools.d.ts.map +1 -1
  63. package/dist/Tools.js +2 -1
  64. package/dist/Tools.js.map +1 -1
  65. package/dist/Transcripts.d.ts +1 -1
  66. package/dist/Transcripts.d.ts.map +1 -1
  67. package/dist/Transcripts.js +3 -2
  68. package/dist/Transcripts.js.map +1 -1
  69. package/dist/index.d.ts +1 -1
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +1 -1
  72. package/dist/index.js.map +1 -1
  73. package/package.json +4 -3
  74. package/src/AgentTree.ts +16 -25
  75. package/src/AgentTreeSurface.ts +6 -11
  76. package/src/Cli.ts +2 -1
  77. package/src/Connectors.ts +141 -107
  78. package/src/Doctor.ts +10 -12
  79. package/src/EventPresentation.ts +168 -0
  80. package/src/ExecutorRoster.ts +4 -6
  81. package/src/FlowArgs.ts +7 -2
  82. package/src/FlowRunner.ts +41 -55
  83. package/src/NodeGeminiCliExecutor.ts +7 -4
  84. package/src/NodeGeminiModelBridge.ts +4 -1
  85. package/src/Otel.ts +4 -6
  86. package/src/Packs.ts +2 -1
  87. package/src/PiModels.ts +6 -10
  88. package/src/Profile.ts +64 -81
  89. package/src/RunReaders.ts +4 -53
  90. package/src/Terminal.ts +105 -162
  91. package/src/Tools.ts +2 -1
  92. package/src/Transcripts.ts +3 -2
  93. package/src/index.ts +3 -0
package/src/AgentTree.ts CHANGED
@@ -1,10 +1,18 @@
1
+ import * as Duration from "effect/Duration"
1
2
  import type { TokenUsage } from "@llm4ts/core/Models"
2
3
  import * as Option from "effect/Option"
3
4
  import * as Schema from "effect/Schema"
4
5
  import { toolCategory } from "@llm4ts/flow/Activity"
5
- import { FlowEvent, cloneName } from "@llm4ts/flow/FlowEvents"
6
+ import {
7
+ FlowEvent,
8
+ cloneName,
9
+ laneClosed,
10
+ laneOpened,
11
+ type LaneKind
12
+ } from "@llm4ts/flow/FlowEvents"
6
13
  import type { TraceLine } from "@llm4ts/flow/FlowRecorder"
7
14
  import { estimateCostUsd } from "@llm4ts/flow/PriceList"
15
+ import { settingValue } from "@llm4ts/flow/Settings"
8
16
  import { formatCount } from "./Terminal.ts"
9
17
 
10
18
  /**
@@ -26,8 +34,7 @@ export type TreeInput =
26
34
 
27
35
  export type LaneStatus = "running" | "done" | "failed"
28
36
 
29
- /** What a lane stands for: a story's coder (ADR 0022), a roundtable attendee (ADR 0046), or a taskforce specialist (ADR 0047). */
30
- export type LaneKind = "story" | "attendee" | "specialist"
37
+ export type { LaneKind }
31
38
 
32
39
  export interface TreeLane {
33
40
  readonly id: string
@@ -236,16 +243,8 @@ export interface TreeOptions {
236
243
  export const defaultIdleAfterMs = 120_000
237
244
 
238
245
  /** `LLM4TS_IDLE_AFTER`: `90s`, `5m`, `1h` (a bare number is seconds); two minutes otherwise. */
239
- export const idleAfterFrom = (
240
- environment: Readonly<Record<string, string | undefined>>
241
- ): number => {
242
- const match = /^(\d+)\s*(s|m|h)?$/u.exec(environment.LLM4TS_IDLE_AFTER?.trim() ?? "")
243
- if (match === null) {
244
- return defaultIdleAfterMs
245
- }
246
- const unit = match[2] === "h" ? 3_600_000 : match[2] === "m" ? 60_000 : 1_000
247
- return Number(match[1]) * unit
248
- }
246
+ export const idleAfterFrom = (environment: Readonly<Record<string, string | undefined>>): number =>
247
+ Duration.toMillis(settingValue("LLM4TS_IDLE_AFTER", environment))
249
248
 
250
249
  export const emptyTree = (options: TreeOptions = {}): TreeState => ({
251
250
  title: options.title ?? "flow",
@@ -279,8 +278,6 @@ const logged = (state: TreeState, entry: TreeLogEntry): TreeState => ({
279
278
  log: [...state.log, entry]
280
279
  })
281
280
 
282
- /** The stage that opens a lane: a story's, a roundtable attendee's or a taskforce specialist's (names may hold spaces). */
283
- const laneStage = /^(story|attendee|specialist) (.+)$/u
284
281
  const expandedTools = 20
285
282
 
286
283
  const updateLane = (
@@ -424,15 +421,9 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
424
421
  }
425
422
  switch (event._tag) {
426
423
  case "StageStarted": {
427
- const opened = laneStage.exec(event.stage)
428
- const story = opened?.[2]
429
- if (story !== undefined && event.lane === story) {
430
- const kind: LaneKind =
431
- opened?.[1] === "attendee"
432
- ? "attendee"
433
- : opened?.[1] === "specialist"
434
- ? "specialist"
435
- : "story"
424
+ const opened = laneOpened(event)
425
+ if (opened !== undefined) {
426
+ const { id: story, kind } = opened
436
427
  const fresh: TreeLane = {
437
428
  id: story,
438
429
  kind,
@@ -477,7 +468,7 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
477
468
  }
478
469
  case "StageCompleted":
479
470
  case "StageFailed": {
480
- if (lane !== undefined && event.stage === `${lane.kind} ${lane.id}`) {
471
+ if (lane !== undefined && laneClosed(event)?.kind === lane.kind) {
481
472
  const status: LaneStatus = event._tag === "StageFailed" ? "failed" : "done"
482
473
  const closed = updateLane(current, lane.id, (open) => ({
483
474
  ...open,
@@ -5,7 +5,7 @@ import * as Ref from "effect/Ref"
5
5
  import type * as Scope from "effect/Scope"
6
6
  import * as Semaphore from "effect/Semaphore"
7
7
  import * as Stream from "effect/Stream"
8
- import { awaitConsumed, type FlowEventHub } from "@llm4ts/flow/FlowEvents"
8
+ import type { FlowEventHub } from "@llm4ts/flow/FlowEvents"
9
9
  import {
10
10
  emptyTree,
11
11
  initialView,
@@ -108,17 +108,12 @@ export const makeAgentTreeHost = Effect.fn("@llm4ts/runner/AgentTreeSurface.make
108
108
  const state = yield* Ref.make(emptyTree(idleAfterMs === undefined ? {} : { idleAfterMs }))
109
109
  const view = yield* Ref.make<TreeView>(initialView)
110
110
  const active = yield* Ref.make(true)
111
- const consumed = yield* Ref.make(0)
112
111
  const lock = yield* Semaphore.make(1)
113
112
 
114
- const subscription = yield* hub.subscribe
115
- yield* Stream.fromSubscription(subscription).pipe(
116
- Stream.runForEach((event) =>
117
- Effect.flatMap(Clock.currentTimeMillis, (at) =>
118
- Ref.update(state, (current) => reduceTree(current, { _tag: "Event", at, event }))
119
- ).pipe(Effect.andThen(Ref.update(consumed, (count) => count + 1)))
120
- ),
121
- Effect.forkScoped
113
+ const consumer = yield* hub.consume("agent tree", (event) =>
114
+ Effect.flatMap(Clock.currentTimeMillis, (at) =>
115
+ Ref.update(state, (current) => reduceTree(current, { _tag: "Event", at, event }))
116
+ )
122
117
  )
123
118
 
124
119
  const render = (rows: number | undefined): Effect.Effect<ReadonlyArray<string>> =>
@@ -210,7 +205,7 @@ export const makeAgentTreeHost = Effect.fn("@llm4ts/runner/AgentTreeSurface.make
210
205
 
211
206
  return {
212
207
  surface,
213
- close: Effect.andThen(awaitConsumed(hub, consumed), leave(true))
208
+ close: Effect.andThen(consumer.caughtUp(), leave(true))
214
209
  }
215
210
  })
216
211
 
package/src/Cli.ts CHANGED
@@ -3,6 +3,7 @@ import { FlowLlmError, type FlowError } from "@llm4ts/flow/FlowError"
3
3
  import { AssistantMessage } from "@llm4ts/flow/FlowEvents"
4
4
  import { collect } from "@llm4ts/core/Streaming"
5
5
  import { CliConnectorConfig } from "@llm4ts/core/ConnectorConfig"
6
+ import { settingValue } from "@llm4ts/flow/Settings"
6
7
  import { coderFromEnvironment } from "./Connectors.ts"
7
8
  import { parseFlowArgs, readFlowPrompt, resolveFlowRepo, type ScriptUsage } from "./FlowArgs.ts"
8
9
  import { runNode } from "./FlowRunner.ts"
@@ -33,7 +34,7 @@ export const makeCliProgram = (
33
34
  workspace,
34
35
  userPrompt: prompt,
35
36
  coder,
36
- verbosity: parseVerbosity(environment.LLM4TS_VERBOSITY),
37
+ verbosity: parseVerbosity(settingValue("LLM4TS_VERBOSITY", environment)),
37
38
  runId: timestamp.toString()
38
39
  },
39
40
  (context) =>
package/src/Connectors.ts CHANGED
@@ -4,10 +4,22 @@ import * as Redacted from "effect/Redacted"
4
4
  import {
5
5
  ApiConnectorConfig,
6
6
  CliConnectorConfig,
7
- type ConnectorConfig
7
+ type ConnectorConfig,
8
+ type ConnectorConfigOf
8
9
  } from "@llm4ts/core/ConnectorConfig"
9
- import { ConnectorIds, connectorDefaultBaseUrl } from "@llm4ts/core/Models"
10
+ import {
11
+ ConnectorIds,
12
+ apiProviderConnector,
13
+ coderConnector,
14
+ coderNames,
15
+ connectorDefaultBaseUrl,
16
+ connectorRow,
17
+ connectorTable,
18
+ offeredApiProviderNames,
19
+ type ConnectorName
20
+ } from "@llm4ts/core/Models"
10
21
  import { runStateDeniedPaths } from "@llm4ts/flow/RunState"
22
+ import { settingText, settingValue } from "@llm4ts/flow/Settings"
11
23
  import { ScriptUsage } from "./FlowArgs.ts"
12
24
 
13
25
  export const claude = CliConnectorConfig.make({
@@ -53,12 +65,12 @@ export const opencode = CliConnectorConfig.make({
53
65
 
54
66
  export const lmStudio = ApiConnectorConfig.make({
55
67
  connectorId: ConnectorIds.LmStudio,
56
- baseUrl: "http://localhost:1234/v1"
68
+ baseUrl: connectorTable.LmStudio.baseUrl
57
69
  })
58
70
 
59
71
  export const mlxLm = ApiConnectorConfig.make({
60
72
  connectorId: ConnectorIds.MlxLm,
61
- baseUrl: "http://localhost:8080"
73
+ baseUrl: connectorTable.MlxLm.baseUrl
62
74
  })
63
75
 
64
76
  export const openAI = ApiConnectorConfig.make({
@@ -82,6 +94,26 @@ export const mock = ApiConnectorConfig.make({
82
94
  model: "mock"
83
95
  })
84
96
 
97
+ // A connector without a preset here does not compile.
98
+ const presets: { readonly [K in ConnectorName]: ConnectorConfigOf<K> } = {
99
+ OpenAI: openAI,
100
+ Anthropic: anthropic,
101
+ GeminiApi: geminiApi,
102
+ LmStudio: lmStudio,
103
+ Ollama: ollama,
104
+ MlxLm: mlxLm,
105
+ ClaudeCli: claude,
106
+ GeminiCli: gemini,
107
+ OpenCode: opencode,
108
+ Codex: codex,
109
+ Copilot: CliConnectorConfig.make({ connectorId: ConnectorIds.Copilot }),
110
+ Pi: pi,
111
+ AntigravityCli: antigravity,
112
+ Grok: grok,
113
+ Cursor: cursor,
114
+ Mock: mock
115
+ }
116
+
85
117
  export function withModel(config: CliConnectorConfig, model: string): CliConnectorConfig
86
118
  export function withModel(config: ApiConnectorConfig, model: string): ApiConnectorConfig
87
119
  export function withModel(
@@ -134,16 +166,12 @@ const environmentApiKey = (
134
166
  config: ApiConnectorConfig,
135
167
  environment: Readonly<Record<string, string | undefined>>
136
168
  ): string | undefined => {
137
- switch (config.connectorId.value) {
138
- case "openai":
139
- return environment.OPENAI_API_KEY
140
- case "anthropic":
141
- return environment.ANTHROPIC_API_KEY
142
- case "gemini-api":
143
- return environment.GEMINI_API_KEY ?? environment.GOOGLE_API_KEY
144
- default:
145
- return undefined
146
- }
169
+ const row = connectorRow(config.connectorId)
170
+ return row?.kind === "Api"
171
+ ? row.credentialVariables
172
+ .map((variable) => environment[variable])
173
+ .find((value) => value !== undefined)
174
+ : undefined
147
175
  }
148
176
 
149
177
  export const enrichApiConnector = (
@@ -163,9 +191,6 @@ export const enrichApiConnector = (
163
191
  })
164
192
  }
165
193
 
166
- const switchedOn = (value: string | undefined): boolean =>
167
- value !== undefined && ["1", "true", "on", "yes"].includes(value.trim().toLowerCase())
168
-
169
194
  export const prepareConnector = (
170
195
  config: ConnectorConfig,
171
196
  workDir: string,
@@ -179,7 +204,7 @@ export const prepareConnector = (
179
204
  workingDir: workDir,
180
205
  // `LLM4TS_ISOLATED=1` asks every CLI seat of the run to leave the
181
206
  // repository's own harness material unloaded (ADR 0029).
182
- ...(switchedOn(environment.LLM4TS_ISOLATED) ? { isolated: true } : {}),
207
+ ...(settingValue("LLM4TS_ISOLATED", environment) ? { isolated: true } : {}),
183
208
  // Every CLI seat keeps out of the run's own state unless the seat
184
209
  // (or the run) says otherwise; the connector maps it to a deny
185
210
  // rule where its harness has one (`capabilities.pathDeny`).
@@ -188,110 +213,124 @@ export const prepareConnector = (
188
213
 
189
214
  /** The API provider a name stands for (`LLM4TS_PROVIDER`, a roster executor's `harness`). */
190
215
  export const apiPresetFor = (name: string): ApiConnectorConfig | undefined => {
191
- switch (name.trim().toLowerCase()) {
192
- case "mock":
193
- return mock
194
- case "openai":
195
- return openAI
196
- case "anthropic":
197
- return anthropic
198
- case "gemini":
199
- case "gemini-api":
200
- return geminiApi
201
- case "lm-studio":
202
- case "lmstudio":
203
- return lmStudio
204
- case "ollama":
205
- return ollama
206
- case "mlx-lm":
207
- case "mlxlm":
208
- case "mlx":
209
- return mlxLm
210
- default:
211
- return undefined
212
- }
216
+ const connector = apiProviderConnector(name.trim().toLowerCase())
217
+ return connector === undefined ? undefined : presets[connector]
213
218
  }
214
219
 
215
- export const apiConnectorFromEnvironment = Effect.fn(
216
- "@llm4ts/runner/Connectors.apiConnectorFromEnvironment"
217
- )(function* (
218
- environment: Readonly<Record<string, string | undefined>> = process.env
220
+ const apiConnectorNamed = Effect.fnUntraced(function* (
221
+ named: string,
222
+ model: string | undefined
219
223
  ): Effect.fn.Return<ApiConnectorConfig, ScriptUsage> {
220
- const provider = environment.LLM4TS_PROVIDER?.trim().toLowerCase() ?? "mock"
224
+ const provider = named.toLowerCase()
221
225
  const preset = apiPresetFor(provider)
222
226
  if (preset === undefined) {
223
227
  return yield* ScriptUsage.make({
224
- message:
225
- `unknown LLM4TS_PROVIDER '${provider}'; expected ` +
226
- "mock|openai|anthropic|gemini|lm-studio|ollama|mlx-lm"
228
+ message: `unknown LLM4TS_PROVIDER '${provider}'; expected ${offeredApiProviderNames.join("|")}`
227
229
  })
228
230
  }
229
- const model = environment.LLM4TS_MODEL?.trim()
230
- if (provider !== "mock" && (model === undefined || model.length === 0)) {
231
+ if (provider !== "mock" && model === undefined) {
231
232
  return yield* ScriptUsage.make({
232
233
  message: `LLM4TS_MODEL is required for provider '${provider}' (e.g. LLM4TS_MODEL=gpt-4o-mini)`
233
234
  })
234
235
  }
235
- return model === undefined || model.length === 0 ? preset : withModel(preset, model)
236
+ return model === undefined ? preset : withModel(preset, model)
236
237
  })
237
238
 
238
- // Every coding CLI, with the names an operator may write for it. The
239
- // connector's own id is always one of them, derived rather than repeated:
240
- // `gemini-cli` is the string llm4ts prints in events, traces and errors,
241
- // so it is the name an operator has actually seen — it must not resolve to
242
- // a different vendor's CLI.
243
- const coderAliases: ReadonlyArray<readonly [CliConnectorConfig, ReadonlyArray<string>]> = [
244
- [claude, ["claude"]],
245
- [codex, ["codex"]],
246
- [gemini, ["gemini"]],
247
- [pi, ["pi"]],
248
- [antigravity, ["agy", "antigravity"]],
249
- [grok, ["grok"]],
250
- [cursor, ["cursor"]],
251
- [opencode, ["opencode"]]
252
- ]
253
-
254
- const coders = new Map<string, CliConnectorConfig>(
255
- coderAliases.flatMap(([preset, aliases]) =>
256
- [...aliases, preset.connectorId.value].map((name) => [name, preset] as const)
239
+ export const apiConnectorFromEnvironment = Effect.fn(
240
+ "@llm4ts/runner/Connectors.apiConnectorFromEnvironment"
241
+ )(function* (
242
+ environment: Readonly<Record<string, string | undefined>> = process.env
243
+ ): Effect.fn.Return<ApiConnectorConfig, ScriptUsage> {
244
+ return yield* apiConnectorNamed(
245
+ settingText("LLM4TS_PROVIDER", environment) ?? "mock",
246
+ settingValue("LLM4TS_MODEL", environment)
257
247
  )
258
- )
248
+ })
259
249
 
260
- export const coderIds: ReadonlyArray<string> = Object.freeze([...coders.keys()].sort())
250
+ export const coderIds: ReadonlyArray<string> = Object.freeze([...coderNames].sort())
261
251
 
262
- export const coderFor = (name: string): CliConnectorConfig | undefined =>
263
- coders.get(name.trim().toLowerCase())
252
+ export const coderFor = (name: string): CliConnectorConfig | undefined => {
253
+ const connector = coderConnector(name.trim().toLowerCase())
254
+ return connector === undefined ? undefined : presets[connector]
255
+ }
264
256
 
265
- // Unchecked: an unrecognized name falls back to claude, which silently
266
- // runs a CLI the operator did not ask for. Prefer coderFromEnvironment,
267
- // which refuses and names the coders it knows.
268
- export const coderFromEnv = (
269
- env: Readonly<Record<string, string | undefined>> = process.env
270
- ): CliConnectorConfig => {
271
- const requested = (env.LLM4TS_CODER ?? "").trim()
272
- return (requested.length === 0 ? claude : coderFor(requested)) ?? claude
257
+ export interface SeatDefaults {
258
+ /** The coder when `LLM4TS_CODER` is unset; claude when omitted. */
259
+ readonly coder?: CliConnectorConfig
260
+ /** The reasoner when `LLM4TS_REASONER` is unset; the coder seat when omitted. */
261
+ readonly reasoner?: CliConnectorConfig
273
262
  }
274
263
 
275
- // The checked reading, matching apiConnectorFromEnvironment above: an
276
- // unset variable is the claude default, and an unknown one is a usage
277
- // error rather than a different coder than the one asked for.
278
- export const coderFromEnvironment = Effect.fn("@llm4ts/runner/Connectors.coderFromEnvironment")(
279
- function* (
280
- environment: Readonly<Record<string, string | undefined>> = process.env
281
- ): Effect.fn.Return<CliConnectorConfig, ScriptUsage> {
282
- const requested = (environment.LLM4TS_CODER ?? "").trim()
283
- if (requested.length === 0) {
284
- return claude
285
- }
286
- const preset = coderFor(requested)
287
- return preset === undefined
288
- ? yield* ScriptUsage.make({
289
- message: `unknown LLM4TS_CODER '${requested}'; expected ${coderIds.join("|")}`
264
+ export interface Seats {
265
+ readonly coder: CliConnectorConfig
266
+ readonly reasoning: CliConnectorConfig
267
+ }
268
+
269
+ type Environment = Readonly<Record<string, string | undefined>>
270
+
271
+ const namedSeat = (
272
+ variable: "LLM4TS_CODER" | "LLM4TS_REASONER",
273
+ environment: Environment
274
+ ): Effect.Effect<CliConnectorConfig | undefined, ScriptUsage> => {
275
+ const requested = settingText(variable, environment)
276
+ if (requested === undefined) {
277
+ return Effect.succeed(undefined)
278
+ }
279
+ const preset = coderFor(requested)
280
+ return preset === undefined
281
+ ? Effect.fail(
282
+ ScriptUsage.make({
283
+ message: `unknown ${variable} '${requested}'; expected ${coderIds.join("|")}`
290
284
  })
291
- : preset
285
+ )
286
+ : Effect.succeed(preset)
287
+ }
288
+
289
+ /**
290
+ * The coder and reasoning seats of a flow that runs both, from `LLM4TS_CODER`
291
+ * and `LLM4TS_REASONER`. An unknown name is a usage error naming the accepted
292
+ * names, never another agent. A flow with one seat reads that one alone
293
+ * (`coderFromEnvironment`, `reasonerFromEnvironment`).
294
+ */
295
+ export const seatsFromEnvironment = Effect.fn("@llm4ts/runner/Connectors.seatsFromEnvironment")(
296
+ function* (
297
+ defaults: SeatDefaults = {},
298
+ environment: Environment = process.env
299
+ ): Effect.fn.Return<Seats, ScriptUsage> {
300
+ const coder = (yield* namedSeat("LLM4TS_CODER", environment)) ?? defaults.coder ?? claude
301
+ const reasoner = yield* namedSeat("LLM4TS_REASONER", environment)
302
+ return { coder, reasoning: reasoner ?? defaults.reasoner ?? coder }
292
303
  }
293
304
  )
294
305
 
306
+ /**
307
+ * An unknown name runs claude, with nothing said about it.
308
+ *
309
+ * @deprecated Use `seatsFromEnvironment`, which refuses an unknown name. Removed in 4.0.0.
310
+ */
311
+ export const coderFromEnv = (env: Environment = process.env): CliConnectorConfig => {
312
+ const requested = settingValue("LLM4TS_CODER", env)
313
+ return (requested === undefined ? claude : coderFor(requested)) ?? claude
314
+ }
315
+
316
+ /**
317
+ * `LLM4TS_CODER` alone, for a flow that runs no separate reasoning seat. A
318
+ * flow answers only for the seats it uses: a bad `LLM4TS_REASONER` is not
319
+ * this flow's to refuse.
320
+ */
321
+ export const coderFromEnvironment = (
322
+ environment: Environment = process.env,
323
+ fallback: CliConnectorConfig = claude
324
+ ): Effect.Effect<CliConnectorConfig, ScriptUsage> =>
325
+ Effect.map(namedSeat("LLM4TS_CODER", environment), (coder) => coder ?? fallback)
326
+
327
+ /** `LLM4TS_REASONER` alone, for a flow that edits nothing. */
328
+ export const reasonerFromEnvironment = (
329
+ environment: Environment = process.env,
330
+ fallback: CliConnectorConfig = claude
331
+ ): Effect.Effect<CliConnectorConfig, ScriptUsage> =>
332
+ Effect.map(namedSeat("LLM4TS_REASONER", environment), (reasoner) => reasoner ?? fallback)
333
+
295
334
  /**
296
335
  * The judgment seat from the environment (ADR 0017): `LLM4TS_JUDGMENT_PROVIDER`
297
336
  * names an API provider the way `LLM4TS_PROVIDER` does, `LLM4TS_JUDGMENT_MODEL`
@@ -302,13 +341,8 @@ export const judgmentConnectorFromEnvironment = Effect.fn(
302
341
  )(function* (
303
342
  environment: Readonly<Record<string, string | undefined>> = process.env
304
343
  ): Effect.fn.Return<ApiConnectorConfig | undefined, ScriptUsage> {
305
- const provider = environment.LLM4TS_JUDGMENT_PROVIDER?.trim()
306
- if (provider === undefined || provider.length === 0) {
307
- return undefined
308
- }
309
- return yield* apiConnectorFromEnvironment({
310
- ...environment,
311
- LLM4TS_PROVIDER: provider,
312
- LLM4TS_MODEL: environment.LLM4TS_JUDGMENT_MODEL
313
- })
344
+ const provider = settingText("LLM4TS_JUDGMENT_PROVIDER", environment)
345
+ return provider === undefined
346
+ ? undefined
347
+ : yield* apiConnectorNamed(provider, settingValue("LLM4TS_JUDGMENT_MODEL", environment))
314
348
  })
package/src/Doctor.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import * as Effect from "effect/Effect"
2
2
  import type { ConnectorKind, HealthStatus } from "@llm4ts/core/Models"
3
- import { ConnectorIds } from "@llm4ts/core/Models"
3
+ import { ConnectorIds, credentialVariables } from "@llm4ts/core/Models"
4
4
  import type { ConnectorRegistryShape } from "@llm4ts/core/ConnectorRegistry"
5
5
  import { nodePreflightReport, type NodePreflightReport } from "@llm4ts/flow/NodePreflight"
6
+ import { settingText, settingValue } from "@llm4ts/flow/Settings"
6
7
  import { nodeFlowRunnerDependencies } from "./FlowRunner.ts"
7
8
  import { nodePlainFileStore } from "./NodePlainFileStore.ts"
8
9
  import { nodeProcessExecutor } from "./NodeProcessExecutor.ts"
@@ -15,13 +16,6 @@ import {
15
16
  import { systemAdminPolicies, systemPolicyProblem } from "./NodeGeminiModelBridge.ts"
16
17
  import { otelConfig, otelDoctorLine, probeOtelEndpoint, type OtelConfig } from "./Otel.ts"
17
18
 
18
- const credentialKeys = [
19
- "OPENAI_API_KEY",
20
- "ANTHROPIC_API_KEY",
21
- "GEMINI_API_KEY",
22
- "GOOGLE_API_KEY"
23
- ] as const
24
-
25
19
  /**
26
20
  * How far a connector's health was verified: `"auth"` means the CLI's own
27
21
  * login check answered (`authStatus` is its word); `"version"` means only
@@ -133,7 +127,7 @@ const wrap = (text: string, width: number, indent: string): ReadonlyArray<string
133
127
  }
134
128
 
135
129
  const selectedCoder = (environment: Readonly<Record<string, string | undefined>>): string =>
136
- environment.LLM4TS_CODER ?? "claude (default)"
130
+ settingText("LLM4TS_CODER", environment) ?? "claude (default)"
137
131
 
138
132
  // Re-exported so `@llm4ts/runner/Doctor` stays the one place callers look
139
133
  // for pi-config questions, while the parsing itself has no dependency on
@@ -161,7 +155,7 @@ export const geminiBridgePrerequisites = (
161
155
  environment: Readonly<Record<string, string | undefined>>,
162
156
  modelsJson: string | undefined
163
157
  ): PrerequisiteReport | undefined => {
164
- if (!truthy(environment.LLM4TS_GEMINI_BRIDGE)) {
158
+ if (!settingValue("LLM4TS_GEMINI_BRIDGE", environment)) {
165
159
  return undefined
166
160
  }
167
161
  const port = bridgePort(environment)
@@ -254,7 +248,11 @@ export const geminiModelModePrerequisites = (
254
248
  systemPolicies: ReadonlyArray<string>
255
249
  ): PrerequisiteReport | undefined => {
256
250
  const mode = geminiBridgeMode(environment)
257
- if (!truthy(environment.LLM4TS_GEMINI_BRIDGE) || mode._tag !== "Mode" || mode.mode !== "model") {
251
+ if (
252
+ !settingValue("LLM4TS_GEMINI_BRIDGE", environment) ||
253
+ mode._tag !== "Mode" ||
254
+ mode.mode !== "model"
255
+ ) {
258
256
  return undefined
259
257
  }
260
258
  const detail = [
@@ -324,7 +322,7 @@ export const collectDoctorReport = (
324
322
  const lines: Array<string> = []
325
323
  lines.push("")
326
324
  lines.push("credentials:")
327
- for (const key of credentialKeys) {
325
+ for (const key of credentialVariables) {
328
326
  const value = environment[key]
329
327
  lines.push(` ${value === undefined || value.length === 0 ? "✖" : "✔"} ${key}`)
330
328
  }