@llm4ts/runner 2.5.0 → 2.6.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,25 @@ 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"
25
37
  import { FlowLlmError, describeFlowError, type FlowError } from "@llm4ts/flow/FlowError"
26
38
  import {
27
39
  FlowEvents,
28
40
  FlowEventsValues,
41
+ Info,
42
+ TokensUsed,
29
43
  makeFlowEventHub,
30
44
  type FlowEventHub
31
45
  } from "@llm4ts/flow/FlowEvents"
32
46
  import { FlowContext, type FlowContextShape } from "@llm4ts/flow/FlowContext"
33
47
  import { makeFlowRecorder } from "@llm4ts/flow/FlowRecorder"
48
+ import { makeJudgmentLog, type JudgmentLogShape } from "@llm4ts/flow/JudgmentLog"
34
49
  import { makeGitHubTool } from "@llm4ts/flow/GitHubTool"
35
50
  import { makeGitTool } from "@llm4ts/flow/GitTool"
36
51
  import type { PlainFileStoreShape } from "@llm4ts/flow/Persistence"
@@ -61,6 +76,8 @@ export interface FlowRunnerDependencies {
61
76
  readonly files: PlainFileStoreShape
62
77
  /** pi's own model config (ADR 0016), injectable so a test never reads $HOME. */
63
78
  readonly readPiModelsJson?: () => string | undefined
79
+ /** For the hosted TypeSafe judgment backend; defaults to the Node client. */
80
+ readonly http?: HttpClientShape
64
81
  }
65
82
 
