@llm4ts/runner 2.35.3 → 2.36.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
@@ -88,7 +88,8 @@ import {
88
88
  type ExecutorSpec,
89
89
  type Role,
90
90
  type RosterDocument,
91
- type RosterShape
91
+ type RosterShape,
92
+ effortOf
92
93
  } from "@llm4ts/flow/Roster"
93
94
  import {
94
95
  makeHeldCoder,
@@ -349,19 +350,54 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
349
350
  // `capabilities.readOnlyEnforcement`.
350
351
  const announceReadOnlyGrade = (
351
352
  configuration: ConnectorConfig,
352
- connector: { readonly capabilities: { readonly readOnlyEnforcement: string } }
353
- ): Effect.Effect<void> =>
354
- configuration instanceof CliConnectorConfig &&
355
- configuration.readOnly &&
356
- connector.capabilities.readOnlyEnforcement !== "enforced"
357
- ? events.publish(
358
- FlowEventsValues.CapabilityUnenforceable(
359
- `readOnly requested from '${configuration.connectorId.value}', but its harness mapping ` +
360
- `is ${connector.capabilities.readOnlyEnforcement} — the flag is a request, not a ` +
361
- "capability removal"
362
- )
363
- )
364
- : Effect.void
353
+ connector: {
354
+ readonly capabilities: {
355
+ readonly readOnlyEnforcement: string
356
+ readonly effort: string
357
+ readonly isolatedHeadless: string
358
+ }
359
+ }
360
+ ): Effect.Effect<void> => {
361
+ const name = configuration.connectorId.value
362
+ const notes: Array<string> = []
363
+ if (
364
+ configuration instanceof CliConnectorConfig &&
365
+ configuration.readOnly &&
366
+ connector.capabilities.readOnlyEnforcement !== "enforced"
367
+ ) {
368
+ notes.push(
369
+ `readOnly requested from '${name}', but its harness mapping ` +
370
+ `is ${connector.capabilities.readOnlyEnforcement} — the flag is a request, not a ` +
371
+ "capability removal"
372
+ )
373
+ }
374
+ // The same honesty for the ADR 0029 requests: an effort the harness
375
+ // has no flag for, an isolation the harness only partly provides.
376
+ if (configuration.effort !== undefined && connector.capabilities.effort !== "mapped") {
377
+ notes.push(
378
+ `effort '${configuration.effort}' requested from '${name}', but its harness has no ` +
379
+ "effort flag — the seat runs at the harness's own default"
380
+ )
381
+ }
382
+ if (
383
+ configuration instanceof CliConnectorConfig &&
384
+ configuration.isolated &&
385
+ connector.capabilities.isolatedHeadless !== "enforced"
386
+ ) {
387
+ notes.push(
388
+ `isolated requested from '${name}', but its harness isolation is ` +
389
+ `${connector.capabilities.isolatedHeadless} — ` +
390
+ (connector.capabilities.isolatedHeadless === "partial"
391
+ ? "the repository's own instruction file is still read"
392
+ : "nothing reaches the harness")
393
+ )
394
+ }
395
+ return Effect.forEach(
396
+ notes,
397
+ (detail) => events.publish(FlowEventsValues.CapabilityUnenforceable(detail)),
398
+ { discard: true }
399
+ )
400
+ }
365
401
  // Every seat is metered, so no run is silently free: a backend that reports
366
402
  // no token counts (`capabilities.usageReporting` false) is estimated from
367
403
  // character counts and labelled `estimated:<model>`, which is what makes
