@llm4ts/runner 2.28.1 → 2.30.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/Doctor.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  geminiBridgePort as bridgePort,
12
12
  piModelsConfig as parsePiModels
13
13
  } from "./PiModels.ts"
14
+ import { otelConfig, otelDoctorLine, probeOtelEndpoint, type OtelConfig } from "./Otel.ts"
14
15
 
15
16
  const credentialKeys = [
16
17
  "OPENAI_API_KEY",
@@ -220,65 +221,75 @@ export const makeDoctorProgram = (
220
221
  nodePlainFileStore,
221
222
  process.cwd(),
222
223
  environment
223
- )
224
- ): Effect.Effect<string> =>
225
- Effect.map(Effect.all([registry.healthCheckAll, nodeCheck]), ([statuses, node]) => {
226
- const lines: Array<string> = []
227
- lines.push("llm4ts doctor")
228
- lines.push("")
229
- lines.push("connectors:")
230
- const sorted = Array.from(statuses.entries()).sort(([a], [b]) => a.value.localeCompare(b.value))
231
- for (const [id, status] of sorted) {
232
- lines.push(statusLine(id.value, status))
233
- }
234
- lines.push("")
235
- lines.push("credentials:")
236
- for (const key of credentialKeys) {
237
- const value = environment[key]
238
- lines.push(` ${value === undefined || value.length === 0 ? "✖" : "✔"} ${key}`)
239
- }
240
- lines.push("")
241
- lines.push("gates:")
242
- lines.push(
243
- ...wrap(
244
- `${node._tag === "Mismatch" || node._tag === "NoNode" ? "✖" : "✔"} ${node.summary}`,
245
- 78,
246
- " "
247
- )
248
- )
249
-
250
- // Connector prerequisites the environment must carry BEFORE a run: shown
251
- // when the connector is selected or the machine has its CLI, because that
252
- // is exactly when a missing one turns into a confusing mid-run failure.
253
- const coder = selectedCoder(environment)
254
- const geminiRelevant =
255
- coder.startsWith("gemini") ||
256
- Array.from(statuses.keys()).some(
257
- (id) =>
258
- id.value === ConnectorIds.GeminiCli.value || id.value === ConnectorIds.GeminiApi.value
224
+ ),
225
+ /** Whether the OTLP endpoint answers (ADR 0026); the network probe by default. */
226
+ probeOtel: (config: OtelConfig) => Effect.Effect<boolean | undefined> = probeOtelEndpoint
227
+ ): Effect.Effect<string> => {
228
+ const otel = otelConfig(environment)
229
+ return Effect.map(
230
+ Effect.all([registry.healthCheckAll, nodeCheck, probeOtel(otel)]),
231
+ ([statuses, node, otelAnswers]) => {
232
+ const lines: Array<string> = []
233
+ lines.push("llm4ts doctor")
234
+ lines.push("")
235
+ lines.push("connectors:")
236
+ const sorted = Array.from(statuses.entries()).sort(([a], [b]) =>
237
+ a.value.localeCompare(b.value)
259
238
  )
260
- const bridge = geminiBridgePrerequisites(environment, readPiModelsJson())
261
- if (geminiRelevant || bridge !== undefined) {
239
+ for (const [id, status] of sorted) {
240
+ lines.push(statusLine(id.value, status))
241
+ }
262
242
  lines.push("")
263
- lines.push("prerequisites:")
264
- const pushReport = (label: string, report: PrerequisiteReport): void => {
265
- lines.push(` ${report.satisfied ? "✔" : "?"} ${label}: ${report.summary}`)
266
- if (report.hint !== undefined) {
267
- lines.push(...wrap(report.hint, 78, " "))
243
+ lines.push("credentials:")
244
+ for (const key of credentialKeys) {
245
+ const value = environment[key]
246
+ lines.push(` ${value === undefined || value.length === 0 ? "✖" : "✔"} ${key}`)
247
+ }
248
+ lines.push("")
249
+ lines.push("gates:")
250
+ lines.push(
251
+ ...wrap(
252
+ `${node._tag === "Mismatch" || node._tag === "NoNode" ? "✖" : "✔"} ${node.summary}`,
253
+ 78,
254
+ " "
255
+ )
256
+ )
257
+
258
+ // Connector prerequisites the environment must carry BEFORE a run: shown
259
+ // when the connector is selected or the machine has its CLI, because that
260
+ // is exactly when a missing one turns into a confusing mid-run failure.
261
+ const coder = selectedCoder(environment)
262
+ const geminiRelevant =
263
+ coder.startsWith("gemini") ||
264
+ Array.from(statuses.keys()).some(
265
+ (id) =>
266
+ id.value === ConnectorIds.GeminiCli.value || id.value === ConnectorIds.GeminiApi.value
267
+ )
268
+ const bridge = geminiBridgePrerequisites(environment, readPiModelsJson())
269
+ if (geminiRelevant || bridge !== undefined) {
270
+ lines.push("")
271
+ lines.push("prerequisites:")
272
+ const pushReport = (label: string, report: PrerequisiteReport): void => {
273
+ lines.push(` ${report.satisfied ? "✔" : "?"} ${label}: ${report.summary}`)
274
+ if (report.hint !== undefined) {
275
+ lines.push(...wrap(report.hint, 78, " "))
276
+ }
277
+ for (const line of report.detail ?? []) {
278
+ lines.push(` ${line}`)
279
+ }
268
280
  }
269
- for (const line of report.detail ?? []) {
270
- lines.push(` ${line}`)
281
+ if (geminiRelevant) {
282
+ pushReport("gemini", geminiPrerequisites(environment))
283
+ }
284
+ if (bridge !== undefined) {
285
+ pushReport("pi-gemini-bridge", bridge)
271
286
  }
272
287
  }
273
- if (geminiRelevant) {
274
- pushReport("gemini", geminiPrerequisites(environment))
275
- }
276
- if (bridge !== undefined) {
277
- pushReport("pi-gemini-bridge", bridge)
278
- }
279
- }
280
288
 
281
- lines.push("")
282
- lines.push(`coder: ${coder}`)
283
- return `${lines.join("\n")}\n`
284
- })
289
+ lines.push("")
290
+ lines.push(otelDoctorLine(otel, otelAnswers))
291
+ lines.push(`coder: ${coder}`)
292
+ return `${lines.join("\n")}\n`
293
+ }
294
+ )
295
+ }
package/src/FlowArgs.ts CHANGED
@@ -34,6 +34,8 @@ environment:
34
34
  LLM4TS_VERBOSITY terminal verbosity: quiet|normal|verbose
35
35
  LLM4TS_UI tree: the full-screen agent tree (ADR 0022; q returns to the classic view)
36
36
  LLM4TS_TRANSCRIPT on: record each seat's input and output under .llm4ts/transcripts/
37
+ LLM4TS_OTEL on: export spans over OTLP (a local Phoenix at localhost:6006 unless OTEL_* says otherwise)
38
+ LLM4TS_OTEL_CONTENT on|full: prompts, replies and tool I/O travel too (off by default)
37
39
  OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY
38
40
  credentials for API providers
39
41
 
package/src/FlowRunner.ts CHANGED
@@ -58,7 +58,18 @@ import {
58
58
  } from "@llm4ts/flow/FlowEvents"
59
59
  import { FlowContext, type ContextOptions, type FlowContextShape } from "@llm4ts/flow/FlowContext"
60
60
  import { makeFlowRecorder, type RunOutcome } from "@llm4ts/flow/FlowRecorder"
61
- import { timedJudgment, timedSeat, withTimedRole } from "@llm4ts/flow/Timing"
61
+ import { timedJudgment, timedSeat, withTimedRole, type TimedSeatOptions } from "@llm4ts/flow/Timing"
62
+ import { attr, otelContent, withKindSpan } from "@llm4ts/flow/Spans"
63
+ import * as Tracer from "effect/Tracer"
64
+ import { basename } from "node:path"
65
+ import {
66
+ otelConfig,
67
+ otelWarning,
68
+ probeOtelEndpoint,
69
+ otelLayer,
70
+ otelSummary,
71
+ runnerVersion
72
+ } from "./Otel.ts"
62
73
  import { transcriptSeat, type TranscriptSink } from "@llm4ts/flow/Transcript"
63
74
  import { nodeTranscriptFiles, nodeTranscriptSink, transcriptsWanted } from "./Transcripts.ts"
64
75
  import { idleAfterFrom } from "./AgentTree.ts"
@@ -362,6 +373,13 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
362
373
  const runEnvironment = options.environment ?? process.env
363
374
  const estimateOptions = estimatedUsageOptionsFromEnv(runEnvironment)
364
375
  const meterUsage = options.estimateUsage ?? !switchedOff(runEnvironment.LLM4TS_ESTIMATE_USAGE)
376
+ // Every seat's spans (ADR 0026): content only when asked, an estimate on a
377
+ // call the backend reports no usage for, and who served it when known.
378
+ const seatOptions = (executor?: Effect.Effect<string | undefined>): TimedSeatOptions => ({
379
+ content: otelContent(runEnvironment),
380
+ ...(meterUsage ? { estimate: estimateOptions } : {}),
381
+ ...(executor === undefined ? {} : { executor })
382
+ })
365
383
  const metered = (service: LlmServiceShape): Effect.Effect<LlmServiceShape> =>
366
384
  meterUsage
367
385
  ? Effect.map(makeEstimatedUsageMeter(service, estimateOptions), (meter) => meter.service)
@@ -490,18 +508,26 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
490
508
  ): FlowContextShape =>
491
509
  FlowContext.of({
492
510
  // Every call is timed on the lane's events, under its role.
493
- reasoning: recorded(timedSeat(seats.reasoning, laneEvents, "reasoning"), lane, "reasoning"),
511
+ reasoning: recorded(
512
+ timedSeat(seats.reasoning, laneEvents, "reasoning", seatOptions()),
513
+ lane,
514
+ "reasoning"
515
+ ),
494
516
  coder: {
495
517
  ...seats.coder,
496
- ...recorded(timedSeat(seats.coder, laneEvents, "coder"), lane, "coder")
518
+ ...recorded(timedSeat(seats.coder, laneEvents, "coder", seatOptions()), lane, "coder")
497
519
  },
498
520
  judgment: timedJudgment(seats.judgment, laneEvents),
499
- judge: recorded(timedSeat(seats.judge, laneEvents, "judgment"), lane, "judgment"),
521
+ judge: recorded(
522
+ timedSeat(seats.judge, laneEvents, "judgment", seatOptions()),
523
+ lane,
524
+ "judgment"
525
+ ),
500
526
  git: makeGitTool(dependencies.process, workDir, laneEvents),
501
527
  hosting: makeGitHubTool(dependencies.process, workDir, laneEvents),
502
528
  events: laneEvents,
503
529
  reviewers: seats.reviewers.map((reviewer) =>
504
- recorded(timedSeat(reviewer, laneEvents, "reviewer"), lane, "reviewer")
530
+ recorded(timedSeat(reviewer, laneEvents, "reviewer", seatOptions()), lane, "reviewer")
505
531
  ),
506
532
  coderCapabilities: seats.coder.capabilities,
507
533
  userPrompt: options.userPrompt,
@@ -671,7 +697,8 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
671
697
  label
672
698
  }),
673
699
  laneEvents,
674
- role
700
+ role,
701
+ seatOptions()
675
702
  ),
