@llm4ts/flow 2.29.0 → 2.30.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/flow",
3
- "version": "2.29.0",
3
+ "version": "2.30.1",
4
4
  "description": "Effect-native LLM workflow, persistence, review, and repository automation",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -74,6 +74,7 @@
74
74
  "./ReviewCache": "./dist/ReviewCache.js",
75
75
  "./Reviewer": "./dist/Reviewer.js",
76
76
  "./SpecChecks": "./dist/SpecChecks.js",
77
+ "./Spans": "./dist/Spans.js",
77
78
  "./Roster": "./dist/Roster.js",
78
79
  "./RosterSeats": "./dist/RosterSeats.js",
79
80
  "./Stories": "./dist/Stories.js",
@@ -112,7 +113,7 @@
112
113
  "typescript"
113
114
  ],
114
115
  "dependencies": {
115
- "@llm4ts/core": "2.29.0"
116
+ "@llm4ts/core": "2.30.1"
116
117
  },
117
118
  "peerDependencies": {
118
119
  "effect": "4.0.0"
@@ -1,25 +1,36 @@
1
1
  import * as Effect from "effect/Effect"
2
2
  import { StageCompleted, StageFailed, StageStarted, type FlowEventsShape } from "./FlowEvents.ts"
3
+ import { withKindSpan, type SpanOptions } from "./Spans.ts"
3
4
  import { describeFlowError, type FlowError } from "./FlowError.ts"
4
5
  import type { Plan, Task } from "./Plan.ts"
5
6
  import type { PlanStoreShape } from "./Persistence.ts"
6
7
 
7
8
  const errorMessage = describeFlowError
8
9
 
10
+ /**
11
+ * A named step of a flow: published as StageStarted/Completed/Failed for the
12
+ * terminal, the trace and the profile, and a span of `span.kind` (CHAIN by
13
+ * default) for OpenTelemetry (ADR 0026).
14
+ */
9
15
  export const stage = <A, E, R>(
10
16
  events: FlowEventsShape,
11
17
  name: string,
12
- effect: Effect.Effect<A, E, R>
18
+ effect: Effect.Effect<A, E, R>,
19
+ span: SpanOptions = { kind: "CHAIN" }
13
20
  ): Effect.Effect<A, E, R> =>
14
- events.publish(StageStarted.make({ stage: name })).pipe(
15
- Effect.andThen(effect),
16
- Effect.tap(() => events.publish(StageCompleted.make({ stage: name }))),
17
- Effect.tapError((error) =>
18
- events.publish(
19
- StageFailed.make({
20
- stage: name,
21
- message: errorMessage(error)
22
- })
21
+ withKindSpan(
22
+ name,
23
+ span,
24
+ events.publish(StageStarted.make({ stage: name })).pipe(
25
+ Effect.andThen(effect),
26
+ Effect.tap(() => events.publish(StageCompleted.make({ stage: name }))),
27
+ Effect.tapError((error) =>
28
+ events.publish(
29
+ StageFailed.make({
30
+ stage: name,
31
+ message: errorMessage(error)
32
+ })
33
+ )
23
34
  )
24
35
  )
25
36
  )
package/src/Review.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  Timed,
22
22
  type FlowEventsShape
23
23
  } from "./FlowEvents.ts"
24
+ import { attr, withKindSpan } from "./Spans.ts"
24
25
  import { Reviewer } from "./Reviewer.ts"
25
26
  import { publishUsage } from "./Usage.ts"
26
27
  import type { JudgmentShape } from "@llm4ts/core/judgment/Judgment"
@@ -275,20 +276,25 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
275
276
  return ReviewResult.make({ issues: [], summary: "" })
276
277
  }
277
278
  const started = yield* Clock.currentTimeMillis
278
- const result = yield* guarded(
279
- Capabilities.Exec(executable),
280
- `lint: ${command.join(" ")}`,
281
- events,
282
- process.run(command, workDir, {}).pipe(
283
- Effect.mapError((cause) =>
284
- ProcessError.make({
285
- message: command.join(" "),
286
- detail: cause.message
287
- })
279
+ // A TOOL span (ADR 0026) and a Timed event: the command as configured and
280
+ // its exit code, never its output.
281
+ const result = yield* withKindSpan(
282
+ `gate ${command.join(" ")}`,
283
+ { kind: "TOOL", attributes: { [attr.gateCommand]: command.join(" ") } },
284
+ guarded(
285
+ Capabilities.Exec(executable),
286
+ `lint: ${command.join(" ")}`,
287
+ events,
288
+ process.run(command, workDir, {}).pipe(
289
+ Effect.mapError((cause) =>
290
+ ProcessError.make({
291
+ message: command.join(" "),
292
+ detail: cause.message
293
+ })
294
+ )
288
295
  )
289
- )
296
+ ).pipe(Effect.tap((ran) => Effect.annotateCurrentSpan(attr.gateExit, ran.exitCode)))
290
297
  )
291
- // The command as configured and its exit code: never its output.
292
298
  yield* events.publish(
293
299
  Timed.make({
294
300
  kind: "gate",
package/src/Spans.ts ADDED
@@ -0,0 +1,150 @@
1
+ // The vocabulary every llm4ts span speaks (ADR 0026): OpenTelemetry GenAI
2
+ // attributes first, OpenInference's span kind and token keys beside them so
3
+ // Phoenix of any version renders the LLM views, and llm4ts's own keys for
4
+ // what no convention names (role, story, epic, estimated usage). Pure: the
5
+ // exporter is the runner's, the seams only call `withKindSpan`.
6
+ import * as Context from "effect/Context"
7
+ import * as Effect from "effect/Effect"
8
+ import * as Option from "effect/Option"
9
+ import type * as Tracer from "effect/Tracer"
10
+ import type { TokenUsage } from "@llm4ts/core/Models"
11
+ import { isEstimatedModel } from "./EstimatedUsage.ts"
12
+
13
+ export type SpanKind = "CHAIN" | "AGENT" | "LLM" | "TOOL" | "EVALUATOR"
14
+
15
+ export const kindAttribute = "openinference.span.kind"
16
+
17
+ export const attr = {
18
+ role: "llm4ts.role",
19
+ executor: "llm4ts.executor",
20
+ story: "llm4ts.story",
21
+ epic: "llm4ts.epic",
22
+ flow: "llm4ts.flow",
23
+ run: "llm4ts.run",
24
+ session: "session.id",
25
+ toolCategory: "llm4ts.tool.category",
26
+ estimated: "llm4ts.usage.estimated",
27
+ costUsd: "llm4ts.cost.usd",
28
+ costSource: "llm4ts.cost.source",
29
+ gateCommand: "llm4ts.gate.command",
30
+ gateExit: "llm4ts.gate.exit_code",
31
+ model: "gen_ai.request.model",
32
+ provider: "gen_ai.provider.name",
33
+ operation: "gen_ai.operation.name",
34
+ inputTokens: "gen_ai.usage.input_tokens",
35
+ outputTokens: "gen_ai.usage.output_tokens",
36
+ oiModel: "llm.model_name",
37
+ oiPrompt: "llm.token_count.prompt",
38
+ oiCompletion: "llm.token_count.completion",
39
+ oiTotal: "llm.token_count.total",
40
+ input: "input.value",
41
+ output: "output.value",
42
+ inputMessages: "gen_ai.input.messages",
43
+ outputMessages: "gen_ai.output.messages"
44
+ } as const
45
+
46
+ /** How much content leaves the machine: nothing, prompts and tools, or the system prompt too. */
47
+ export type OtelContent = "off" | "on" | "full"
48
+
49
+ /** LLM4TS_OTEL_CONTENT: `on` or `full`; anything else is off. */
50
+ export const otelContent = (
51
+ environment: Readonly<Record<string, string | undefined>>
52
+ ): OtelContent => {
53
+ const value = environment.LLM4TS_OTEL_CONTENT?.trim().toLowerCase()
54
+ return value === "on" ? "on" : value === "full" ? "full" : "off"
55
+ }
56
+
57
+ export interface SpanOptions {
58
+ readonly kind: SpanKind
59
+ /** Start a new trace here (a story), linked to the span it was started from. */
60
+ readonly root?: boolean
61
+ readonly attributes?: Readonly<Record<string, unknown>>
62
+ }
63
+
64
+ /**
65
+ * The nearest span `withKindSpan` opened. Effect names a span for every
66
+ * `Effect.fn`, so the fiber's current span is usually an internal one; kind
67
+ * spans chain to each other instead, and the runner leaves the internal ones
68
+ * unsampled, so an exported trace shows the flow, not the call stack.
69
+ */
70
+ const CurrentKindSpan = Context.Reference<Option.Option<Tracer.Span>>(
71
+ "@llm4ts/flow/Spans/CurrentKindSpan",
72
+ { defaultValue: () => Option.none() }
73
+ )
74
+
75
+ /** The kind span the current code runs under, when there is one. */
76
+ export const currentKindSpan: Effect.Effect<Option.Option<Tracer.Span>> = CurrentKindSpan
77
+
78
+ /**
79
+ * A span with the OpenInference kind set, parented to the nearest kind span,
80
+ * always sampled, ended with the effect's exit. With no kind span above it
81
+ * (the run; a story asked to root) it starts a trace of its own, linked to
82
+ * the kind span it was started from when there is one. The wrapped effect
83
+ * keeps the caller's scope: only the span's lifetime is managed here.
84
+ */
85
+ export const withKindSpan = <A, E, R>(
86
+ name: string,
87
+ options: SpanOptions,
88
+ effect: Effect.Effect<A, E, R>
89
+ ): Effect.Effect<A, E, R> =>
90
+ Effect.flatMap(CurrentKindSpan, (parent) =>
91
+ Effect.useSpan(
92
+ name,
93
+ {
94
+ kind: options.kind === "LLM" ? "client" : "internal",
95
+ sampled: true,
96
+ ...(options.root === true || Option.isNone(parent)
97
+ ? {
98
+ root: true,
99
+ ...(Option.isSome(parent) ? { links: [{ span: parent.value, attributes: {} }] } : {})
100
+ }
101
+ : { parent: parent.value }),
102
+ attributes: { [kindAttribute]: options.kind, ...(options.attributes ?? {}) }
103
+ },
104
+ (span) =>
105
+ Effect.withParentSpan(
106
+ Effect.provideService(effect, CurrentKindSpan, Option.some(span)),
107
+ span
108
+ )
109
+ )
110
+ )
111
+
112
+ /** The model name a backend would price: the estimate label stripped. */
113
+ export const modelNameOf = (model: string | undefined): string | undefined =>
114
+ model === undefined
115
+ ? undefined
116
+ : isEstimatedModel(model)
117
+ ? model.replace(/^estimated:/u, "")
118
+ : model
119
+
120
+ /**
121
+ * Token and cost attributes for one model call; `{}` when nothing is known.
122
+ * A cost travels only when the backend reported it on measured usage: the
123
+ * backends price estimates themselves from the model name.
124
+ */
125
+ export const usageAttributes = (
126
+ model: string | undefined,
127
+ usage: TokenUsage | undefined,
128
+ estimated: boolean
129
+ ): Record<string, unknown> => {
130
+ const name = modelNameOf(model)
131
+ if (name === undefined && usage === undefined) {
132
+ return {}
133
+ }
134
+ return {
135
+ ...(name === undefined ? {} : { [attr.model]: name, [attr.oiModel]: name }),
136
+ ...(usage === undefined
137
+ ? {}
138
+ : {
139
+ [attr.inputTokens]: usage.prompt,
140
+ [attr.outputTokens]: usage.completion,
141
+ [attr.oiPrompt]: usage.prompt,
142
+ [attr.oiCompletion]: usage.completion,
143
+ [attr.oiTotal]: usage.total,
144
+ ...(usage.costUsd === undefined || estimated
145
+ ? {}
146
+ : { [attr.costUsd]: usage.costUsd, [attr.costSource]: "reported" })
147
+ }),
148
+ [attr.estimated]: estimated
149
+ }
150
+ }
package/src/Stories.ts CHANGED
@@ -74,6 +74,7 @@ import {
74
74
  type StoryRelation
75
75
  } from "./StoryPlan.ts"
76
76
  import { isOutageMessage } from "./TransientRetry.ts"
77
+ import { attr, withKindSpan } from "./Spans.ts"
77
78
 
78
79
  const join = (root: string, path: string): string =>
79
80
  `${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
@@ -1243,40 +1244,68 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1243
1244
  // rerun that finds the same diff gets the same answer without a
1244
1245
  // model call, and the last verdict is on disk to read. Only the
1245
1246
  // first round reuses it; a later round follows a revision and asks.
1246
- const judged =
1247
- empty && subject.trim().length === 0
1248
- ? // Nothing to judge is decided here, not asked of a model.
1249
- {
1250
- value: ReviewResult.make({
1251
- issues: [
1252
- ReviewIssue.make({
1253
- severity: "Critical",
1254
- title: `nothing exists yet under ${story.owned.join(", ")}`,
1255
- description: "The story's owned paths are empty on the epic branch."
1247
+ // The round is an EVALUATOR span (ADR 0026): the verdict's scores and
1248
+ // findings land on it, so a trace shows why a story was sent back.
1249
+ const judged = yield* withKindSpan(
1250
+ `story ${story.id}: judge ${round}`,
1251
+ { kind: "EVALUATOR" },
1252
+ Effect.gen(function* () {
1253
+ const outcome =
1254
+ empty && subject.trim().length === 0
1255
+ ? // Nothing to judge is decided here, not asked of a model.
1256
+ {
1257
+ value: ReviewResult.make({
1258
+ issues: [
1259
+ ReviewIssue.make({
1260
+ severity: "Critical",
1261
+ title: `nothing exists yet under ${story.owned.join(", ")}`,
1262
+ description: "The story's owned paths are empty on the epic branch."
1263
+ })
1264
+ ],
1265
+ summary: `judge:${story.id}`
1266
+ }),
1267
+ reused: false
1268
+ }
1269
+ : yield* cachedValue(
1270
+ files,
1271
+ judgePath(story),
1272
+ Verdict,
1273
+ fingerprintOf([
1274
+ empty ? "code" : "diff",
1275
+ subject,
1276
+ story.id,
1277
+ story.title,
1278
+ story.description,
1279
+ story.provides.join("\n")
1280
+ ]),
1281
+ withTimedRole(
1282
+ "judge",
1283
+ judge(story, subject, watchedSeats, empty ? "code" : "diff")
1284
+ ),
1285
+ { reuse: round === 1 }
1286
+ )
1287
+ const scored = outcome.value instanceof StoryVerdict ? outcome.value.dimensions : []
1288
+ yield* Effect.annotateCurrentSpan({
1289
+ "llm4ts.judge.cleared": outcome.value.isClean,
1290
+ "llm4ts.judge.reused": outcome.reused,
1291
+ ...Object.fromEntries(scored.map((d) => [`llm4ts.judge.${d.id}`, d.score]))
1292
+ })
1293
+ const now = yield* Clock.currentTimeNanos
1294
+ yield* Effect.option(Effect.currentSpan).pipe(
1295
+ Effect.map((span) => {
1296
+ if (span._tag === "Some") {
1297
+ for (const issue of outcome.value.issues) {
1298
+ span.value.event("judge finding", now, {
1299
+ title: issue.title,
1300
+ severity: issue.severity
1256
1301
  })
1257
- ],
1258
- summary: `judge:${story.id}`
1259
- }),
1260
- reused: false
1261
- }
1262
- : yield* cachedValue(
1263
- files,
1264
- judgePath(story),
1265
- Verdict,
1266
- fingerprintOf([
1267
- empty ? "code" : "diff",
1268
- subject,
1269
- story.id,
1270
- story.title,
1271
- story.description,
1272
- story.provides.join("\n")
1273
- ]),
1274
- withTimedRole(
1275
- "judge",
1276
- judge(story, subject, watchedSeats, empty ? "code" : "diff")
1277
- ),
1278
- { reuse: round === 1 }
1279
- )
1302
+ }
1303
+ }
1304
+ })
1305
+ )
1306
+ return outcome
1307
+ })
1308
+ )
1280
1309
  const verdict = judged.value
1281
1310
  if (judged.reused) {
1282
1311
  yield* laneOf(story).publish(
@@ -1390,8 +1419,33 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1390
1419
  })
1391
1420
 
1392
1421
  /** A story's run as a completion — failures become outcomes, never fiber deaths. */
1422
+ // One trace per story (ADR 0026): the story span roots it, linked to the
1423
+ // run's span, and every span under it — tasks, model calls, tools, gates —
1424
+ // carries the story, the epic and the run's session.
1425
+ const storySpanAttributes = (story: Story): Readonly<Record<string, unknown>> => ({
1426
+ [attr.story]: story.id,
1427
+ [attr.epic]: plan.epicId,
1428
+ ...(context.trace === undefined
1429
+ ? {}
1430
+ : { [attr.session]: context.trace.runId, [attr.run]: context.trace.runId })
1431
+ })
1432
+ const annotated = <A, E, R>(
1433
+ story: Story,
1434
+ effect: Effect.Effect<A, E, R>
1435
+ ): Effect.Effect<A, E, R> =>
1436
+ Object.entries(storySpanAttributes(story)).reduce(
1437
+ (acc, [key, value]) => Effect.annotateSpans(key, value)(acc),
1438
+ effect
1439
+ )
1393
1440
  const attempt = (story: Story): Effect.Effect<Completion> =>
1394
- stage(laneOf(story), `story ${story.id}`, runStory(story)).pipe(
1441
+ annotated(
1442
+ story,
1443
+ stage(laneOf(story), `story ${story.id}`, runStory(story), {
1444
+ kind: "AGENT",
1445
+ root: true,
1446
+ attributes: storySpanAttributes(story)
1447
+ })
1448
+ ).pipe(
1395
1449
  Effect.map((outcome): Completion => ({ story, outcome })),
1396
1450
  Effect.catch((error) =>
1397
1451
  Effect.gen(function* () {