@llm4ts/runner 2.45.0 → 2.47.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 (49) hide show
  1. package/dist/Connectors.d.ts +1 -1
  2. package/dist/Connectors.d.ts.map +1 -1
  3. package/dist/Connectors.js +7 -2
  4. package/dist/Connectors.js.map +1 -1
  5. package/dist/Costs.d.ts +8 -4
  6. package/dist/Costs.d.ts.map +1 -1
  7. package/dist/Costs.js +32 -13
  8. package/dist/Costs.js.map +1 -1
  9. package/dist/Doctor.d.ts +24 -2
  10. package/dist/Doctor.d.ts.map +1 -1
  11. package/dist/Doctor.js +32 -12
  12. package/dist/Doctor.js.map +1 -1
  13. package/dist/FlowRunner.d.ts +38 -2
  14. package/dist/FlowRunner.d.ts.map +1 -1
  15. package/dist/FlowRunner.js +128 -13
  16. package/dist/FlowRunner.js.map +1 -1
  17. package/dist/NodeGeminiCliExecutor.js +3 -3
  18. package/dist/NodeGeminiCliExecutor.js.map +1 -1
  19. package/dist/RunReaders.d.ts +69 -0
  20. package/dist/RunReaders.d.ts.map +1 -0
  21. package/dist/RunReaders.js +283 -0
  22. package/dist/RunReaders.js.map +1 -0
  23. package/dist/RunSummary.d.ts +3 -0
  24. package/dist/RunSummary.d.ts.map +1 -0
  25. package/dist/RunSummary.js +49 -0
  26. package/dist/RunSummary.js.map +1 -0
  27. package/dist/StoryReaders.d.ts +16 -0
  28. package/dist/StoryReaders.d.ts.map +1 -0
  29. package/dist/StoryReaders.js +107 -0
  30. package/dist/StoryReaders.js.map +1 -0
  31. package/dist/Terminal.d.ts +2 -0
  32. package/dist/Terminal.d.ts.map +1 -1
  33. package/dist/Terminal.js +8 -1
  34. package/dist/Terminal.js.map +1 -1
  35. package/dist/Watch.d.ts +2 -0
  36. package/dist/Watch.d.ts.map +1 -1
  37. package/dist/Watch.js +2 -1
  38. package/dist/Watch.js.map +1 -1
  39. package/package.json +6 -3
  40. package/src/Connectors.ts +8 -2
  41. package/src/Costs.ts +44 -12
  42. package/src/Doctor.ts +62 -14
  43. package/src/FlowRunner.ts +177 -16
  44. package/src/NodeGeminiCliExecutor.ts +3 -3
  45. package/src/RunReaders.ts +419 -0
  46. package/src/RunSummary.ts +62 -0
  47. package/src/StoryReaders.ts +157 -0
  48. package/src/Terminal.ts +10 -1
  49. package/src/Watch.ts +2 -1
package/src/FlowRunner.ts CHANGED
@@ -5,6 +5,7 @@ import * as Cause from "effect/Cause"
5
5
  import * as Clock from "effect/Clock"
6
6
  import * as Effect from "effect/Effect"
7
7
  import * as Exit from "effect/Exit"
8
+ import * as Fiber from "effect/Fiber"
8
9
  import * as Ref from "effect/Ref"
9
10
  import type * as Scope from "effect/Scope"
10
11
  import type { ConnectorConfig } from "@llm4ts/core/ConnectorConfig"
@@ -82,6 +83,7 @@ import { makeJudgmentLog, type JudgmentLogShape } from "@llm4ts/flow/JudgmentLog
82
83
  import { makeGitHubTool } from "@llm4ts/flow/GitHubTool"
83
84
  import { makeGitTool } from "@llm4ts/flow/GitTool"
84
85
  import type { PlainFileStoreShape } from "@llm4ts/flow/Persistence"
86
+ import { ignoringRunState } from "@llm4ts/flow/RunState"
85
87
  import { makeTransientRetry } from "@llm4ts/flow/TransientRetry"