676
703
  lane,
677
704
  role
@@ -709,7 +736,7 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
709
736
  return FlowContext.of({
710
737
  reasoning: view.forRole(rebind ? "planner" : "reviewer"),
711
738
  coder: recorded(
712
- timedSeat(held.service, laneEvents, "coder"),
739
+ timedSeat(held.service, laneEvents, "coder", seatOptions(held.executor)),
713
740
  lane,
714
741
  "coder",
715
742
  held.executor
@@ -811,6 +838,9 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
811
838
  const environment = options.environment ?? process.env
812
839
  const verbosity = options.verbosity ?? "Normal"
813
840
  const persistRun = options.persistRun ?? true
841
+ // OpenTelemetry export (ADR 0026): decided from the environment, built once
842
+ // per run, flushed when the run's scope closes.
843
+ const otel = otelConfig(environment)
814
844
  const tracePath =
815
845
  options.tracePath ??
816
846
  (persistRun ? join(options.workDir, ".llm4ts", `trace-${startedAt}.jsonl`) : undefined)
@@ -867,6 +897,16 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
867
897
  )
868
898
  )
869
899
  }
900
+ const where = otelSummary(otel)
901
+ if (where !== undefined) {
902
+ yield* surface.log(palette.info(where))
903
+ // One warning, at the start, when nothing listens: the exporter itself
904
+ // drops batches quietly. The probe is bounded (1.5s) and runs once.
905
+ const warning = otelWarning(otel, yield* probeOtelEndpoint(otel))
906
+ if (warning !== undefined) {
907
+ yield* surface.log(palette.warn(warning))
908
+ }
909
+ }
870
910
  }