@@ -633,12 +669,15 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
633
669
  cacheLock.withPermit(
634
670
  Effect.gen(function* () {
635
671
  const readOnly = role !== "coder"
636
- const key = `${executor.id}\u0000${readOnly ? "ro" : "rw"}\u0000${workDir}`
672
+ // One seat per executor, mode, directory and effort: two roles
673
+ // asking the same effort share a process configuration.
674
+ const effort = effortOf(executor, role) ?? ""
675
+ const key = `${executor.id}\u0000${readOnly ? "ro" : "rw"}\u0000${workDir}\u0000${effort}`
637
676
  const cached = seatCache.get(key)
638
677
  if (cached !== undefined) {
639
678
  return cached
640
679
  }
641
- const configuration = executorConfig(executor, readOnly, runEnvironment)
680
+ const configuration = executorConfig(executor, readOnly, runEnvironment, role)
642
681
  if (configuration === undefined) {
643
682
  return yield* RosterInvalid.make({
644
683
  violations: [`executor '${executor.id}' has an unknown harness '${executor.harness}'`]
@@ -659,7 +698,7 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
659
698
  document.executors
660
699
  .filter((spec) => rolesOf(spec).includes("judge"))
661
700
  .flatMap((spec) => {
662
- const configuration = executorConfig(spec, true, runEnvironment)
701
+ const configuration = executorConfig(spec, true, runEnvironment, "judge")
663
702
  return configuration === undefined ? [] : [configuration]
664
703
  })[0] ??
665
704
  reasoning
@@ -8,7 +8,9 @@ import {
8
8
  GeminiCliExecutor,
9
9
  buildGeminiArgs,
10
10
  extractGeminiCliResponse,
11
+ geminiHarnessPolicyToml,
11
12
  geminiProcessEnv,
13
+ geminiSupportsAdminPolicy,
12
14
  geminiTurnLimitSettingsJson,
13
15
  parseGeminiCliStreamEvent,
14
16
  validateGeminiExitCode,
@@ -37,6 +39,40 @@ export const makeNodeGeminiCliExecutor = (
37
39
  ),
38
40
  (path) => geminiProcessEnv(context, path)
39
41
  )
42
+ // The harness policy (`geminiHarnessPolicyToml`) goes in with
43
+ // `--admin-policy` on a CLI that lists the flag: `gemini --help` is asked
44
+ // once, and an older CLI simply runs without it — an unknown flag would
45
+ // fail every turn.
46
+ let adminPolicy: boolean | undefined
47
+ const supportsAdminPolicy: Effect.Effect<boolean> = Effect.suspend(() =>
48
+ adminPolicy !== undefined
49
+ ? Effect.succeed(adminPolicy)
50
+ : executor.run(["gemini", "--help"], process.cwd(), {}).pipe(
51
+ Effect.map((result) =>
52
+ geminiSupportsAdminPolicy([...result.stdout, ...result.stderr].join("\n"))
53
+ ),
54
+ Effect.catch(() => Effect.succeed(false)),
55
+ Effect.tap((supported) =>
56
+ Effect.sync(() => {
57
+ adminPolicy = supported
58
+ })
59
+ )
60
+ )
61
+ )
62
+ /** The turn's arguments: the policy file, written for the turn, when the CLI takes it. */
63
+ const argsFor = (
64
+ config: Parameters<typeof buildGeminiArgs>[0],
65
+ context: GeminiCliExecutionContext,
66
+ outputFormat: string
67
+ ) =>
68
+ Effect.flatMap(supportsAdminPolicy, (supported) =>
69
+ supported
70
+ ? Effect.map(
71
+ temporaryFiles.write("gemini-policy-", ".toml", geminiHarnessPolicyToml),
72
+ (path) => buildGeminiArgs(config, context, outputFormat, path)
73
+ )
74
+ : Effect.succeed(buildGeminiArgs(config, context, outputFormat))
75
+ )
40
76
  return {
41
77
  checkGeminiInstalled: executor
42
78
  .run(["gemini", "--version"], process.cwd(), {})
@@ -51,8 +87,9 @@ export const makeNodeGeminiCliExecutor = (
51
87
  Effect.scoped(
52
88
  Effect.gen(function* () {
53
89
  const environment = yield* environmentFor(context)
90
+ const args = yield* argsFor(config, context, "json")
54
91
  const result = yield* executor.runWithStdin(
55
- ["gemini", ...buildGeminiArgs(config, context, "json")],
92
+ ["gemini", ...args],
56
93
  context.cwd ?? process.cwd(),
57
94
  environment,
58
95
  prompt
@@ -69,15 +106,17 @@ export const makeNodeGeminiCliExecutor = (
69
106
  runGeminiProcessStream: (prompt, config, context) =>
70
107
  Stream.scoped(
71
108
  Stream.unwrap(
72
- Effect.map(environmentFor(context), (environment) =>
73
- executor
74
- .runStreamingWithStdin(
75
- ["gemini", ...buildGeminiArgs(config, context, "stream-json")],
76
- context.cwd ?? process.cwd(),
77
- environment,
78
- prompt
79
- )
80
- .pipe(Stream.map(parseGeminiCliStreamEvent))
109
+ Effect.map(
110
+ Effect.all([environmentFor(context), argsFor(config, context, "stream-json")]),
111
+ ([environment, args]) =>
112
+ executor
113
+ .runStreamingWithStdin(
114
+ ["gemini", ...args],
115
+ context.cwd ?? process.cwd(),
116
+ environment,
117
+ prompt
118
+ )
119
+ .pipe(Stream.map(parseGeminiCliStreamEvent))
81
120
  )
82
121
  )
83
122
  )
@@ -32,6 +32,32 @@ const processFailure = (argv: ReadonlyArray<string>, detail: string): ProviderEr
32
32
  : `${commandName(argv)} failed: ${detail}`
33
33
  })
34
34
 
35
+ /**
36
+ * The orchestrator's own variables: its `LLM4TS_*` knobs and `_`, the path
37
+ * of the binary the shell started. The standard `OTEL_*` variables stay:
38
+ * other OpenTelemetry-aware tools (a CLI's own telemetry) read them too.
39
+ */
40
+ const orchestratorOwn = (name: string): boolean => name === "_" || name.startsWith("LLM4TS_")
41
+
42
+ /**
43
+ * What a child process inherits: this process's environment without the
44
+ * orchestrator's own variables, then `envVars`. A coding agent, a gate or a
45
+ * setup command has no use for llm4ts's knobs, and an agent that lists its
46
+ * environment should not be pointed at the tool running it.
47
+ */
48
+ export const childEnvironment = (
49
+ parent: Readonly<Record<string, string | undefined>>,
50
+ envVars: Readonly<Record<string, string>>
51
+ ): Record<string, string> => {
52
+ const inherited: Record<string, string> = {}
53
+ for (const [name, value] of Object.entries(parent)) {
54
+ if (value !== undefined && !orchestratorOwn(name)) {
55
+ inherited[name] = value
56
+ }
57
+ }
58
+ return { ...inherited, ...envVars }
59
+ }
60
+
35
61
  const spawnChild = (
36
62
  argv: ReadonlyArray<string>,
37
63
  cwd: string,
@@ -58,10 +84,7 @@ const spawnChild = (
58
84
  try: () =>
59
85
  spawn(file, spawnArgs, {
60
86
  cwd,
61
- env: {
62
- ...process.env,
63
- ...envVars
64
- },
87
+ env: childEnvironment(process.env, envVars),
65
88
  stdio: "pipe",
66
89
  ...(windows === undefined ? {} : { windowsVerbatimArguments: windows.verbatim })
67
90
  }),
package/src/Profile.ts CHANGED
@@ -9,9 +9,9 @@ export { duration }
9
9
  import { formatCount } from "./Terminal.ts"
10
10
  import {
11
11
  resolveTraceTarget,
12
+ WatchTargetMissing,
12
13
  type TraceChoice,
13
- type TraceSources,
14
- type WatchTargetMissing
14
+ type TraceSources
15
15
  } from "./Watch.ts"
16
16
 
17
17
  /**
@@ -1014,18 +1014,158 @@ export const renderProfile = (report: ProfileReport): string => {
1014
1014
  ].join("\n")
1015
1015
  }
1016
1016
 
1017
+ // ── delta ───────────────────────────────────────────────────────────────────
1018
+
1019
+ /** One line of a before/after comparison (ADR 0029): a count or a duration. */
1020
+ export const DeltaRow = Schema.Struct({
1021
+ label: Schema.String,
1022
+ before: Schema.Number,
1023
+ after: Schema.Number,
1024
+ unit: Schema.Literals(["count", "ms"]),
1025
+ /** Which direction is better, for the arrow; `none` for neutral facts. */
1026
+ better: Schema.Literals(["lower", "higher", "none"])
1027
+ })
1028
+ export type DeltaRow = typeof DeltaRow.Type
1029
+
1030
+ export const ProfileDelta = Schema.Struct({
1031
+ rows: Schema.Array(DeltaRow)
1032
+ })
1033
+ export type ProfileDelta = typeof ProfileDelta.Type
1034
+
1035
+ const roleCalls = (report: ProfileReport, role: string): number =>
1036
+ sum(report.models.filter((row) => row.role === role).map((row) => row.calls))
1037
+
1038
+ /**
1039
+ * The harness-evals comparison (`docs/harness-evals/README.md`), in its
1040
+ * order: how much the coders explored before editing, how often review and
1041
+ * judgment were called, what the gates said, how honest the claims were,
1042
+ * then where the time went. Tokens and cost are `llm4ts costs`' business.
1043
+ */
1044
+ export const profileDelta = (before: ProfileReport, after: ProfileReport): ProfileDelta => {
1045
+ const count = (
1046
+ label: string,
1047
+ pick: (report: ProfileReport) => number,
1048
+ better: DeltaRow["better"] = "lower"
1049
+ ): DeltaRow => ({
1050
+ label,
1051
+ before: pick(before),
1052
+ after: pick(after),
1053
+ unit: "count",
1054
+ better
1055
+ })
1056
+ const time = (label: string, pick: (report: ProfileReport) => number): DeltaRow => ({
1057
+ label,
1058
+ before: pick(before),
1059
+ after: pick(after),
1060
+ unit: "ms",
1061
+ better: "lower"
1062
+ })
1063
+ return {
1064
+ rows: [
1065
+ count("stories done", (r) => r.stories.filter((s) => s.status === "done").length, "higher"),
1066
+ count("stories failed", (r) => r.stories.filter((s) => s.status === "failed").length),
1067
+ count("explore calls before the first edit", (r) =>
1068
+ sum(r.stories.map((s) => s.exploreBeforeEdit))
1069
+ ),
1070
+ count("coder turns", (r) => sum(r.stories.map((s) => s.turns.count))),
1071
+ count("coder edits", (r) => sum(r.stories.map((s) => s.toolCalls.edit)), "none"),
1072
+ count("coder test runs", (r) => sum(r.stories.map((s) => s.toolCalls.test)), "none"),
1073
+ count("reviewer calls", (r) => roleCalls(r, "reviewer")),
1074
+ count("judge calls", (r) => roleCalls(r, "judge")),
1075
+ count("gate runs", (r) => sum(r.gates.map((g) => g.runs))),
1076
+ count("gate failures", (r) => sum(r.gates.map((g) => g.failed))),
1077
+ count("fabricated verification claims", (r) => sum(r.stories.map((s) => s.unverifiedClaims))),
1078
+ count("low-confidence tasks", (r) => sum(r.stories.map((s) => s.lowConfidenceTasks))),
1079
+ count(
1080
+ "stories whose prompt grew over 3×",
1081
+ (r) => r.stories.filter((s) => s.turns.promptGrowth).length
1082
+ ),
1083
+ time("wall", (r) => r.wallMs),
1084
+ time("model time", (r) => r.totals.model),
1085
+ time("coder tool time", (r) => r.totals.tools),
1086
+ time("gate time", (r) => r.totals.gates),
1087
+ time("waiting", (r) => r.totals.waiting)
1088
+ ]
1089
+ }
1090
+ }
1091
+
1092
+ const deltaCell = (row: DeltaRow): string => {
1093
+ const diff = row.after - row.before
1094
+ if (diff === 0) {
1095
+ return "="
1096
+ }
1097
+ const shown = row.unit === "ms" ? duration(Math.abs(diff)) : String(Math.abs(diff))
1098
+ const sign = diff > 0 ? "+" : "-"
1099
+ const verdict =
1100
+ row.better === "none" ? "" : diff < 0 === (row.better === "lower") ? " better" : " worse"
1101
+ const percent = row.before === 0 ? "" : ` (${Math.round((diff / row.before) * 100)}%)`
1102
+ return `${sign}${shown}${percent}${verdict}`
1103
+ }
1104
+
1105
+ export const renderProfileDelta = (
1106
+ delta: ProfileDelta,
1107
+ labels: { readonly before: string; readonly after: string }
1108
+ ): string =>
1109
+ [
1110
+ `llm4ts profile · ${labels.before} → ${labels.after}`,
1111
+ "",
1112
+ ...table(
1113
+ ["measure", "before", "after", "change"],
1114
+ delta.rows.map((row) => [
1115
+ row.label,
1116
+ row.unit === "ms" ? duration(row.before) : String(row.before),
1117
+ row.unit === "ms" ? duration(row.after) : String(row.after),
1118
+ deltaCell(row)
1119
+ ])
1120
+ ),
1121
+ "",
1122
+ "* tokens and cost per role: llm4ts costs --repo <repo>"
1123
+ ].join("\n")
1124
+
1017
1125
  export interface ProfileOptions extends TraceChoice {
1018
1126
  readonly json?: boolean
1127
+ /** A trace, or a `--json` report saved earlier, to compare the profiled run against (ADR 0029). */
1128
+ readonly against?: string
1019
1129
  }
1020
1130
 
1021
1131
  const encodeReport = Schema.encodeSync(Schema.fromJsonString(ProfileReport))
1132
+ const decodeReport = Schema.decodeUnknownOption(Schema.fromJsonString(ProfileReport))
1133
+ const encodeDelta = Schema.encodeSync(Schema.fromJsonString(ProfileDelta))
1022
1134
 
1023
- /** The report for a trace, an epic's latest run, or the newest trace: text, or JSON for comparing runs. */
1135
+ const reportAt = Effect.fn("@llm4ts/runner/Profile.reportAt")(function* (
1136
+ files: TraceSources["files"],
1137
+ path: string
1138
+ ): Effect.fn.Return<ProfileReport, FlowError | WatchTargetMissing> {
1139
+ if (path.endsWith(".json")) {
1140
+ const text = yield* files.read(path)
1141
+ const decoded = text === undefined ? undefined : decodeReport(text)
1142
+ if (decoded === undefined || decoded._tag === "None") {
1143
+ return yield* WatchTargetMissing.make({
1144
+ message: `${path} is not a profile report (write one with llm4ts profile --json)`
1145
+ })
1146
+ }
1147
+ return decoded.value
1148
+ }
1149
+ return profileOf(treeInputsOfTrace(yield* readTrace(files, path)))
1150
+ })
1151
+
1152
+ /**
1153
+ * The report for a trace, an epic's latest run, or the newest trace: text,
1154
+ * or JSON for comparing runs; with `against`, the delta from that earlier
1155
+ * run to this one.
1156
+ */
1024
1157
  export const makeProfileProgram = Effect.fn("@llm4ts/runner/Profile.make")(function* (
1025
1158
  options: ProfileOptions,
1026
1159
  sources: TraceSources
1027
1160
  ): Effect.fn.Return<string, FlowError | WatchTargetMissing> {
1028
1161
  const target = yield* resolveTraceTarget(options, sources)
1029
1162
  const report = profileOf(treeInputsOfTrace(yield* readTrace(sources.files, target.tracePath)))
1163
+ if (options.against !== undefined) {
1164
+ const before = yield* reportAt(sources.files, options.against)
1165
+ const delta = profileDelta(before, report)
1166
+ return options.json === true
1167
+ ? encodeDelta(delta)
1168
+ : renderProfileDelta(delta, { before: options.against, after: target.tracePath })
1169
+ }
1030
1170
  return options.json === true ? encodeReport(report) : renderProfile(report)
1031
1171
  })