86
88
  import {
87
89
  Exclusion,
@@ -147,6 +149,16 @@ export interface NodeConnectorDependencies {
147
149
  readonly geminiCli: GeminiCliExecutorShape
148
150
  }
149
151
 
152
+ /**
153
+ * The dependencies with the run-state rule on their file store: the first
154
+ * write under any `.llm4ts/` leaves a `.gitignore` there, so a trace, a
155
+ * plan or a transcript is never staged by a flow commit (`RunState`).
156
+ */
157
+ const withRunStateIgnore = (dependencies: FlowRunnerDependencies): FlowRunnerDependencies => ({
158
+ ...dependencies,
159
+ files: ignoringRunState(dependencies.files)
160
+ })
161
+
150
162
  export const nodeFlowRunnerDependencies = (): FlowRunnerDependencies => ({
151
163
  process: nodeProcessExecutor,
152
164
  files: nodePlainFileStore,
@@ -223,6 +235,14 @@ export interface FlowRunnerOptions {
223
235
  * roster files); `"none"` runs with one executor per seat.
224
236
  */
225
237
  readonly roster?: RosterDocument | "none"
238
+ /**
239
+ * Globs, relative to each seat's working directory, every CLI seat of
240
+ * the run is denied (`CliConnectorConfig.deniedPaths`). Default: the
241
+ * run's own state, `.llm4ts/**`. A flow whose coder writes there on
242
+ * purpose (port-guide's trial ports) passes `[]`; a seat that names its
243
+ * own list keeps it.
244
+ */
245
+ readonly deniedPaths?: ReadonlyArray<string>
226
246
  }
227
247
 
228
248
  export interface FlowRunnerBundle {
@@ -329,12 +349,34 @@ const geminiBridgeSeats = Effect.fn("@llm4ts/runner/FlowRunner.geminiBridgeSeats
329
349
  : config
330
350
  })
331
351
 
352
+ const nonBlank = (value: string | undefined): string | undefined =>
353
+ value === undefined || value.trim() === "" ? undefined : value
354
+
355
+ /**
356
+ * The run id and trace path a parent process (the MCP operator server) hands
357
+ * to a child run. Explicit options win; blank variables count as unset.
358
+ */
359
+ export const runIdentityFromEnvironment = (
360
+ options: Pick<FlowRunnerOptions, "runId" | "tracePath">,
361
+ environment: Readonly<Record<string, string | undefined>>
362
+ ): { readonly runId?: string; readonly tracePath?: string } => {
363
+ const runId = nonBlank(options.runId) ?? nonBlank(environment["LLM4TS_RUN_ID"])
364
+ const tracePath = nonBlank(options.tracePath) ?? nonBlank(environment["LLM4TS_TRACE_PATH"])
365
+ return {
366
+ ...(runId === undefined ? {} : { runId }),
367
+ ...(tracePath === undefined ? {} : { tracePath })
368
+ }
369
+ }
370
+
332
371
  export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeContext")(function* (
333
372
  options: FlowRunnerOptions,
334
- dependencies: FlowRunnerDependencies
373
+ given: FlowRunnerDependencies
335
374
  ): Effect.fn.Return<FlowRunnerBundle, FlowError, Scope.Scope> {
375
+ const dependencies = withRunStateIgnore(given)
336
376
  const events = yield* makeFlowEventHub()
337
- const runId = options.runId ?? `run-${(yield* Clock.currentTimeMillis).toString()}`
377
+ const runId =
378
+ runIdentityFromEnvironment(options, options.environment ?? process.env).runId ??
379
+ `run-${(yield* Clock.currentTimeMillis).toString()}`
338
380
  // `llm4ts run --transcript`: what each seat is told and answers, per story,
339
381
  // in its own files — never in the trace.
340
382
  const transcriptOn =
@@ -391,6 +433,7 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
391
433
  readonly readOnlyEnforcement: string
392
434
  readonly effort: string
393
435
  readonly isolatedHeadless: string
436
+ readonly pathDeny: string
394
437
  }
395
438
  }
396
439
  ): Effect.Effect<void> => {
@@ -428,6 +471,24 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
428
471
  : "nothing reaches the harness")
429
472
  )
430
473
  }