871
911
  const recorder =
872
912
  tracePath === undefined
@@ -925,7 +965,32 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
925
965
  tracePath === undefined
926
966
  ? bundle.context
927
967
  : { ...bundle.context, trace: { runId: bundle.runId, path: tracePath } }
928
- return yield* body(context).pipe(
968
+ const runAttributes: Record<string, unknown> = {
969
+ [attr.run]: bundle.runId,
970
+ [attr.session]: bundle.runId,
971
+ ...(environment.LLM4TS_FLOW === undefined ? {} : { [attr.flow]: environment.LLM4TS_FLOW })
972
+ }
973
+ // The run is a CHAIN span every other span descends from or links to; its
974
+ // attributes ride on all of them. Effect's own function spans stay
975
+ // unsampled so an exported trace shows the flow, not the call stack.
976
+ const traced = withKindSpan(
977
+ "run",
978
+ { kind: "CHAIN", attributes: runAttributes },
979
+ Object.entries(runAttributes).reduce(
980
+ (acc, [key, value]) => Effect.annotateSpans(key, value)(acc),
981
+ body(context)
982
+ )
983
+ ).pipe(
984
+ Effect.provideService(Tracer.MinimumTraceLevel, otel.mode === "off" ? "All" : "None"),
985
+ Effect.provide(
986
+ otelLayer(
987
+ otel,
988
+ { serviceVersion: runnerVersion, project: basename(options.workDir) },
989
+ environment
990
+ )
991
+ )
992
+ )
993
+ return yield* traced.pipe(
929
994
  Effect.provideService(FlowContext, context),
930
995
  Effect.andThen((value) => Effect.as(enforceBudget, value)),
931
996
  Effect.ensuring(
package/src/Otel.ts ADDED
@@ -0,0 +1,198 @@
1
+ // OpenTelemetry export (ADR 0026): Effect's own OTLP tracer and metrics
2
+ // exporters over core's fetch client, so a run can show up in Phoenix,
3
+ // Langfuse, SigNoz or any collector with no SDK and no new dependency.
4
+ // The standard OTEL_* variables are the truth; `--otel` is the laptop case.
5
+ import * as ConfigProvider from "effect/ConfigProvider"
6
+ import * as Duration from "effect/Duration"
7
+ import * as Effect from "effect/Effect"
8
+ import * as Layer from "effect/Layer"
9
+ import * as FetchHttpClient from "effect/http/FetchHttpClient"
10
+ import * as OtlpExporter from "effect/observability/OtlpExporter"
11
+ import * as OtlpMetrics from "effect/observability/OtlpMetrics"
12
+ import * as OtlpSerialization from "effect/observability/OtlpSerialization"
13
+ import * as OtlpTracer from "effect/observability/OtlpTracer"
14
+ import { packageVersion } from "./Package.ts"
15
+
16
+ export type OtelConfig =
17
+ | { readonly mode: "off" }
18
+ | { readonly mode: "phoenix"; readonly tracesUrl: string }
19
+ | { readonly mode: "env"; readonly endpoint: string }
20
+
21
+ export const phoenixTracesUrl = "http://localhost:6006/v1/traces"
22
+
23
+ const truthy = (value: string | undefined): boolean =>
24
+ /^(on|1|true|yes)$/iu.test(value?.trim() ?? "")
25
+ const falsy = (value: string | undefined): boolean =>
26
+ /^(off|0|false|no)$/iu.test(value?.trim() ?? "")
27
+
28
+ /**
29
+ * Off unless asked. An OTLP endpoint in the environment wins and is used as
30
+ * the standard variables say; `LLM4TS_OTEL=on` alone means a local Phoenix,
31
+ * traces only. `OTEL_SDK_DISABLED` or `LLM4TS_OTEL=off` switch everything off.
32
+ */
33
+ export const otelConfig = (
34
+ environment: Readonly<Record<string, string | undefined>>
35
+ ): OtelConfig => {
36
+ if (truthy(environment.OTEL_SDK_DISABLED) || falsy(environment.LLM4TS_OTEL)) {
37
+ return { mode: "off" }
38
+ }
39
+ // Under the test runner the exporter's sleeps run on a TestClock nobody
40
+ // advances, so its final flush would wait forever: a developer with
41
+ // LLM4TS_OTEL=on in the shell must still get a green suite.
42
+ if (environment.VITEST !== undefined) {
43
+ return { mode: "off" }
44
+ }
45
+ const endpoint =
46
+ environment.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim() ||
47
+ environment.OTEL_EXPORTER_OTLP_ENDPOINT?.trim()
48
+ if (endpoint !== undefined && endpoint.length > 0) {
49
+ return { mode: "env", endpoint }
50
+ }
51
+ return truthy(environment.LLM4TS_OTEL)
52
+ ? { mode: "phoenix", tracesUrl: phoenixTracesUrl }
53
+ : { mode: "off" }
54
+ }
55
+
56
+ export interface OtelResource {
57
+ readonly serviceVersion: string
58
+ /** Phoenix's project: the repository's basename, never a path. */
59
+ readonly project: string
60
+ }
61
+
62
+ /** The run end may wait this long for the last batch; the event drain waits the same. */
63
+ export const flushTimeout = Duration.seconds(3)
64
+
65
+ /** This runner's version, for `service.version`. */
66
+ export const runnerVersion: string = packageVersion
67
+
68
+ const resourceOf = (resource: OtelResource) => ({
69
+ serviceName: "llm4ts",
70
+ serviceVersion: resource.serviceVersion,
71
+ attributes: { "openinference.project.name": resource.project }
72
+ })
73
+
74
+ /**
75
+ * The exporter for `config`: nothing when off; traces only for Phoenix;
76
+ * traces and metrics from the standard variables for a generic endpoint.
77
+ * Logs are never exported. Export errors are the exporter's to swallow; the
78
+ * run never fails on them.
79
+ */
80
+ export const otelLayer = (
81
+ config: OtelConfig,
82
+ resource: OtelResource,
83
+ /** What the standard variables are read from in env mode (the run's environment). */
84
+ environment: Readonly<Record<string, string | undefined>> = process.env
85
+ ): Layer.Layer<OtlpExporter.Flusher> => {
86
+ switch (config.mode) {
87
+ case "off":
88
+ // The exporter's no-op flusher: nothing to export, nothing to flush.
89
+ return OtlpExporter.layerFlusher
90
+ case "phoenix":
91
+ return OtlpTracer.layer({
92
+ url: config.tracesUrl,
93
+ resource: resourceOf(resource),
94
+ shutdownTimeout: flushTimeout
95
+ }).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
96
+ case "env":
97
+ // The standard variables are read from the run's own environment, with
98
+ // the two exporter defaults an endpoint implies; the process env is
99
+ // never written.
100
+ return Layer.mergeAll(
101
+ OtlpTracer.layerFromConfig({ resource: resourceOf(resource) }),
102
+ OtlpMetrics.layerFromConfig({ resource: resourceOf(resource) })
103
+ ).pipe(
104
+ Layer.provide(OtlpSerialization.layerJson),
105
+ Layer.provide(FetchHttpClient.layer),
106
+ Layer.provide(
107
+ ConfigProvider.layer(
108
+ ConfigProvider.fromEnvRecord({
109
+ ...environment,
110
+ ...otelEnvironmentDefaults(config, environment)
111
+ })
112
+ )
113
+ )
114
+ )
115
+ }
116
+ }
117
+
118
+ /**
119
+ * `layerFromConfig` exports nothing unless `OTEL_TRACES_EXPORTER` names
120
+ * `otlp`; an endpoint in the environment is the operator's intent, so the
121
+ * layer reads its variables with that default (and the metrics one) added
122
+ * when they are unset.
123
+ */
124
+ export const otelEnvironmentDefaults = (
125
+ config: OtelConfig,
126
+ environment: Readonly<Record<string, string | undefined>>
127
+ ): Readonly<Record<string, string>> =>
128
+ config.mode !== "env"
129
+ ? {}
130
+ : {
131
+ ...(environment.OTEL_TRACES_EXPORTER === undefined ? { OTEL_TRACES_EXPORTER: "otlp" } : {}),
132
+ ...(environment.OTEL_METRICS_EXPORTER === undefined
133
+ ? { OTEL_METRICS_EXPORTER: "otlp" }
134
+ : {})
135
+ }
136
+
137
+ export const otelSummary = (config: OtelConfig): string | undefined => {
138
+ switch (config.mode) {
139
+ case "off":
140
+ return undefined
141
+ case "phoenix":
142
+ return `otel → Phoenix at ${config.tracesUrl} (traces)`
143
+ case "env":
144
+ return `otel → ${config.endpoint} (traces + metrics, OTEL_* variables)`
145
+ }
146
+ }
147
+
148
+ /**
149
+ * The one warning a run prints when export is on and nothing answers at the
150
+ * endpoint (spec §9): the exporter itself only logs at debug level and drops
151
+ * the batch. Nothing when off or when the endpoint answered.
152
+ */
153
+ export const otelWarning = (
154
+ config: OtelConfig,
155
+ answers: boolean | undefined
156
+ ): string | undefined => {
157
+ if (config.mode === "off" || answers !== false) {
158
+ return undefined
159
+ }
160
+ const where = config.mode === "phoenix" ? config.tracesUrl : config.endpoint
161
+ return (
162
+ `⚠ otel: nothing answers at ${where} — spans will be dropped (the run is unaffected)` +
163
+ (config.mode === "phoenix"
164
+ ? "; start Phoenix with `docker run -p 6006:6006 arizephoenix/phoenix:latest`"
165
+ : "")
166
+ )
167
+ }
168
+
169
+ /** The doctor's line: `answers` is the probe's result, undefined when export is off. */
170
+ export const otelDoctorLine = (config: OtelConfig, answers: boolean | undefined): string => {
171
+ switch (config.mode) {
172
+ case "off":
173
+ return "otel: off (LLM4TS_OTEL=on for a local Phoenix, or OTEL_EXPORTER_OTLP_ENDPOINT)"
174
+ case "phoenix":
175
+ case "env": {
176
+ const where = config.mode === "phoenix" ? `Phoenix at ${config.tracesUrl}` : config.endpoint
177
+ return `otel: ${where} — ${
178
+ answers === true
179
+ ? "endpoint answers"
180
+ : "endpoint does not answer (spans will be dropped, the run is unaffected)"
181
+ }`
182
+ }
183
+ }
184
+ }
185
+
186
+ /** Whether anything listens at the endpoint: any HTTP answer counts, a refused connection does not. */
187
+ export const probeOtelEndpoint = (config: OtelConfig): Effect.Effect<boolean | undefined> =>
188
+ config.mode === "off"
189
+ ? Effect.succeed(undefined)
190
+ : Effect.tryPromise(() =>
191
+ fetch(config.mode === "phoenix" ? config.tracesUrl : config.endpoint, {
192
+ method: "OPTIONS",
193
+ signal: AbortSignal.timeout(1_500)
194
+ })
195
+ ).pipe(
196
+ Effect.as(true),
197
+ Effect.orElseSucceed(() => false)
198
+ )
package/src/Package.ts CHANGED
@@ -1 +1,11 @@
1
+ import { createRequire } from "node:module"
2
+ import * as Schema from "effect/Schema"
3
+
4
+ const Manifest = Schema.Struct({ version: Schema.String })
5
+
6
+ const manifest = Schema.decodeUnknownSync(Manifest)(
7
+ createRequire(import.meta.url)("../package.json")
8
+ )
9
+
1
10
  export const packageName = "@llm4ts/runner"
11
+ export const packageVersion: string = manifest.version
package/src/Profile.ts CHANGED
@@ -65,6 +65,15 @@ export const Gap = Schema.Struct({
65
65
  })
66
66
  export type Gap = typeof Gap.Type
67
67
 
68
+ /** The coder's tool calls by kind of work (build, install and git count as other). */
69
+ export const ToolCalls = Schema.Struct({
70
+ explore: Schema.Int,
71
+ edit: Schema.Int,
72
+ test: Schema.Int,
73
+ other: Schema.Int
74
+ })
75
+ export type ToolCalls = typeof ToolCalls.Type
76
+
68
77
  export const StoryProfile = Schema.Struct({
69
78
  id: Schema.String,
70
79
  executor: Schema.optionalKey(Schema.String),
@@ -81,7 +90,14 @@ export const StoryProfile = Schema.Struct({
81
90
  * call) but not ended: a run still going. Counted as model time so far.
82
91
  */
83
92
  openTurnMs: Ms,
84
- openTurnTools: Schema.Int
93
+ openTurnTools: Schema.Int,
94
+ toolCalls: ToolCalls,
95
+ /** Task stages the coder ran on this lane: the tasks it gave itself, plus any revision. */
96
+ tasks: Schema.Int,
97
+ /** From the coder's first task (after setup and planning) to its first edit; absent when it never edited. */
98
+ firstEditMs: Schema.optionalKey(Ms),
99
+ /** Explore calls (ls, find, grep, read) made before that first edit. */
100
+ exploreBeforeEdit: Schema.Int
85
101
  })
86
102
  export type StoryProfile = typeof StoryProfile.Type
87
103
 
@@ -159,6 +175,12 @@ interface Lane {
159
175
  /** Since when a coder turn has been working without ending, and its tool calls. */
160
176
  openSince: number | undefined
161
177
  openTools: number
178
+ tasks: number
179
+ /** When the coder's first task began: setup and task planning come before it. */
180
+ firstTask: number | undefined
181
+ toolCalls: { explore: number; edit: number; test: number; other: number }
182
+ firstEdit: number | undefined
183
+ exploreBeforeEdit: number
162
184
  }
163
185
 
164
186
  interface Lease {
@@ -321,10 +343,23 @@ export const profileOf = (inputs: ReadonlyArray<TreeInput>): ProfileReport => {
321
343
  skipped: false,
322
344
  moments: [{ at, what: describe(event) }],
323
345
  openSince: undefined,
324
- openTools: 0
346
+ openTools: 0,
347
+ tasks: 0,
348
+ firstTask: undefined,
349
+ toolCalls: { explore: 0, edit: 0, test: 0, other: 0 },
350
+ firstEdit: undefined,
351
+ exploreBeforeEdit: 0
325
352
  })
326
353
  continue
327
354
  }
355
+ if (
356
+ lane !== undefined &&
357
+ !storyStage.test(event.stage) &&
358
+ !event.stage.startsWith(`story ${lane.id}:`)
359
+ ) {
360
+ lane.tasks += 1
361
+ lane.firstTask = lane.firstTask ?? at
362
+ }
328
363
  break
329
364
  }
330
365
  case "StageCompleted":
@@ -340,6 +375,17 @@ export const profileOf = (inputs: ReadonlyArray<TreeInput>): ProfileReport => {
340
375
  case "Timed": {
341
376
  allTimed.push({ at, event })
342
377
  lane?.timed.push({ at, event })
378
+ if (lane !== undefined && event.kind === "tool" && event.category !== undefined) {
379
+ const kind =
380
+ event.category === "explore" || event.category === "edit" || event.category === "test"
381
+ ? event.category
382
+ : "other"
383
+ lane.toolCalls[kind] += 1
384
+ if (kind === "edit" && lane.firstEdit === undefined) {
385
+ lane.firstEdit = at - (lane.firstTask ?? lane.start)
386
+ lane.exploreBeforeEdit = lane.toolCalls.explore
387
+ }
388
+ }
343
389
  if (lane !== undefined && event.kind === "model" && event.label === "coder") {
344
390
  lane.openSince = undefined
345
391
  lane.openTools = 0
@@ -578,6 +624,10 @@ const storyProfile = (
578
624
  gaps: estimated ? [] : gapsOf(lane),
579
625
  openTurnMs,
580
626
  openTurnTools: open ? lane.openTools : 0,
627
+ toolCalls: { ...lane.toolCalls },
628
+ tasks: lane.tasks,
629
+ ...(lane.firstEdit === undefined ? {} : { firstEditMs: lane.firstEdit }),
630
+ exploreBeforeEdit: lane.exploreBeforeEdit,
581
631
  id: lane.id,
582
632
  ...(lane.executor === undefined ? {} : { executor: lane.executor }),
583
633
  status: lane.status,
@@ -683,6 +733,19 @@ const findingsOf = (facts: {
683
733
  })
684
734
  }
685
735
  }
736
+ for (const story of facts.stories) {
737
+ const wandered =
738
+ story.firstEditMs !== undefined &&
739
+ story.wallMs >= 60_000 &&
740
+ (story.exploreBeforeEdit >= 15 ||
741
+ (story.exploreBeforeEdit >= 5 && story.firstEditMs >= story.wallMs / 4))
742
+ if (wandered && story.firstEditMs !== undefined) {
743
+ candidates.push({
744
+ ms: story.firstEditMs,
745
+ text: `${story.id}: ${plural(story.exploreBeforeEdit, "explore call")} and ${duration(story.firstEditMs)} before the coder's first edit — it found its way instead of being told where to go`
746
+ })
747
+ }
748
+ }
686
749
  const queued = sum(facts.stories.map((story) => story.queuedMs ?? 0))
687
750
  if (queued >= 1_000) {
688
751
  candidates.push({
@@ -795,6 +858,41 @@ export const renderProfile = (report: ProfileReport): string => {
795
858
  : ""
796
859
  ])
797
860
  ),
861
+ ...(report.stories.every(
862
+ (story) =>
863
+ story.toolCalls.explore +
864
+ story.toolCalls.edit +
865
+ story.toolCalls.test +
866
+ story.toolCalls.other ===
867
+ 0
868
+ )
869
+ ? []
870
+ : [
871
+ "",
872
+ "Coder work per story",
873
+ ...table(
874
+ [
875
+ "story",
876
+ "tasks",
877
+ "explore",
878
+ "edit",
879
+ "test",
880
+ "other",
881
+ "before 1st edit",
882
+ "1st edit at"
883
+ ],
884
+ report.stories.map((story) => [
885
+ story.id,
886
+ String(story.tasks),
887
+ String(story.toolCalls.explore),
888
+ String(story.toolCalls.edit),
889
+ String(story.toolCalls.test),
890
+ String(story.toolCalls.other),
891
+ story.firstEditMs === undefined ? "" : `${story.exploreBeforeEdit} explore`,
892
+ story.firstEditMs === undefined ? "never" : duration(story.firstEditMs)
893
+ ])
894
+ )
895
+ ]),
798
896
  ...(report.skipped.length === 0
799
897
  ? []
800
898
  : [` skipped (merged on an earlier run): ${report.skipped.join(", ")}`]),