@llm4ts/runner 2.5.0 → 2.7.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.
package/src/FlowRunner.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { join } from "node:path"
1
2
  import * as Clock from "effect/Clock"
2
3
  import * as Effect from "effect/Effect"
3
4
  import type * as Scope from "effect/Scope"
@@ -5,8 +6,13 @@ import type { ConnectorConfig } from "@llm4ts/core/ConnectorConfig"
5
6
  import { CliConnectorConfig, defaultReasoningConfig } from "@llm4ts/core/ConnectorConfig"
6
7
  import type { ConnectorRegistryShape } from "@llm4ts/core/ConnectorRegistry"
7
8
  import type { HttpClientShape } from "@llm4ts/core/HttpClient"
9
+ import type { JudgmentBackend } from "@llm4ts/core/judgment/Schemas"
10
+ import type { JudgmentShape } from "@llm4ts/core/judgment/Judgment"
11
+ import { LlmJudgmentConfig, makeLlmJudgment } from "@llm4ts/core/judgment/LlmJudgment"
12
+ import { makeTypeSafeJudgment } from "@llm4ts/core/judgment/TypeSafeJudgment"
13
+ import * as Redacted from "effect/Redacted"
8
14
  import type { LlmServiceShape } from "@llm4ts/core/LlmService"
9
- import type { ConnectorCapabilities } from "@llm4ts/core/Models"
15
+ import type { ConnectorCapabilities, TokenUsage } from "@llm4ts/core/Models"
10
16
  import type { ProcessExecutorShape } from "@llm4ts/core/ProcessExecutor"
11
17
  import type { TemporaryFilesShape } from "@llm4ts/core/TemporaryFiles"
12
18
  import type { GeminiCliExecutorShape } from "@llm4ts/core/providers/GeminiCliProvider"
@@ -21,16 +27,26 @@ import {
21
27
  geminiBridgePort,
22
28
  resolveBridgeModel
23
29
  } from "./PiModels.ts"
24
- import { checkCostBudget, makeCostRecord, type CostBudget } from "@llm4ts/flow/CostLedger"
30
+ import {
31
+ appendCostRecord,
32
+ checkCostBudget,
33
+ CostLedgerFileName,
34
+ makeCostRecord,
35
+ type CostBudget
36
+ } from "@llm4ts/flow/CostLedger"
37
+ import { estimatedUsageOptionsFromEnv, makeEstimatedUsageMeter } from "@llm4ts/flow/EstimatedUsage"
25
38
  import { FlowLlmError, describeFlowError, type FlowError } from "@llm4ts/flow/FlowError"
26
39
  import {
27
40
  FlowEvents,
28
41
  FlowEventsValues,
42
+ Info,
43
+ TokensUsed,
29
44
  makeFlowEventHub,
30
45
  type FlowEventHub
31
46
  } from "@llm4ts/flow/FlowEvents"
32
47
  import { FlowContext, type FlowContextShape } from "@llm4ts/flow/FlowContext"
33
48
  import { makeFlowRecorder } from "@llm4ts/flow/FlowRecorder"
49
+ import { makeJudgmentLog, type JudgmentLogShape } from "@llm4ts/flow/JudgmentLog"
34
50
  import { makeGitHubTool } from "@llm4ts/flow/GitHubTool"
35
51
  import { makeGitTool } from "@llm4ts/flow/GitTool"
36
52
  import type { PlainFileStoreShape } from "@llm4ts/flow/Persistence"
@@ -61,6 +77,8 @@ export interface FlowRunnerDependencies {
61
77
  readonly files: PlainFileStoreShape
62
78
  /** pi's own model config (ADR 0016), injectable so a test never reads $HOME. */
63
79
  readonly readPiModelsJson?: () => string | undefined
80
+ /** For the hosted TypeSafe judgment backend; defaults to the Node client. */
81
+ readonly http?: HttpClientShape
64
82
  }
65
83
 