66
83
  export interface NodeConnectorDependencies {
@@ -73,6 +90,7 @@ export interface NodeConnectorDependencies {
73
90
  export const nodeFlowRunnerDependencies = (): FlowRunnerDependencies => ({
74
91
  process: nodeProcessExecutor,
75
92
  files: nodePlainFileStore,
93
+ http: nodeHttpClient,
76
94
  registry: createConnectorRegistry({
77
95
  http: nodeHttpClient,
78
96
  process: nodeProcessExecutor,
@@ -88,7 +106,36 @@ export interface FlowRunnerOptions {
88
106
  readonly coder: ConnectorConfig
89
107
  readonly reasoning?: ConnectorConfig
90
108
  readonly reviewers?: ReadonlyArray<ConnectorConfig>
109
+ /**
110
+ * The seat typed judgments run on (ADR 0017). Defaults to the reasoning
111
+ * seat; point it at a small non-thinking model for one-token label scoring.
112
+ */
113
+ readonly judgment?: ConnectorConfig
114
+ /**
115
+ * `llm` (default) answers on the judgment seat; `typesafe` uses the hosted
116
+ * Jev model with `TYPESAFE_API_KEY` from the environment. Unset, the
117
+ * environment's `LLM4TS_JUDGMENT_BACKEND` decides, then `llm`.
118
+ */
119
+ readonly judgmentBackend?: JudgmentBackend
120
+ /** Persist judgment observations under workDir/.llm4ts/judgments; off by default. */
121
+ readonly judgmentLog?: boolean
122
+ /**
123
+ * Where the run's trace goes. Default `workDir/.llm4ts/trace-<start ms>.jsonl`
124
+ * while `persistRun` holds.
125
+ */
91
126
  readonly tracePath?: string
127
+ /**
128
+ * Append-only ledger (`CostLedger`) that receives one `CostRecord` per run
129
+ * that accrued usage, so `llm4ts costs` and budgets can look across runs.
130
+ * Default `workDir/.llm4ts/costs.jsonl` while `persistRun` holds.
131
+ */
132
+ readonly costLedgerPath?: string
133
+ /**
134
+ * `false` writes neither the trace nor the ledger unless their paths are
135
+ * given explicitly. Default `true`: every run leaves its record under
136
+ * `workDir/.llm4ts/`, which `llm4ts costs` and replay read back.
137
+ */
138
+ readonly persistRun?: boolean
92
139
  readonly runId?: string
93
140
  readonly verbosity?: Verbosity
94
141
  readonly surface?: TerminalSurface
@@ -108,6 +155,8 @@ export interface FlowRunnerBundle {
108
155
  readonly events: FlowEventHub
109
156
  /** The run's cost tracker, already subscribed to `events`. */
110
157
  readonly tracker: CostTracker
158
+ readonly runId: string
159
+ readonly judgmentLog?: JudgmentLogShape
111
160
  }
112
161
 
113
162
  const truthy = (value: string | undefined): boolean =>
@@ -177,6 +226,12 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
177
226
  dependencies: FlowRunnerDependencies
178
227
  ): Effect.fn.Return<FlowRunnerBundle, FlowError, Scope.Scope> {
179
228
  const events = yield* makeFlowEventHub()
229
+ const runId = options.runId ?? `run-${(yield* Clock.currentTimeMillis).toString()}`
230
+ const judgmentLog =
231
+ options.judgmentLog === true
232
+ ? yield* makeJudgmentLog({ files: dependencies.files, root: options.workDir, runId })
233
+ : undefined
234
+ if (judgmentLog !== undefined) yield* judgmentLog.consume(events)
180
235
  const tracker = yield* makeCostTracker()
181
236
  yield* tracker.consume(events)
182
237
  const reasoning = defaultReasoningConfig(options.coder, options.reasoning)
@@ -225,6 +280,62 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
225
280
  )
226
281
  )
227
282
  )
283
+ // Judgment usage is metered like any chat (ADR 0005 amendment) and every
284
+ // fallback from label scoring to the verbalized path is announced.
285
+ const judgmentHooks = {
286
+ onUsage: (usage: TokenUsage, model: string | undefined) =>
287
+ events.publish(
288
+ new TokensUsed({ agent: "judgment", usage, ...(model === undefined ? {} : { model }) })
289
+ ),
290
+ onFallback: (key: string, reason: string) =>
291
+ events.publish(
292
+ new Info({ message: `judgment '${key}' fell back to verbalized scoring: ${reason}` })
293
+ )
294
+ }
295
+ const judgmentFor = (
296
+ seat: LlmServiceShape,
297
+ seatConfig: ConnectorConfig,
298
+ environment: Readonly<Record<string, string | undefined>>
299
+ ): Effect.Effect<JudgmentShape, FlowError> => {
300
+ const backend =
301
+ options.judgmentBackend ??
302
+ (environment.LLM4TS_JUDGMENT_BACKEND?.trim().toLowerCase() === "typesafe"
303
+ ? "typesafe"
304
+ : "llm")
305
+ if (backend !== "typesafe") {
306
+ return Effect.succeed(
307
+ makeLlmJudgment(
308
+ seat,
309
+ LlmJudgmentConfig.make({
310
+ connector: seatConfig.connectorId.value,
311
+ ...(seatConfig.model === undefined ? {} : { model: seatConfig.model }),
312
+ // LLM4TS_JUDGMENT_BATCHING=shared-prefix sends each request's state
313
+ // once with every question; anything else keeps them independent.
314
+ ...(environment.LLM4TS_JUDGMENT_BATCHING?.trim().toLowerCase() === "shared-prefix"
315
+ ? { batching: "shared-prefix" as const }
316
+ : {})
317
+ }),
318
+ judgmentHooks
319
+ )
320
+ )
321
+ }
322
+ const key = environment.TYPESAFE_API_KEY?.trim()
323
+ if (key === undefined || key.length === 0) {
324
+ return Effect.fail(
325
+ FlowLlmError.from(
326
+ ProviderError.make({
327
+ message: "judgment backend 'typesafe' needs TYPESAFE_API_KEY in the environment"
328
+ })
329
+ )
330
+ )
331
+ }
332
+ return Effect.succeed(
333
+ makeTypeSafeJudgment(
334
+ { apiKey: Redacted.make(key), onUsage: judgmentHooks.onUsage },
335
+ dependencies.http ?? nodeHttpClient
336
+ )
337
+ )
338
+ }
228
339
  const seatsFor = Effect.fn("@llm4ts/runner/FlowRunner.seatsFor")(function* (
229
340
  workDir: string
230
341
  ): Effect.fn.Return<
@@ -232,6 +343,7 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
232
343
  readonly coder: LlmServiceShape & { readonly capabilities: ConnectorCapabilities }
233
344
  readonly reasoning: LlmServiceShape
234
345
  readonly reviewers: ReadonlyArray<LlmServiceShape>
346
+ readonly judgment: JudgmentShape
235
347
  },
236
348
  FlowError,
237
349
  Scope.Scope
@@ -244,7 +356,14 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
244
356
  const reviewers = yield* Effect.forEach(options.reviewers ?? [], (configuration) =>
245
357
  resolveSeat(prepareConnector(bridged(configuration), workDir, environment))
246
358
  )
247
- return { coder, reasoning: reasoningService, reviewers }
359
+ // The judgment seat defaults to the reasoning seat's service itself, so
360
+ // an unconfigured run resolves nothing extra.
361
+ const judgmentSeat =
362
+ options.judgment === undefined
363
+ ? reasoningService
364
+ : yield* resolveSeat(prepareConnector(bridged(options.judgment), workDir, environment))
365
+ const judgment = yield* judgmentFor(judgmentSeat, options.judgment ?? reasoning, environment)
366
+ return { coder, reasoning: reasoningService, reviewers, judgment }
248
367
  })
249
368
  const contextAt = (
250
369
  workDir: string,
@@ -252,12 +371,14 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
252
371
  readonly coder: LlmServiceShape & { readonly capabilities: ConnectorCapabilities }
253
372
  readonly reasoning: LlmServiceShape
254
373
  readonly reviewers: ReadonlyArray<LlmServiceShape>
374
+ readonly judgment: JudgmentShape
255
375
  },
256
376
  rebind: boolean
257
377
  ): FlowContextShape =>
258
378
  FlowContext.of({
259
379
  reasoning: seats.reasoning,
260
380
  coder: seats.coder,
381
+ judgment: seats.judgment,
261
382
  git: makeGitTool(dependencies.process, workDir, events),
262
383
  hosting: makeGitHubTool(dependencies.process, workDir, events),
263
384
  events,
@@ -279,6 +400,8 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
279
400
  return {
280
401
  events,
281
402
  tracker,
403
+ runId,
404
+ ...(judgmentLog === undefined ? {} : { judgmentLog }),
282
405
  context: contextAt(options.workDir, seats, true)
283
406
  }
284
407
  })
@@ -296,6 +419,13 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
296
419
  const startedAt = yield* Clock.currentTimeMillis
297
420
  const environment = options.environment ?? process.env
298
421
  const verbosity = options.verbosity ?? "Normal"
422
+ const persistRun = options.persistRun ?? true
423
+ const tracePath =
424
+ options.tracePath ??
425
+ (persistRun ? join(options.workDir, ".llm4ts", `trace-${startedAt}.jsonl`) : undefined)
426
+ const costLedgerPath =
427
+ options.costLedgerPath ??
428
+ (persistRun ? join(options.workDir, ".llm4ts", CostLedgerFileName) : undefined)
299
429
  const tracker = bundle.tracker
300
430
  const surface = options.surface ?? (yield* makeTerminalSurface(environment))
301
431
  const palette = surface.palette
@@ -310,22 +440,18 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
310
440
  ...(reviewers.length === 0 ? [] : [`reviewers ${reviewers.map(describeSeat).join(", ")}`])
311
441
  ].join(" · ")
