@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/dist/PlanExecution.d.ts +7 -1
- package/dist/PlanExecution.d.ts.map +1 -1
- package/dist/PlanExecution.js +8 -2
- package/dist/PlanExecution.js.map +1 -1
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +5 -3
- package/dist/Review.js.map +1 -1
- package/dist/Spans.d.ts +63 -0
- package/dist/Spans.d.ts.map +1 -0
- package/dist/Spans.js +104 -0
- package/dist/Spans.js.map +1 -0
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +61 -23
- package/dist/Stories.js.map +1 -1
- package/dist/Timing.d.ts +21 -1
- package/dist/Timing.d.ts.map +1 -1
- package/dist/Timing.js +218 -18
- package/dist/Timing.js.map +1 -1
- package/package.json +3 -2
- package/src/PlanExecution.ts +21 -10
- package/src/Review.ts +18 -12
- package/src/Spans.ts +150 -0
- package/src/Stories.ts +88 -34
- package/src/Timing.ts +343 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llm4ts/flow",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
116
|
+
"@llm4ts/core": "2.30.1"
|
|
116
117
|
},
|
|
117
118
|
"peerDependencies": {
|
|
118
119
|
"effect": "4.0.0"
|
package/src/PlanExecution.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
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
|
-
|
|
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* () {
|