474
+ // The run state (ADR 0027 amendment): a harness with no per-path deny
475
+ // rule keeps the model out of `.llm4ts/` by the contract text alone.
476
+ if (
477
+ configuration instanceof CliConnectorConfig &&
478
+ configuration.deniedPaths !== undefined &&
479
+ configuration.deniedPaths.length > 0 &&
480
+ connector.capabilities.pathDeny !== "enforced"
481
+ ) {
482
+ const paths = configuration.deniedPaths.join(", ")
483
+ notes.push(
484
+ `'${name}' is asked to keep out of ${paths} (the run's own state: traces, plans, ` +
485
+ "transcripts), but its harness " +
486
+ (connector.capabilities.pathDeny === "partial"
487
+ ? "denies it by rule, not at the filesystem — a shell command that spells the path " +
488
+ "another way can still reach it"
489
+ : "has no per-path deny rule — only the contract text in its prompt keeps it out")
490
+ )
491
+ }
431
492
  return Effect.forEach(
432
493
  notes,
433
494
  (detail) => events.publish(FlowEventsValues.CapabilityUnenforceable(detail)),
@@ -545,12 +606,16 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
545
606
  Scope.Scope
546
607
  > {
547
608
  const environment = options.environment ?? process.env
548
- const coder = yield* resolveSeat(prepareConnector(bridged(options.coder), workDir, environment))
609
+ const coder = yield* resolveSeat(
610
+ prepareConnector(bridged(options.coder), workDir, environment, options.deniedPaths)
611
+ )
549
612
  const reasoningService = yield* resolveSeat(
550
- prepareConnector(bridged(reasoning), workDir, environment)
613
+ prepareConnector(bridged(reasoning), workDir, environment, options.deniedPaths)
551
614
  )
552
615
  const reviewers = yield* Effect.forEach(options.reviewers ?? [], (configuration) =>
553
- resolveSeat(prepareConnector(bridged(configuration), workDir, environment))
616
+ resolveSeat(
617
+ prepareConnector(bridged(configuration), workDir, environment, options.deniedPaths)
618
+ )
554
619
  )
555
620
  // The judgment seat is the reasoner with its tools taken away: a judge
556
621
  // reads, it does not act. Over an API reasoner (no tools to take) it is
@@ -561,7 +626,9 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
561
626
  const judgmentSeat =
562
627
  judgmentConfig === undefined
563
628
  ? reasoningService
564
- : yield* resolveSeat(prepareConnector(bridged(judgmentConfig), workDir, environment))
629
+ : yield* resolveSeat(
630
+ prepareConnector(bridged(judgmentConfig), workDir, environment, options.deniedPaths)
631
+ )
565
632
  const judgment = yield* judgmentFor(judgmentSeat, judgmentConfig ?? reasoning, environment)
566
633
  return { coder, reasoning: reasoningService, reviewers, judge: judgmentSeat, judgment }
567
634
  })
@@ -720,7 +787,7 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
720
787
  })
721
788
  }
722
789
  const seat: RosterSeat = yield* resolveSeat(
723
- prepareConnector(bridged(configuration), workDir, runEnvironment)
790
+ prepareConnector(bridged(configuration), workDir, runEnvironment, options.deniedPaths)
724
791
  ).pipe(ScopeModule.provide(runScope))
725
792
  seatCache.set(key, seat)
726
793
  return seat
@@ -918,8 +985,9 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
918
985
  bundle: FlowRunnerBundle,
919
986
  options: FlowRunnerOptions,
920
987
  body: (context: FlowContextShape) => Effect.Effect<A, E, R>,