312
442
  yield* surface.log(palette.info(`${seats} · ${options.workDir}`))
313
- if (options.tracePath !== undefined) {
443
+ if (tracePath !== undefined) {
314
444
  yield* surface.log(
315
445
  palette.info(
316
- `trace ${options.tracePath}${options.runId === undefined ? "" : ` · run ${options.runId}`}`
446
+ `trace ${tracePath}${options.runId === undefined ? "" : ` · run ${options.runId}`}`
317
447
  )
318
448
  )
319
449
  }
320
450
  }
321
451
  const recorder =
322
- options.tracePath === undefined
452
+ tracePath === undefined
323
453
  ? undefined
324
- : yield* makeFlowRecorder(
325
- dependencies.files,
326
- options.tracePath,
327
- options.runId ?? `run-${(yield* Clock.currentTimeMillis).toString()}`
328
- )
454
+ : yield* makeFlowRecorder(dependencies.files, tracePath, bundle.runId)
329
455
  if (recorder !== undefined) {
330
456
  yield* recorder.consume(bundle.events)
331
457
  }
@@ -349,6 +475,32 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
349
475
  budget
350
476
  )
351
477
  })
478
+ // Runs without usage leave no record: a ledger line of zeros would only
479
+ // inflate run counts for backends that report nothing.
480
+ const appendLedger =
481
+ costLedgerPath === undefined
482
+ ? Effect.void
483
+ : Effect.gen(function* () {
484
+ const cells = yield* tracker.cells
485
+ if (cells.length === 0) {
486
+ return
487
+ }
488
+ yield* appendCostRecord(
489
+ dependencies.files,
490
+ costLedgerPath,
491
+ makeCostRecord({
492
+ runId: bundle.runId,
493
+ at: new Date(startedAt).toISOString(),
494
+ repo: options.workDir,
495
+ prompt: options.userPrompt,
496
+ cells
497
+ })
498
+ )
499
+ }).pipe(
500
+ Effect.catch((error) =>
501
+ surface.log(palette.fail(`cost ledger not written: ${describeFlowError(error)}`))
502
+ )
503
+ )
352
504
  return yield* body(bundle.context).pipe(
353
505
  Effect.provideService(FlowContext, bundle.context),
354
506
  Effect.andThen((value) => Effect.as(enforceBudget, value)),
@@ -357,10 +509,14 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
357
509
  [
358
510
  terminal.awaitDrained(),
359
511
  tracker.awaitDrained(bundle.events),
360
- recorder === undefined ? Effect.void : recorder.awaitDrained(bundle.events)
512
+ recorder === undefined ? Effect.void : recorder.awaitDrained(bundle.events),
513
+ bundle.judgmentLog === undefined
514
+ ? Effect.void
515
+ : bundle.judgmentLog.awaitDrained(bundle.events)
361
516
  ],
362
517
  { concurrency: "unbounded" }
363
518
  ).pipe(
519
+ Effect.andThen(appendLedger),
364
520
  Effect.andThen(surface.setStatus(undefined)),
365
521
  Effect.andThen(tracker.summary),
366
522
  Effect.flatMap((summary) => surface.log(`\n${summary}`))
@@ -375,8 +531,8 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
375
531
  `flow failed after ${formatDurationMs(finishedAt - startedAt)}: ${describeFlowError(error)}`
376
532
  )}`
377
533
  )
378
- if (options.tracePath !== undefined) {
379
- yield* surface.log(palette.info(`trace ${options.tracePath}`))
534
+ if (tracePath !== undefined) {
535
+ yield* surface.log(palette.info(`trace ${tracePath}`))
380
536
  }
381
537
  })
382
538
  ),
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":