66
84
  export interface NodeConnectorDependencies {
@@ -73,6 +91,7 @@ export interface NodeConnectorDependencies {
73
91
  export const nodeFlowRunnerDependencies = (): FlowRunnerDependencies => ({
74
92
  process: nodeProcessExecutor,
75
93
  files: nodePlainFileStore,
94
+ http: nodeHttpClient,
76
95
  registry: createConnectorRegistry({
77
96
  http: nodeHttpClient,
78
97
  process: nodeProcessExecutor,
@@ -88,7 +107,44 @@ export interface FlowRunnerOptions {
88
107
  readonly coder: ConnectorConfig
89
108
  readonly reasoning?: ConnectorConfig
90
109
  readonly reviewers?: ReadonlyArray<ConnectorConfig>
110
+ /**
111
+ * The seat typed judgments run on (ADR 0017). Defaults to the reasoning
112
+ * seat; point it at a small non-thinking model for one-token label scoring.
113
+ */
114
+ readonly judgment?: ConnectorConfig
115
+ /**
116
+ * `llm` (default) answers on the judgment seat; `typesafe` uses the hosted
117
+ * Jev model with `TYPESAFE_API_KEY` from the environment. Unset, the
118
+ * environment's `LLM4TS_JUDGMENT_BACKEND` decides, then `llm`.
119
+ */
120
+ readonly judgmentBackend?: JudgmentBackend
121
+ /** Persist judgment observations under workDir/.llm4ts/judgments; off by default. */
122
+ readonly judgmentLog?: boolean
123
+ /**
124
+ * Meter every seat with `EstimatedUsage`, so a backend that reports no
125
+ * token counts still yields usage — counted from characters and
126
+ * labelled `estimated:<model>`, never mixed with measured usage. Default
127
+ * `true`; `false`, or `LLM4TS_ESTIMATE_USAGE=0` in the environment, leaves
128
+ * the seats raw and those runs accrue nothing.
129
+ */
130
+ readonly estimateUsage?: boolean
131
+ /**
132
+ * Where the run's trace goes. Default `workDir/.llm4ts/trace-<start ms>.jsonl`
133
+ * while `persistRun` holds.
134
+ */
91
135
  readonly tracePath?: string
136
+ /**
137
+ * Append-only ledger (`CostLedger`) that receives one `CostRecord` per run
138
+ * that accrued usage, so `llm4ts costs` and budgets can look across runs.
139
+ * Default `workDir/.llm4ts/costs.jsonl` while `persistRun` holds.
140
+ */
141
+ readonly costLedgerPath?: string
142
+ /**
143
+ * `false` writes neither the trace nor the ledger unless their paths are
144
+ * given explicitly. Default `true`: every run leaves its record under
145
+ * `workDir/.llm4ts/`, which `llm4ts costs` and replay read back.
146
+ */
147
+ readonly persistRun?: boolean
92
148
  readonly runId?: string
93
149
  readonly verbosity?: Verbosity
94
150
  readonly surface?: TerminalSurface
@@ -108,11 +164,16 @@ export interface FlowRunnerBundle {
108
164
  readonly events: FlowEventHub
109
165
  /** The run's cost tracker, already subscribed to `events`. */
110
166
  readonly tracker: CostTracker
167
+ readonly runId: string
168
+ readonly judgmentLog?: JudgmentLogShape
111
169
  }
112
170
 
113
171
  const truthy = (value: string | undefined): boolean =>
114
172
  value !== undefined && ["1", "true", "yes"].includes(value.trim().toLowerCase())
115
173
 
174
+ const switchedOff = (value: string | undefined): boolean =>
175
+ value !== undefined && ["0", "false", "no", "off"].includes(value.trim().toLowerCase())
176
+
116
177
  const isPiSeat = (config: ConnectorConfig | undefined): config is CliConnectorConfig =>
117
178
  config instanceof CliConnectorConfig && config.connectorId.value === ConnectorIds.Pi.value
118
179
 
@@ -177,6 +238,12 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
177
238
  dependencies: FlowRunnerDependencies
178
239
  ): Effect.fn.Return<FlowRunnerBundle, FlowError, Scope.Scope> {
179
240
  const events = yield* makeFlowEventHub()
241
+ const runId = options.runId ?? `run-${(yield* Clock.currentTimeMillis).toString()}`
242
+ const judgmentLog =
243
+ options.judgmentLog === true
244
+ ? yield* makeJudgmentLog({ files: dependencies.files, root: options.workDir, runId })
245
+ : undefined
246
+ if (judgmentLog !== undefined) yield* judgmentLog.consume(events)
180
247
  const tracker = yield* makeCostTracker()
181
248
  yield* tracker.consume(events)
182
249
  const reasoning = defaultReasoningConfig(options.coder, options.reasoning)
@@ -212,19 +279,96 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
212
279
  )
213
280
  )
214
281
  : Effect.void
282
+ // Every seat is metered, so no run is silently free: a backend that reports
283
+ // no token counts (`capabilities.usageReporting` false) is estimated from
284
+ // character counts and labelled `estimated:<model>`, which is what makes
285
+ // `TokensUsed` — and therefore cost summaries, budgets, the ledger, and
286
+ // `llm4ts costs` — answer for those seats at all. Measured usage always
287
+ // wins; the estimate only fills a gap the backend left. Metering here rather
288
+ // than in each flow also covers the seats a flow never touches directly,
289
+ // notably the judgment seat and the worktree seats `contextFor` rebinds.
290
+ const runEnvironment = options.environment ?? process.env
291
+ const estimateOptions = estimatedUsageOptionsFromEnv(runEnvironment)
292
+ const meterUsage = options.estimateUsage ?? !switchedOff(runEnvironment.LLM4TS_ESTIMATE_USAGE)
293
+ const metered = (service: LlmServiceShape): Effect.Effect<LlmServiceShape> =>
294
+ meterUsage
295
+ ? Effect.map(makeEstimatedUsageMeter(service, estimateOptions), (meter) => meter.service)
296
+ : Effect.succeed(service)
215
297
  // The spread keeps everything the connector carries beyond the service
216
- // methods — notably `capabilities`, which the flow context exposes.
298
+ // methods — notably `capabilities`, which the flow context exposes. The
299
+ // meter decorates the retrying seat, so an estimate reflects the attempt
300
+ // that actually succeeded, and it sees `scoreLabelSequence` when the
301
+ // connector has one (the retry wrapper does not carry it).
217
302
  const resolveSeat = (configuration: ConnectorConfig) =>
218
303
  dependencies.registry.resolve(configuration).pipe(
219
304
  Effect.mapError(FlowLlmError.from),
220
305
  Effect.flatMap((connector) =>
221
306
  announceReadOnlyGrade(configuration, connector).pipe(
222
307
  Effect.andThen(
223
- Effect.map(resilient(connector), (retrying) => ({ ...connector, ...retrying }))
308
+ Effect.flatMap(resilient(connector), (retrying) => {
309
+ const seat = { ...connector, ...retrying }
310
+ return Effect.map(metered(seat), (measured) => ({ ...seat, ...measured }))
311
+ })
224
312
  )
225
313
  )
226
314
  )
227
315
  )
316
+ // Judgment usage is metered like any chat (ADR 0005 amendment) and every
317
+ // fallback from label scoring to the verbalized path is announced.
318
+ const judgmentHooks = {
319
+ onUsage: (usage: TokenUsage, model: string | undefined) =>
320
+ events.publish(
321
+ new TokensUsed({ agent: "judgment", usage, ...(model === undefined ? {} : { model }) })
322
+ ),
323
+ onFallback: (key: string, reason: string) =>
324
+ events.publish(
325
+ new Info({ message: `judgment '${key}' fell back to verbalized scoring: ${reason}` })
326
+ )
327
+ }
328
+ const judgmentFor = (
329
+ seat: LlmServiceShape,
330
+ seatConfig: ConnectorConfig,
331
+ environment: Readonly<Record<string, string | undefined>>
332
+ ): Effect.Effect<JudgmentShape, FlowError> => {
333
+ const backend =
334
+ options.judgmentBackend ??
335
+ (environment.LLM4TS_JUDGMENT_BACKEND?.trim().toLowerCase() === "typesafe"
336
+ ? "typesafe"
337
+ : "llm")
338
+ if (backend !== "typesafe") {
339
+ return Effect.succeed(
340
+ makeLlmJudgment(
341
+ seat,
342
+ LlmJudgmentConfig.make({
343
+ connector: seatConfig.connectorId.value,
344
+ ...(seatConfig.model === undefined ? {} : { model: seatConfig.model }),
345
+ // LLM4TS_JUDGMENT_BATCHING=shared-prefix sends each request's state
346
+ // once with every question; anything else keeps them independent.
347
+ ...(environment.LLM4TS_JUDGMENT_BATCHING?.trim().toLowerCase() === "shared-prefix"
348
+ ? { batching: "shared-prefix" as const }
349
+ : {})
350
+ }),
351
+ judgmentHooks
352
+ )
353
+ )
354
+ }
355
+ const key = environment.TYPESAFE_API_KEY?.trim()
356
+ if (key === undefined || key.length === 0) {
357
+ return Effect.fail(
358
+ FlowLlmError.from(
359
+ ProviderError.make({
360
+ message: "judgment backend 'typesafe' needs TYPESAFE_API_KEY in the environment"
361
+ })
362
+ )
363
+ )
364
+ }
365
+ return Effect.succeed(
366
+ makeTypeSafeJudgment(
367
+ { apiKey: Redacted.make(key), onUsage: judgmentHooks.onUsage },
368
+ dependencies.http ?? nodeHttpClient
369
+ )
370
+ )
371
+ }
228
372
  const seatsFor = Effect.fn("@llm4ts/runner/FlowRunner.seatsFor")(function* (
229
373
  workDir: string
230
374
  ): Effect.fn.Return<
@@ -232,6 +376,7 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
232
376
  readonly coder: LlmServiceShape & { readonly capabilities: ConnectorCapabilities }
233
377
  readonly reasoning: LlmServiceShape
234
378
  readonly reviewers: ReadonlyArray<LlmServiceShape>
379
+ readonly judgment: JudgmentShape
235
380
  },
236
381
  FlowError,
237
382
  Scope.Scope
@@ -244,7 +389,14 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
244
389
  const reviewers = yield* Effect.forEach(options.reviewers ?? [], (configuration) =>
245
390
  resolveSeat(prepareConnector(bridged(configuration), workDir, environment))
246
391
  )
247
- return { coder, reasoning: reasoningService, reviewers }
392
+ // The judgment seat defaults to the reasoning seat's service itself, so
393
+ // an unconfigured run resolves nothing extra.
394
+ const judgmentSeat =
395
+ options.judgment === undefined
396
+ ? reasoningService
397
+ : yield* resolveSeat(prepareConnector(bridged(options.judgment), workDir, environment))
398
+ const judgment = yield* judgmentFor(judgmentSeat, options.judgment ?? reasoning, environment)
399
+ return { coder, reasoning: reasoningService, reviewers, judgment }
248
400
  })
249
401
  const contextAt = (
250
402
  workDir: string,
@@ -252,12 +404,14 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
252
404
  readonly coder: LlmServiceShape & { readonly capabilities: ConnectorCapabilities }
253
405
  readonly reasoning: LlmServiceShape
254
406
  readonly reviewers: ReadonlyArray<LlmServiceShape>
407
+ readonly judgment: JudgmentShape
255
408
  },
256
409
  rebind: boolean
257
410
  ): FlowContextShape =>
258
411
  FlowContext.of({
259
412
  reasoning: seats.reasoning,
260
413
  coder: seats.coder,
414
+ judgment: seats.judgment,
261
415
  git: makeGitTool(dependencies.process, workDir, events),
262
416
  hosting: makeGitHubTool(dependencies.process, workDir, events),
263
417
  events,
@@ -279,6 +433,8 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
279
433
  return {
280
434
  events,
281
435
  tracker,
436
+ runId,
437
+ ...(judgmentLog === undefined ? {} : { judgmentLog }),
282
438
  context: contextAt(options.workDir, seats, true)
283
439
  }
284
440
  })
@@ -296,6 +452,13 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
296
452
  const startedAt = yield* Clock.currentTimeMillis
297
453
  const environment = options.environment ?? process.env
298
454
  const verbosity = options.verbosity ?? "Normal"
455
+ const persistRun = options.persistRun ?? true
456
+ const tracePath =
457
+ options.tracePath ??
458
+ (persistRun ? join(options.workDir, ".llm4ts", `trace-${startedAt}.jsonl`) : undefined)
459
+ const costLedgerPath =
460
+ options.costLedgerPath ??
461
+ (persistRun ? join(options.workDir, ".llm4ts", CostLedgerFileName) : undefined)
299
462
  const tracker = bundle.tracker
300
463
  const surface = options.surface ?? (yield* makeTerminalSurface(environment))
301
464
  const palette = surface.palette
@@ -310,22 +473,18 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
310
473
  ...(reviewers.length === 0 ? [] : [`reviewers ${reviewers.map(describeSeat).join(", ")}`])
311
474
  ].join(" · ")
312
475
  yield* surface.log(palette.info(`${seats} · ${options.workDir}`))
313
- if (options.tracePath !== undefined) {
476
+ if (tracePath !== undefined) {
314
477
  yield* surface.log(
315
478
  palette.info(
316
- `trace ${options.tracePath}${options.runId === undefined ? "" : ` · run ${options.runId}`}`
479
+ `trace ${tracePath}${options.runId === undefined ? "" : ` · run ${options.runId}`}`
317
480
  )
318
481
  )
319
482
  }
320
483
  }
321
484
  const recorder =
322
- options.tracePath === undefined
485
+ tracePath === undefined
323
486
  ? undefined
324
- : yield* makeFlowRecorder(
325
- dependencies.files,
326
- options.tracePath,
327
- options.runId ?? `run-${(yield* Clock.currentTimeMillis).toString()}`
328
- )
487
+ : yield* makeFlowRecorder(dependencies.files, tracePath, bundle.runId)
329
488
  if (recorder !== undefined) {
330
489
  yield* recorder.consume(bundle.events)
331
490
  }
@@ -349,6 +508,32 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
349
508
  budget
350
509
  )