921
- dependencies: FlowRunnerDependencies = nodeFlowRunnerDependencies()
988
+ given: FlowRunnerDependencies = nodeFlowRunnerDependencies()
922
989
  ): Effect.fn.Return<A, E | FlowError, R | Scope.Scope> {
990
+ const dependencies = withRunStateIgnore(given)
923
991
  const startedAt = yield* Clock.currentTimeMillis
924
992
  const environment = options.environment ?? process.env
925
993
  const verbosity = options.verbosity ?? "Normal"
@@ -928,7 +996,7 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
928
996
  // per run, flushed when the run's scope closes.
929
997
  const otel = otelConfig(environment)
930
998
  const tracePath =
931
- options.tracePath ??
999
+ runIdentityFromEnvironment(options, environment).tracePath ??
932
1000
  (persistRun ? join(options.workDir, ".llm4ts", `trace-${startedAt}.jsonl`) : undefined)
933
1001
  const costLedgerPath =
934
1002
  options.costLedgerPath ??
@@ -977,11 +1045,7 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
977
1045
  : `executors ${bundle.roster.executors.map(describeExecutor).join(", ")} (roster in force: the flow's own seat choices are not used)`
978
1046
  yield* surface.log(palette.info(`${seats} · ${options.workDir}`))
979
1047
  if (tracePath !== undefined) {
980
- yield* surface.log(
981
- palette.info(
982
- `trace ${tracePath}${options.runId === undefined ? "" : ` · run ${options.runId}`}`
983
- )
984
- )
1048
+ yield* surface.log(palette.info(`trace ${tracePath} · run ${bundle.runId}`))
985
1049
  }
986
1050
  const where = otelSummary(otel)
987
1051
  if (where !== undefined) {
@@ -1012,7 +1076,7 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
1012
1076
  const at = new Date(yield* Clock.currentTimeMillis).toISOString()
1013
1077
  yield* checkCostBudget(
1014
1078
  makeCostRecord({
1015
- runId: options.runId ?? "run",
1079
+ runId: bundle.runId,
1016
1080
  at,
1017
1081
  repo: options.workDir,
1018
1082
  prompt: options.userPrompt,
@@ -1110,6 +1174,22 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
1110
1174
  ),
1111
1175
  Effect.andThen(tree === undefined ? Effect.void : tree.close),
1112
1176
  Effect.andThen(appendLedger),
1177
+ // A trace that lost lines says so here, once; the ledger line above
1178
+ // is complete, and `llm4ts costs` reads it for this run's remainder.
1179
+ Effect.andThen(
1180
+ recorder === undefined
1181
+ ? Effect.void
1182
+ : Effect.flatMap(recorder.shortfall, (shortfall) =>
1183
+ shortfall === undefined
1184
+ ? Effect.void
1185
+ : surface.log(
1186
+ palette.fail(
1187
+ `trace short: ${shortfall.lines} line${shortfall.lines === 1 ? "" : "s"} not recorded after "${shortfall.reason}" — ` +
1188
+ "the cost ledger line is complete, and llm4ts costs reads it for this run"
1189
+ )
1190
+ )
1191
+ )
1192
+ ),
1113
1193
  Effect.andThen(surface.setStatus(undefined)),
1114
1194
  Effect.andThen(tracker.summary),
1115
1195
  Effect.flatMap((summary) => surface.log(`\n${summary}`))
@@ -1189,8 +1269,83 @@ export const runNode = <A, E, R>(
1189
1269
  const mainErrorMessage = (error: unknown): string =>
1190
1270
  error instanceof Error && error.message.length > 0 ? error.message : String(error)
1191
1271
 
1272
+ export type InterruptSignal = "SIGINT" | "SIGTERM"
1273
+
1274
+ export interface SignalSource {
1275
+ readonly once: (signal: InterruptSignal, handler: () => void) => void
1276
+ readonly off: (signal: InterruptSignal, handler: () => void) => void
1277
+ readonly exit: (code: number) => void
1278
+ }
1279
+
1280
+ const signalExitCode: Record<InterruptSignal, number> = { SIGINT: 130, SIGTERM: 143 }
1281
+
1282
+ /**
1283
+ * The first of the given signals (SIGINT by default) interrupts the fiber
1284
+ * (finalizers run, the trace ends `interrupted`); any later one exits at
1285
+ * once, 130 for SIGINT and 143 for SIGTERM. The handlers re-arm themselves
1286
+ * after the first signal. Returns a disposer that removes whichever handlers
1287
+ * are armed.
1288
+ */
1289
+ export const interruptOnSignal = <A, E>(
1290
+ fiber: Fiber.Fiber<A, E>,
1291
+ signals: SignalSource,
1292
+ signal: InterruptSignal | ReadonlyArray<InterruptSignal> = "SIGINT"
1293
+ ): (() => void) => {
1294
+ const watched: ReadonlyArray<InterruptSignal> = typeof signal === "string" ? [signal] : signal
1295
+ const seconds = new Map<InterruptSignal, () => void>(
1296
+ watched.map((name) => [name, () => signals.exit(signalExitCode[name])])
1297
+ )
1298
+ const firsts = new Map<InterruptSignal, () => void>(
1299
+ watched.map((name) => [
1300
+ name,
1301
+ () => {
1302
+ for (const other of watched) {
1303
+ const otherFirst = firsts.get(other)
1304
+ if (otherFirst !== undefined) signals.off(other, otherFirst)
1305
+ const second = seconds.get(other)
1306
+ if (second !== undefined) signals.once(other, second)
1307
+ }
1308
+ Effect.runFork(Fiber.interrupt(fiber))
1309
+ }
1310
+ ])
1311
+ )
1312
+ const dispose = (): void => {
1313
+ for (const name of watched) {
1314
+ const first = firsts.get(name)
1315
+ if (first !== undefined) signals.off(name, first)
1316
+ const second = seconds.get(name)
1317
+ if (second !== undefined) signals.off(name, second)
1318
+ }
1319
+ }
1320
+ for (const [name, first] of firsts) signals.once(name, first)
1321
+ // After an interrupt the handlers stay: a second signal must still kill a
1322
+ // run whose finalizers hang. Otherwise they are removed when the fiber ends.
1323
+ fiber.addObserver((exit) => {
1324
+ if (!(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause))) {
1325
+ dispose()
1326
+ }
1327
+ })
1328
+ return dispose
1329
+ }
1330
+
1331
+ /**
1332
+ * The signals `runFlowMain` stops on. SIGTERM counts like SIGINT, so a
1333
+ * supervisor's or init system's stop ends the trace `interrupted` too.
1334
+ */
1335
+ export const flowMainSignals: ReadonlyArray<InterruptSignal> = ["SIGINT", "SIGTERM"]
1336
+
1337
+ const processSignals: SignalSource = {
1338
+ once: (signal, handler) => {
1339
+ process.once(signal, handler)
1340
+ },
1341
+ off: (signal, handler) => {
1342
+ process.off(signal, handler)
1343
+ },
1344
+ exit: (code) => process.exit(code)
1345
+ }
1346
+
1192
1347
  export const runFlowMain = <E>(program: Effect.Effect<void, E>): void => {
1193
- Effect.runFork(
1348
+ const fiber = Effect.runFork(
1194
1349
  program.pipe(
1195
1350
  Effect.match({
1196
1351
  onFailure: (error) =>
@@ -1203,4 +1358,10 @@ export const runFlowMain = <E>(program: Effect.Effect<void, E>): void => {
1203
1358
  Effect.flatten
1204
1359
  )
1205
1360
  )
1361
+ fiber.addObserver((exit) => {
1362
+ if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) {
1363
+ process.exitCode = 130
1364
+ }
1365
+ })
1366
+ interruptOnSignal(fiber, processSignals, flowMainSignals)
1206
1367
  }
@@ -8,7 +8,7 @@ import {
8
8
  GeminiCliExecutor,
9
9
  buildGeminiArgs,
10
10
  extractGeminiCliResponse,
11
- geminiHarnessPolicyToml,
11
+ geminiHarnessPolicyFor,
12
12
  geminiProcessEnv,
13
13
  geminiSupportsAdminPolicy,
14
14
  geminiTurnLimitSettingsJson,
@@ -39,7 +39,7 @@ export const makeNodeGeminiCliExecutor = (
39
39
  ),
40
40
  (path) => geminiProcessEnv(context, path)
41
41
  )
42
- // The harness policy (`geminiHarnessPolicyToml`) goes in with
42
+ // The harness policy (`geminiHarnessPolicyFor`) goes in with
43
43
  // `--admin-policy` on a CLI that lists the flag: `gemini --help` is asked
44
44
  // once, and an older CLI simply runs without it — an unknown flag would
45
45
  // fail every turn.
@@ -68,7 +68,7 @@ export const makeNodeGeminiCliExecutor = (
68
68
  Effect.flatMap(supportsAdminPolicy, (supported) =>
69
69
  supported
70
70
  ? Effect.map(
71
- temporaryFiles.write("gemini-policy-", ".toml", geminiHarnessPolicyToml),
71
+ temporaryFiles.write("gemini-policy-", ".toml", geminiHarnessPolicyFor(context)),
72
72
  (path) => buildGeminiArgs(config, context, outputFormat, path)
73
73
  )
74
74
  : Effect.succeed(buildGeminiArgs(config, context, outputFormat))