351
510
  })
511
+ // Runs without usage leave no record: a ledger line of zeros would only
512
+ // inflate run counts for backends that report nothing.
513
+ const appendLedger =
514
+ costLedgerPath === undefined
515
+ ? Effect.void
516
+ : Effect.gen(function* () {
517
+ const cells = yield* tracker.cells
518
+ if (cells.length === 0) {
519
+ return
520
+ }
521
+ yield* appendCostRecord(
522
+ dependencies.files,
523
+ costLedgerPath,
524
+ makeCostRecord({
525
+ runId: bundle.runId,
526
+ at: new Date(startedAt).toISOString(),
527
+ repo: options.workDir,
528
+ prompt: options.userPrompt,
529
+ cells
530
+ })
531
+ )
532
+ }).pipe(
533
+ Effect.catch((error) =>
534
+ surface.log(palette.fail(`cost ledger not written: ${describeFlowError(error)}`))
535
+ )
536
+ )
352
537
  return yield* body(bundle.context).pipe(
353
538
  Effect.provideService(FlowContext, bundle.context),
354
539
  Effect.andThen((value) => Effect.as(enforceBudget, value)),
@@ -357,10 +542,14 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
357
542
  [
358
543
  terminal.awaitDrained(),
359
544
  tracker.awaitDrained(bundle.events),
360
- recorder === undefined ? Effect.void : recorder.awaitDrained(bundle.events)
545
+ recorder === undefined ? Effect.void : recorder.awaitDrained(bundle.events),
546
+ bundle.judgmentLog === undefined
547
+ ? Effect.void
548
+ : bundle.judgmentLog.awaitDrained(bundle.events)
361
549
  ],
362
550
  { concurrency: "unbounded" }
363
551
  ).pipe(
552
+ Effect.andThen(appendLedger),
364
553
  Effect.andThen(surface.setStatus(undefined)),
365
554
  Effect.andThen(tracker.summary),
366
555
  Effect.flatMap((summary) => surface.log(`\n${summary}`))
@@ -375,8 +564,8 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
375
564
  `flow failed after ${formatDurationMs(finishedAt - startedAt)}: ${describeFlowError(error)}`
376
565
  )}`
377
566
  )
378
- if (options.tracePath !== undefined) {
379
- yield* surface.log(palette.info(`trace ${options.tracePath}`))
567
+ if (tracePath !== undefined) {
568
+ yield* surface.log(palette.info(`trace ${tracePath}`))
380
569
  }
381
570
  })
382
571
  ),
package/src/Terminal.ts CHANGED
@@ -26,6 +26,9 @@ export const parseVerbosity = (value: string | undefined): Verbosity => {
26
26
 
27
27
  export const rendersEvent = (verbosity: Verbosity, event: FlowEvent): boolean => {
28
28
  switch (event._tag) {
29
+ case "JudgmentObserved":
30
+ // Advise mode publishes a separate Info event for the operator.
31
+ return false
29
32
  case "StageStarted":
30
33
  case "StageCompleted":
31
34
  case "StageFailed":
@@ -141,6 +144,8 @@ export const terminalLine = (
141
144
  ): string => {
142
145
  const safe = terminalSafe
143
146
  switch (event._tag) {
147
+ case "JudgmentObserved":
148
+ return ""
144
149
  case "StageStarted":
145
150
  return palette.stageStart(safe(event.stage))
146
151
  case "StageCompleted":