@gr8ful/spf 0.18.0 → 0.19.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.
Files changed (50) hide show
  1. package/README.md +8 -0
  2. package/assets/skill/references/config.md +1 -0
  3. package/dist/cli/commands/doctor.js +25 -3
  4. package/dist/cli/commands/estimate.d.ts +22 -6
  5. package/dist/cli/commands/estimate.js +32 -10
  6. package/dist/cli/commands/loop.d.ts +20 -0
  7. package/dist/cli/commands/loop.js +20 -1
  8. package/dist/cli/commands/ui.js +2 -1
  9. package/dist/cli/commands/watch.js +1 -1
  10. package/dist/cli/index.js +2 -2
  11. package/dist/cli/interview.js +13 -0
  12. package/dist/cli/ui/run_dashboard.js +13 -7
  13. package/dist/core/agent_cc.d.ts +19 -3
  14. package/dist/core/agent_cc.js +38 -18
  15. package/dist/core/agent_flue.js +51 -14
  16. package/dist/core/agent_opencode.d.ts +62 -25
  17. package/dist/core/agent_opencode.js +71 -30
  18. package/dist/core/agents.d.ts +51 -4
  19. package/dist/core/agents.js +79 -4
  20. package/dist/core/console.d.ts +24 -4
  21. package/dist/core/console.js +20 -7
  22. package/dist/core/data_types.d.ts +300 -19
  23. package/dist/core/data_types.js +134 -5
  24. package/dist/core/issues/jira_provider.d.ts +58 -3
  25. package/dist/core/issues/jira_provider.js +89 -18
  26. package/dist/core/issues/markdown_adf.d.ts +58 -0
  27. package/dist/core/issues/markdown_adf.js +705 -0
  28. package/dist/core/issues/provider.d.ts +23 -0
  29. package/dist/core/loop.d.ts +39 -1
  30. package/dist/core/loop.js +33 -2
  31. package/dist/core/ollama_provider.d.ts +96 -13
  32. package/dist/core/ollama_provider.js +172 -26
  33. package/dist/core/otel.js +10 -1
  34. package/dist/core/otel_propagation.d.ts +168 -24
  35. package/dist/core/otel_propagation.js +219 -43
  36. package/dist/core/permissions.d.ts +16 -1
  37. package/dist/core/permissions.js +91 -3
  38. package/dist/core/providers.js +8 -3
  39. package/dist/core/refine.js +13 -1
  40. package/dist/core/runner.d.ts +33 -2
  41. package/dist/core/runner.js +40 -5
  42. package/dist/core/tiering.js +7 -3
  43. package/dist/core/tracer.d.ts +7 -1
  44. package/dist/core/tracer.js +15 -3
  45. package/dist/ui/server/db.d.ts +8 -1
  46. package/dist/ui/server/db.js +21 -4
  47. package/dist/ui/server/serve.d.ts +7 -0
  48. package/dist/ui/server/serve.js +10 -7
  49. package/dist/ui/shared/types.d.ts +16 -0
  50. package/package.json +3 -3
@@ -14,8 +14,11 @@
14
14
  * `-undici` create a real client span (with a real, non-noop SpanContext)
15
15
  * around every outbound `http`/`https`/`fetch`(undici) call made from this
16
16
  * process, and the OTel API's global propagator is what those
17
- * instrumentations use to inject `traceparent` (and, here, `x-request-id`)
18
- * into that call's headers REGARDLESS of which provider SDK issued it.
17
+ * instrumentations use to inject `traceparent` (and, here, the gateway's own
18
+ * `x-correlation-id`/`x-spf-agent`see GatewayHeadersPropagator below;
19
+ * NEVER `x-request-id`, which Envoy/Switchyard own end-to-end and which a
20
+ * client-sent value would corrupt) into that call's headers — REGARDLESS of
21
+ * which provider SDK issued it.
19
22
  * This reaches every provider whose Node SDK issues requests through
20
23
  * Node's own `http`/`https` modules or `undici` (verified: `fetch()`,
21
24
  * `https.request()`). It does NOT reach a provider transport that bypasses
@@ -58,7 +61,64 @@
58
61
  * agent processing", which is the id SPF mints and hands to
59
62
  * `init(SfAgent, { id })`. Extraction goes through the globally
60
63
  * registered propagator against ROOT_CONTEXT, so no leaked loop context
61
- * can stick. Consequences, all intended:
64
+ * can stick.
65
+ *
66
+ * BLOCKER 1 (fixed) — `resolveRootContext`'s return value NEVER becomes the
67
+ * active context. Verified against `@flue/opentelemetry`'s own dist
68
+ * (index.mjs:361 for the `chat <model>` span, :329 for the interceptor that
69
+ * activates it): the resolved `Context` is passed to `tracer.startSpan(...)`
70
+ * ONLY as `parentContext` — it supplies the new span's trace id/parent span
71
+ * id and is then discarded. What actually gets activated around the real
72
+ * dispatch is `context.with(trace.setSpan(context.active(), span), next)` —
73
+ * a context built from `context.active()` (whatever was active before,
74
+ * almost always ROOT) plus the freshly minted `span`, NOT the resolved
75
+ * context itself. A prior version of this module stashed the session's
76
+ * `adw_id`/`agent_name` as a plain context VALUE on the context
77
+ * `resolveFlueRootContext` returned (keyed under a private context key) and
78
+ * had `GatewayHeadersPropagator` read that value back at inject time — which
79
+ * can never work, because that exact context object is never the one made
80
+ * active; only its SpanContext (traceId/spanId/flags) survives, carried by
81
+ * the new span. Confirmed live: `scratchpad/probe_installed_real.mjs`,
82
+ * reproducing flue's exact two-line sequence against a real pi-ai dispatch,
83
+ * printed `x-correlation-id: ABSENT` / `x-spf-agent: ABSENT` before this fix.
84
+ *
85
+ * THE FIX: key the registration by TRACE ID instead of by context identity.
86
+ * `registerFlueSessionTrace` now ALSO records, in `traceIdRegistrations`,
87
+ * the trace id parsed out of the very `traceparent` it's given — the same
88
+ * deterministic trace id `resolveFlueRootContext` extracts and hands to
89
+ * `tracer.startSpan` as that span's parent, so the span it creates carries
90
+ * that exact trace id forward into whatever context DOES get activated.
91
+ * `GatewayHeadersPropagator.inject()` below reads `trace.getSpanContext(ctx)
92
+ * ?.traceId` — the thing that provably survives into the active context at
93
+ * request time — and looks IT UP in `traceIdRegistrations`, rather than
94
+ * trying to read a value off a context object that was never activated.
95
+ * One deterministic trace id per adw_id (`otel.ts:556`'s `traceIdFor(adwId)`,
96
+ * reused for EVERY agent call in that run — `otel.ts:737`'s
97
+ * `agentCallTraceContext` varies only the span id, never the trace id), and
98
+ * `traceIdRegistrations` is keyed on that trace id alone, not on session id
99
+ * or span id. So "latest registration for this trace id wins" is exact
100
+ * within a run when its agents run one at a time — which is how a `fanout`
101
+ * attempt's own agents run: `fanout.ts:448` derives each attempt its OWN
102
+ * adw_id, so distinct fanout attempts get distinct trace ids and cannot
103
+ * collide here, regardless of `fanout`'s concurrency. The one real collision
104
+ * this map can still see is narrower and does not happen in SPF today: TWO
105
+ * AGENTS OVERLAPPING INSIDE ONE adw_id — a single run dispatching a second
106
+ * flue agent call before the first one's `unregisterFlueSessionTrace` has
107
+ * run — since both share that run's one trace id, the second
108
+ * `registerFlueSessionTrace` call overwrites the first agent's entry in
109
+ * `traceIdRegistrations` while its dispatch may still be in flight, and that
110
+ * agent's outbound request would then carry the OTHER agent's
111
+ * `x-correlation-id`/`x-spf-agent` (never a wrong `adw_id`, since both
112
+ * belong to the same run — only the wrong `agentName`). No chain SPF ships
113
+ * dispatches two agents concurrently within one adw_id; this is flagged as
114
+ * the mechanism's honest limit, not a bug being carried forward. The SAME
115
+ * registration carries this session's `adw_id`/agent name (see
116
+ * `FlueSessionRegistration`) so a
117
+ * session's `x-correlation-id`/`x-spf-agent` ride the exact same "resolved
118
+ * once per submission, read per real HTTP call" path as `traceparent` does,
119
+ * via `GatewayHeadersPropagator` below, instead of the static-per-model-id
120
+ * fallback `ollama_provider.ts` needs when this pipeline isn't installed at
121
+ * all (otel unconfigured). Consequences, all intended:
62
122
  * - Flue's spans inherit SPF's sha256 trace id, parented under the
63
123
  * right agent-call span PER SESSION — correct under multiple agents
64
124
  * per process, concurrent agents, and claim-loop restarts alike. Span
@@ -66,17 +126,42 @@
66
126
  * - The http/undici client spans' injected `traceparent` carries the
67
127
  * deterministic id too, so Switchyard/vLLM hops land as descendants of
68
128
  * SPF's trace — parity with `claude_code`'s `ANTHROPIC_CUSTOM_HEADERS`
69
- * path. `x-request-id` stays the this-span id for request-keyed
70
- * correlation, unchanged.
129
+ * path. The same per-request injection point also carries THIS
130
+ * session's `x-correlation-id`/`x-spf-agent`, correctly attributed even
131
+ * when multiple sessions are concurrent in one process, AS LONG AS their
132
+ * deterministic trace ids differ (their `ctx.id`s, and therefore their
133
+ * registrations, are distinct) — see BLOCKER 1 above for the precise
134
+ * lookup key.
71
135
  * - Unmapped sessions (never registered, restarted process with a
72
136
  * durable backlog, post-`unregister` straggler bookkeeping spans)
73
137
  * resolve to an unparented root — flue's spans root a separate SDK
74
- * trace exactly as v1 did, correlatable by `x-request-id`/`spf.adw_id`/
75
- * time window. Degraded join, never an error and never MIS-attributed.
138
+ * trace exactly as v1 did, correlatable by `spf.adw_id`/time window.
139
+ * Degraded join, never an error and never MIS-attributed; the gateway
140
+ * headers for exactly this case are simply ABSENT (no fallback identity
141
+ * — see `GatewayHeadersPropagator.inject()` below) rather than guessing
142
+ * at whichever session happened to register most recently.
76
143
  * - flue's internal `executionContext.traceCarrier` (typed but not on
77
144
  * the public `AgentDispatchRequest` surface) stays unused — noted here
78
145
  * as flue's own escape hatch, not something SPF reaches into.
79
146
  *
147
+ * STATIC HEADERS STAY SUPPRESSED WHEN INSTALLED (BLOCKER 1's other half,
148
+ * decided AGAINST enabling): `ollama_provider.ts`'s `modelFor()` keeps
149
+ * omitting `Model.headers`' `x-correlation-id`/`x-spf-agent` whenever
150
+ * `isFluePropagationInstalled()` is true — it is NOT also stamped as a
151
+ * baseline alongside this propagator. Measured live
152
+ * (`scratchpad/probe_duplicate_header.mjs`, against the REAL
153
+ * `@opentelemetry/instrumentation-undici` used here): `request.addHeader(k,
154
+ * v)` — what that instrumentation calls for every header
155
+ * `propagation.inject()` returns — APPENDS a second raw header line rather
156
+ * than replacing one already present (undici's own `Request.addHeader` has
157
+ * no dedupe-by-name step); the receiving `http.IncomingMessage.headers`
158
+ * then comma-joins the two into one corrupted value
159
+ * (`"FROM_STATIC, FROM_PROPAGATOR"`), and `req.rawHeaders` shows the literal
160
+ * duplicate line. So when propagation is installed, THIS propagator is the
161
+ * sole source of `x-correlation-id`/`x-spf-agent` — never doubled up with a
162
+ * static value from `Model.headers` — proven on the wire by
163
+ * `src/test/ollama_gateway_e2e.test.ts`'s "no duplicate headers" test.
164
+ *
80
165
  * REGISTRATION TIMING. `installFluePropagation()` is called from
81
166
  * `agent_flue.ts`'s `run()`, before `ensureRuntime()`/dispatch — i.e. before
82
167
  * the actual outbound call, which is the only ordering that matters for
@@ -98,33 +183,73 @@
98
183
  * agent dispatches this process makes) happens to arrive first.
99
184
  */
100
185
  import { type Context, type TextMapPropagator, type TextMapSetter } from "@opentelemetry/api";
101
- /** The one custom propagation field this repo adds beyond the standard W3C `traceparent`: the current span's own id, for a collector/log pipeline that correlates by request rather than by trace. */
102
- export declare const X_REQUEST_ID_HEADER = "x-request-id";
103
186
  /**
104
- * Injects `x-request-id` from whatever span is active at the point of the
105
- * outbound call the same id that would appear as that span's `spanId` on
106
- * the (separate — see the module header) Flue-side trace. One-directional:
107
- * `extract()` is a pass-through, since nothing on the INBOUND side of an
108
- * outbound provider call needs to read this back.
187
+ * The Briefs gateway's own correlation headers see `ollama_provider.ts`'s
188
+ * "Gateway headers" section for the full three-header contract (the third,
189
+ * `traceparent`, is the W3C standard one `W3CTraceContextPropagator` already
190
+ * injects). Defined here (not in `ollama_provider.ts`) because
191
+ * `GatewayHeadersPropagator` below is the thing that actually injects them
192
+ * onto the wire when this pipeline is installed; `ollama_provider.ts`
193
+ * imports these two constants rather than redeclaring them, so there is
194
+ * exactly one spelling of each header name in the codebase.
195
+ */
196
+ export declare const X_CORRELATION_ID_HEADER = "x-correlation-id";
197
+ export declare const X_SPF_AGENT_HEADER = "x-spf-agent";
198
+ /**
199
+ * One flue session's (`ctx.id`'s) registration: the W3C `traceparent` string
200
+ * for SPF's deterministic agent-call span (see `registerFlueSessionTrace`),
201
+ * plus this session's `adw_id`/agent name — carried the SAME way, see the
202
+ * module header's FLUE SPANS JOIN... section for why both ride one
203
+ * registration rather than two separate maps.
204
+ */
205
+ export interface FlueSessionRegistration {
206
+ traceparent: string;
207
+ adwId?: string;
208
+ agentName?: string;
209
+ }
210
+ /**
211
+ * Registers `registration` (SPF's deterministic agent-call traceparent, plus
212
+ * this session's adw_id/agent name — see `FlueSessionRegistration`) as the
213
+ * trace root for flue spans belonging to `sessionId` — flue's instance id,
214
+ * minted by SPF and handed to `init(SfAgent, { id })`. Overwrites a prior
215
+ * registration for the same id (a same-phase retry is the same logical call;
216
+ * the current call wins). Also indexes the SAME identity under this
217
+ * registration's parsed trace id (`traceIdRegistrations`) — see the module
218
+ * header's BLOCKER 1 section and that map's own doc for why.
109
219
  */
110
- export declare class XRequestIdPropagator implements TextMapPropagator {
220
+ export declare function registerFlueSessionTrace(sessionId: string, registration: FlueSessionRegistration): void;
221
+ /**
222
+ * Injects `x-correlation-id`/`x-spf-agent` by looking up the ACTIVE
223
+ * `Context`'s SpanContext trace id (`trace.getSpanContext(ctx)?.traceId`) in
224
+ * `traceIdRegistrations` — the same per-real-HTTP-call injection point
225
+ * `W3CTraceContextPropagator` uses for `traceparent`, and, critically, keyed
226
+ * on the one piece of `resolveFlueRootContext`'s return value that provably
227
+ * survives into that active context (see the module header's BLOCKER 1
228
+ * section for why a context VALUE does not). Injects NOTHING — no fallback,
229
+ * no last-known identity — when this exact trace id is not in the map: an
230
+ * unregistered/unmapped span (flue's own bookkeeping spans, or ANY span
231
+ * after every session has been unregistered) must not stamp a stale or
232
+ * unrelated session's `x-correlation-id`/`x-spf-agent` onto unrelated
233
+ * outbound traffic (a third-party call, a post-session straggler). A true
234
+ * no-op (nothing set) in that case — never throws, never invents a value.
235
+ * Replaces the old `XRequestIdPropagator`:
236
+ * `x-request-id` must NEVER reach the gateway (Envoy/Switchyard own it
237
+ * end-to-end; a client-sent value corrupts their own sampling) and this repo
238
+ * no longer has anything that wants it emitted.
239
+ */
240
+ export declare class GatewayHeadersPropagator implements TextMapPropagator {
111
241
  inject(ctx: Context, carrier: unknown, setter: TextMapSetter): void;
112
242
  extract(ctx: Context): Context;
113
243
  fields(): string[];
114
244
  }
115
- /**
116
- * Registers `traceparent` (SPF's deterministic agent-call span, as a W3C
117
- * carrier string) as the trace root for flue spans belonging to `sessionId`
118
- * — flue's instance id, minted by SPF and handed to `init(SfAgent, { id })`.
119
- * Overwrites a prior registration for the same id (a same-phase retry is
120
- * the same logical call; the current call wins).
121
- */
122
- export declare function registerFlueSessionTrace(sessionId: string, traceparent: string): void;
123
245
  /**
124
246
  * Idempotent — called from `run()`'s `finally`. Post-settlement bookkeeping
125
247
  * spans flue mints after this point simply resolve to an unparented root
126
248
  * (separate trace), which is preferable to leaking a registration whose id
127
- * a REUSED session id could collide with on a later phase.
249
+ * a REUSED session id could collide with on a later phase. Also prunes this
250
+ * session's entry out of `traceIdRegistrations` (parsed fresh from the
251
+ * departing registration's own `traceparent`, so it removes exactly the
252
+ * entry this session added.
128
253
  */
129
254
  export declare function unregisterFlueSessionTrace(sessionId: string): void;
130
255
  /**
@@ -137,6 +262,13 @@ export declare function unregisterFlueSessionTrace(sessionId: string): void;
137
262
  * unparented root span of its own) for an unmapped session id, a malformed
138
263
  * traceparent, an absent ctx — and, with no global propagator installed,
139
264
  * for everything. Exported for tests.
265
+ *
266
+ * Used by `@flue/opentelemetry` ONLY as `tracer.startSpan`'s parent
267
+ * argument (see the module header's BLOCKER 1 section) — the value handed
268
+ * back here is never itself activated, so it carries no context VALUES for
269
+ * `GatewayHeadersPropagator` to read; `x-correlation-id`/`x-spf-agent` are
270
+ * instead looked up by trace id, via `traceIdRegistrations`, which
271
+ * `registerFlueSessionTrace` populates independently of this function.
140
272
  */
141
273
  export declare function resolveFlueRootContext(_event: unknown, ctx: {
142
274
  id?: string;
@@ -157,3 +289,15 @@ export interface FluePropagationConfig {
157
289
  export declare function installFluePropagation(cfg: FluePropagationConfig | undefined | null, log?: (message: string) => void): void;
158
290
  /** Tests only: forget global installation state. Does NOT undo `setGlobalTracerProvider`/`registerInstrumentations` (the OTel API has no supported "un-register" — tests that need isolation run in a fresh process). */
159
291
  export declare function resetFluePropagationForTest(): void;
292
+ /**
293
+ * Whether `installFluePropagation` has actually installed the global
294
+ * TracerProvider/propagator/instrumentations in THIS process. Consulted by
295
+ * `ollama_provider.ts` (see its "Gateway headers" section) so `resolve()`/
296
+ * `modelFor()` know whether the instrumentation above already covers
297
+ * `traceparent`/`x-correlation-id`/`x-spf-agent` per real outbound call —
298
+ * supplying any of the three a second way when this is `true` would double
299
+ * it up on the wire (`UndiciInstrumentation` appends via `addHeader`, it
300
+ * does not replace). `false` — the common case, otel unconfigured — means
301
+ * `ollama_provider.ts` must supply all three itself.
302
+ */
303
+ export declare function isFluePropagationInstalled(): boolean;
@@ -14,8 +14,11 @@
14
14
  * `-undici` create a real client span (with a real, non-noop SpanContext)
15
15
  * around every outbound `http`/`https`/`fetch`(undici) call made from this
16
16
  * process, and the OTel API's global propagator is what those
17
- * instrumentations use to inject `traceparent` (and, here, `x-request-id`)
18
- * into that call's headers REGARDLESS of which provider SDK issued it.
17
+ * instrumentations use to inject `traceparent` (and, here, the gateway's own
18
+ * `x-correlation-id`/`x-spf-agent`see GatewayHeadersPropagator below;
19
+ * NEVER `x-request-id`, which Envoy/Switchyard own end-to-end and which a
20
+ * client-sent value would corrupt) into that call's headers — REGARDLESS of
21
+ * which provider SDK issued it.
19
22
  * This reaches every provider whose Node SDK issues requests through
20
23
  * Node's own `http`/`https` modules or `undici` (verified: `fetch()`,
21
24
  * `https.request()`). It does NOT reach a provider transport that bypasses
@@ -58,7 +61,64 @@
58
61
  * agent processing", which is the id SPF mints and hands to
59
62
  * `init(SfAgent, { id })`. Extraction goes through the globally
60
63
  * registered propagator against ROOT_CONTEXT, so no leaked loop context
61
- * can stick. Consequences, all intended:
64
+ * can stick.
65
+ *
66
+ * BLOCKER 1 (fixed) — `resolveRootContext`'s return value NEVER becomes the
67
+ * active context. Verified against `@flue/opentelemetry`'s own dist
68
+ * (index.mjs:361 for the `chat <model>` span, :329 for the interceptor that
69
+ * activates it): the resolved `Context` is passed to `tracer.startSpan(...)`
70
+ * ONLY as `parentContext` — it supplies the new span's trace id/parent span
71
+ * id and is then discarded. What actually gets activated around the real
72
+ * dispatch is `context.with(trace.setSpan(context.active(), span), next)` —
73
+ * a context built from `context.active()` (whatever was active before,
74
+ * almost always ROOT) plus the freshly minted `span`, NOT the resolved
75
+ * context itself. A prior version of this module stashed the session's
76
+ * `adw_id`/`agent_name` as a plain context VALUE on the context
77
+ * `resolveFlueRootContext` returned (keyed under a private context key) and
78
+ * had `GatewayHeadersPropagator` read that value back at inject time — which
79
+ * can never work, because that exact context object is never the one made
80
+ * active; only its SpanContext (traceId/spanId/flags) survives, carried by
81
+ * the new span. Confirmed live: `scratchpad/probe_installed_real.mjs`,
82
+ * reproducing flue's exact two-line sequence against a real pi-ai dispatch,
83
+ * printed `x-correlation-id: ABSENT` / `x-spf-agent: ABSENT` before this fix.
84
+ *
85
+ * THE FIX: key the registration by TRACE ID instead of by context identity.
86
+ * `registerFlueSessionTrace` now ALSO records, in `traceIdRegistrations`,
87
+ * the trace id parsed out of the very `traceparent` it's given — the same
88
+ * deterministic trace id `resolveFlueRootContext` extracts and hands to
89
+ * `tracer.startSpan` as that span's parent, so the span it creates carries
90
+ * that exact trace id forward into whatever context DOES get activated.
91
+ * `GatewayHeadersPropagator.inject()` below reads `trace.getSpanContext(ctx)
92
+ * ?.traceId` — the thing that provably survives into the active context at
93
+ * request time — and looks IT UP in `traceIdRegistrations`, rather than
94
+ * trying to read a value off a context object that was never activated.
95
+ * One deterministic trace id per adw_id (`otel.ts:556`'s `traceIdFor(adwId)`,
96
+ * reused for EVERY agent call in that run — `otel.ts:737`'s
97
+ * `agentCallTraceContext` varies only the span id, never the trace id), and
98
+ * `traceIdRegistrations` is keyed on that trace id alone, not on session id
99
+ * or span id. So "latest registration for this trace id wins" is exact
100
+ * within a run when its agents run one at a time — which is how a `fanout`
101
+ * attempt's own agents run: `fanout.ts:448` derives each attempt its OWN
102
+ * adw_id, so distinct fanout attempts get distinct trace ids and cannot
103
+ * collide here, regardless of `fanout`'s concurrency. The one real collision
104
+ * this map can still see is narrower and does not happen in SPF today: TWO
105
+ * AGENTS OVERLAPPING INSIDE ONE adw_id — a single run dispatching a second
106
+ * flue agent call before the first one's `unregisterFlueSessionTrace` has
107
+ * run — since both share that run's one trace id, the second
108
+ * `registerFlueSessionTrace` call overwrites the first agent's entry in
109
+ * `traceIdRegistrations` while its dispatch may still be in flight, and that
110
+ * agent's outbound request would then carry the OTHER agent's
111
+ * `x-correlation-id`/`x-spf-agent` (never a wrong `adw_id`, since both
112
+ * belong to the same run — only the wrong `agentName`). No chain SPF ships
113
+ * dispatches two agents concurrently within one adw_id; this is flagged as
114
+ * the mechanism's honest limit, not a bug being carried forward. The SAME
115
+ * registration carries this session's `adw_id`/agent name (see
116
+ * `FlueSessionRegistration`) so a
117
+ * session's `x-correlation-id`/`x-spf-agent` ride the exact same "resolved
118
+ * once per submission, read per real HTTP call" path as `traceparent` does,
119
+ * via `GatewayHeadersPropagator` below, instead of the static-per-model-id
120
+ * fallback `ollama_provider.ts` needs when this pipeline isn't installed at
121
+ * all (otel unconfigured). Consequences, all intended:
62
122
  * - Flue's spans inherit SPF's sha256 trace id, parented under the
63
123
  * right agent-call span PER SESSION — correct under multiple agents
64
124
  * per process, concurrent agents, and claim-loop restarts alike. Span
@@ -66,17 +126,42 @@
66
126
  * - The http/undici client spans' injected `traceparent` carries the
67
127
  * deterministic id too, so Switchyard/vLLM hops land as descendants of
68
128
  * SPF's trace — parity with `claude_code`'s `ANTHROPIC_CUSTOM_HEADERS`
69
- * path. `x-request-id` stays the this-span id for request-keyed
70
- * correlation, unchanged.
129
+ * path. The same per-request injection point also carries THIS
130
+ * session's `x-correlation-id`/`x-spf-agent`, correctly attributed even
131
+ * when multiple sessions are concurrent in one process, AS LONG AS their
132
+ * deterministic trace ids differ (their `ctx.id`s, and therefore their
133
+ * registrations, are distinct) — see BLOCKER 1 above for the precise
134
+ * lookup key.
71
135
  * - Unmapped sessions (never registered, restarted process with a
72
136
  * durable backlog, post-`unregister` straggler bookkeeping spans)
73
137
  * resolve to an unparented root — flue's spans root a separate SDK
74
- * trace exactly as v1 did, correlatable by `x-request-id`/`spf.adw_id`/
75
- * time window. Degraded join, never an error and never MIS-attributed.
138
+ * trace exactly as v1 did, correlatable by `spf.adw_id`/time window.
139
+ * Degraded join, never an error and never MIS-attributed; the gateway
140
+ * headers for exactly this case are simply ABSENT (no fallback identity
141
+ * — see `GatewayHeadersPropagator.inject()` below) rather than guessing
142
+ * at whichever session happened to register most recently.
76
143
  * - flue's internal `executionContext.traceCarrier` (typed but not on
77
144
  * the public `AgentDispatchRequest` surface) stays unused — noted here
78
145
  * as flue's own escape hatch, not something SPF reaches into.
79
146
  *
147
+ * STATIC HEADERS STAY SUPPRESSED WHEN INSTALLED (BLOCKER 1's other half,
148
+ * decided AGAINST enabling): `ollama_provider.ts`'s `modelFor()` keeps
149
+ * omitting `Model.headers`' `x-correlation-id`/`x-spf-agent` whenever
150
+ * `isFluePropagationInstalled()` is true — it is NOT also stamped as a
151
+ * baseline alongside this propagator. Measured live
152
+ * (`scratchpad/probe_duplicate_header.mjs`, against the REAL
153
+ * `@opentelemetry/instrumentation-undici` used here): `request.addHeader(k,
154
+ * v)` — what that instrumentation calls for every header
155
+ * `propagation.inject()` returns — APPENDS a second raw header line rather
156
+ * than replacing one already present (undici's own `Request.addHeader` has
157
+ * no dedupe-by-name step); the receiving `http.IncomingMessage.headers`
158
+ * then comma-joins the two into one corrupted value
159
+ * (`"FROM_STATIC, FROM_PROPAGATOR"`), and `req.rawHeaders` shows the literal
160
+ * duplicate line. So when propagation is installed, THIS propagator is the
161
+ * sole source of `x-correlation-id`/`x-spf-agent` — never doubled up with a
162
+ * static value from `Model.headers` — proven on the wire by
163
+ * `src/test/ollama_gateway_e2e.test.ts`'s "no duplicate headers" test.
164
+ *
80
165
  * REGISTRATION TIMING. `installFluePropagation()` is called from
81
166
  * `agent_flue.ts`'s `run()`, before `ensureRuntime()`/dispatch — i.e. before
82
167
  * the actual outbound call, which is the only ordering that matters for
@@ -108,56 +193,124 @@ import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trac
108
193
  import { createOpenTelemetryInstrumentation } from "@flue/opentelemetry";
109
194
  import { instrument } from "@flue/runtime";
110
195
  import { resolveTracesUrl } from "./otel.js";
111
- /** The one custom propagation field this repo adds beyond the standard W3C `traceparent`: the current span's own id, for a collector/log pipeline that correlates by request rather than by trace. */
112
- export const X_REQUEST_ID_HEADER = "x-request-id";
113
196
  /**
114
- * Injects `x-request-id` from whatever span is active at the point of the
115
- * outbound call the same id that would appear as that span's `spanId` on
116
- * the (separate — see the module header) Flue-side trace. One-directional:
117
- * `extract()` is a pass-through, since nothing on the INBOUND side of an
118
- * outbound provider call needs to read this back.
197
+ * The Briefs gateway's own correlation headers see `ollama_provider.ts`'s
198
+ * "Gateway headers" section for the full three-header contract (the third,
199
+ * `traceparent`, is the W3C standard one `W3CTraceContextPropagator` already
200
+ * injects). Defined here (not in `ollama_provider.ts`) because
201
+ * `GatewayHeadersPropagator` below is the thing that actually injects them
202
+ * onto the wire when this pipeline is installed; `ollama_provider.ts`
203
+ * imports these two constants rather than redeclaring them, so there is
204
+ * exactly one spelling of each header name in the codebase.
205
+ */
206
+ export const X_CORRELATION_ID_HEADER = "x-correlation-id";
207
+ export const X_SPF_AGENT_HEADER = "x-spf-agent";
208
+ /**
209
+ * Instance-id -> registration backing `resolveFlueRootContext` — see the
210
+ * module header for the full mechanism and WHY this is a map consulted per
211
+ * span rather than a dispatch-time context wrap (flue's single claim loop
212
+ * makes the latter mis-attribute every agent after the first). One entry per
213
+ * in-flight `agent_flue.run()` call.
214
+ */
215
+ const flueSessionTraces = new Map();
216
+ /**
217
+ * Trace id -> gateway identity, keyed on the deterministic trace id parsed
218
+ * out of a registration's OWN `traceparent` (see `traceIdFromTraceparent`) —
219
+ * see the module header's BLOCKER 1 section for why this, and not a context
220
+ * VALUE, is the lookup `GatewayHeadersPropagator` uses. Populated by
221
+ * `registerFlueSessionTrace`, pruned by `unregisterFlueSessionTrace`. A
222
+ * trace id collision here (two DIFFERENT registrations sharing one trace id)
223
+ * is not expected in practice — trace ids are SPF's deterministic per-adw_id
224
+ * ids — but if it ever happened, the later `registerFlueSessionTrace` call
225
+ * would simply win, matching this map's own "current call wins" rule.
119
226
  */
120
- export class XRequestIdPropagator {
227
+ const traceIdRegistrations = new Map();
228
+ /**
229
+ * Parses the W3C `traceId` segment out of a `traceparent` string via a real
230
+ * `propagation.extract`/`getSpanContext` round trip — the SAME extraction
231
+ * `resolveFlueRootContext` performs, so "the trace id we index
232
+ * `traceIdRegistrations` under" and "the trace id a span parented via this
233
+ * `traceparent` actually carries" are provably the same value, not two
234
+ * independent parses that could drift. Returns `undefined` for a malformed
235
+ * `traceparent` (mirrors `resolveFlueRootContext`'s own malformed-input
236
+ * handling) rather than throwing.
237
+ */
238
+ function traceIdFromTraceparent(traceparent) {
239
+ const extracted = propagation.extract(ROOT_CONTEXT, { traceparent }, defaultTextMapGetter);
240
+ const spanContext = traceApi.getSpanContext(extracted);
241
+ return spanContext && isSpanContextValid(spanContext) ? spanContext.traceId : undefined;
242
+ }
243
+ /**
244
+ * Registers `registration` (SPF's deterministic agent-call traceparent, plus
245
+ * this session's adw_id/agent name — see `FlueSessionRegistration`) as the
246
+ * trace root for flue spans belonging to `sessionId` — flue's instance id,
247
+ * minted by SPF and handed to `init(SfAgent, { id })`. Overwrites a prior
248
+ * registration for the same id (a same-phase retry is the same logical call;
249
+ * the current call wins). Also indexes the SAME identity under this
250
+ * registration's parsed trace id (`traceIdRegistrations`) — see the module
251
+ * header's BLOCKER 1 section and that map's own doc for why.
252
+ */
253
+ export function registerFlueSessionTrace(sessionId, registration) {
254
+ flueSessionTraces.set(sessionId, registration);
255
+ const identity = { adwId: registration.adwId, agentName: registration.agentName };
256
+ const traceId = traceIdFromTraceparent(registration.traceparent);
257
+ if (traceId)
258
+ traceIdRegistrations.set(traceId, identity);
259
+ }
260
+ /**
261
+ * Injects `x-correlation-id`/`x-spf-agent` by looking up the ACTIVE
262
+ * `Context`'s SpanContext trace id (`trace.getSpanContext(ctx)?.traceId`) in
263
+ * `traceIdRegistrations` — the same per-real-HTTP-call injection point
264
+ * `W3CTraceContextPropagator` uses for `traceparent`, and, critically, keyed
265
+ * on the one piece of `resolveFlueRootContext`'s return value that provably
266
+ * survives into that active context (see the module header's BLOCKER 1
267
+ * section for why a context VALUE does not). Injects NOTHING — no fallback,
268
+ * no last-known identity — when this exact trace id is not in the map: an
269
+ * unregistered/unmapped span (flue's own bookkeeping spans, or ANY span
270
+ * after every session has been unregistered) must not stamp a stale or
271
+ * unrelated session's `x-correlation-id`/`x-spf-agent` onto unrelated
272
+ * outbound traffic (a third-party call, a post-session straggler). A true
273
+ * no-op (nothing set) in that case — never throws, never invents a value.
274
+ * Replaces the old `XRequestIdPropagator`:
275
+ * `x-request-id` must NEVER reach the gateway (Envoy/Switchyard own it
276
+ * end-to-end; a client-sent value corrupts their own sampling) and this repo
277
+ * no longer has anything that wants it emitted.
278
+ */
279
+ export class GatewayHeadersPropagator {
121
280
  inject(ctx, carrier, setter) {
122
281
  const spanContext = traceApi.getSpanContext(ctx);
123
- if (!spanContext || !isSpanContextValid(spanContext))
282
+ const registration = spanContext && traceIdRegistrations.get(spanContext.traceId);
283
+ if (!registration)
124
284
  return;
125
- setter.set(carrier, X_REQUEST_ID_HEADER, spanContext.spanId);
285
+ if (registration.adwId)
286
+ setter.set(carrier, X_CORRELATION_ID_HEADER, registration.adwId);
287
+ if (registration.agentName)
288
+ setter.set(carrier, X_SPF_AGENT_HEADER, registration.agentName);
126
289
  }
127
290
  extract(ctx) {
128
291
  return ctx;
129
292
  }
130
293
  fields() {
131
- return [X_REQUEST_ID_HEADER];
294
+ return [X_CORRELATION_ID_HEADER, X_SPF_AGENT_HEADER];
132
295
  }
133
296
  }
134
- /**
135
- * Instance-id -> agent-call traceparent registrations backing
136
- * `resolveFlueRootContext` — see the module header for the full mechanism
137
- * and WHY this is a map consulted per span rather than a dispatch-time
138
- * context wrap (flue's single claim loop makes the latter mis-attribute
139
- * every agent after the first). One entry per in-flight `agent_flue.run()`
140
- * call.
141
- */
142
- const flueSessionTraces = new Map();
143
- /**
144
- * Registers `traceparent` (SPF's deterministic agent-call span, as a W3C
145
- * carrier string) as the trace root for flue spans belonging to `sessionId`
146
- * — flue's instance id, minted by SPF and handed to `init(SfAgent, { id })`.
147
- * Overwrites a prior registration for the same id (a same-phase retry is
148
- * the same logical call; the current call wins).
149
- */
150
- export function registerFlueSessionTrace(sessionId, traceparent) {
151
- flueSessionTraces.set(sessionId, traceparent);
152
- }
153
297
  /**
154
298
  * Idempotent — called from `run()`'s `finally`. Post-settlement bookkeeping
155
299
  * spans flue mints after this point simply resolve to an unparented root
156
300
  * (separate trace), which is preferable to leaking a registration whose id
157
- * a REUSED session id could collide with on a later phase.
301
+ * a REUSED session id could collide with on a later phase. Also prunes this
302
+ * session's entry out of `traceIdRegistrations` (parsed fresh from the
303
+ * departing registration's own `traceparent`, so it removes exactly the
304
+ * entry this session added.
158
305
  */
159
306
  export function unregisterFlueSessionTrace(sessionId) {
307
+ const registration = flueSessionTraces.get(sessionId);
160
308
  flueSessionTraces.delete(sessionId);
309
+ if (!registration)
310
+ return;
311
+ const traceId = traceIdFromTraceparent(registration.traceparent);
312
+ if (traceId)
313
+ traceIdRegistrations.delete(traceId);
161
314
  }
162
315
  /**
163
316
  * The `resolveRootContext` implementation handed to
@@ -169,14 +322,23 @@ export function unregisterFlueSessionTrace(sessionId) {
169
322
  * unparented root span of its own) for an unmapped session id, a malformed
170
323
  * traceparent, an absent ctx — and, with no global propagator installed,
171
324
  * for everything. Exported for tests.
325
+ *
326
+ * Used by `@flue/opentelemetry` ONLY as `tracer.startSpan`'s parent
327
+ * argument (see the module header's BLOCKER 1 section) — the value handed
328
+ * back here is never itself activated, so it carries no context VALUES for
329
+ * `GatewayHeadersPropagator` to read; `x-correlation-id`/`x-spf-agent` are
330
+ * instead looked up by trace id, via `traceIdRegistrations`, which
331
+ * `registerFlueSessionTrace` populates independently of this function.
172
332
  */
173
333
  export function resolveFlueRootContext(_event, ctx) {
174
- const traceparent = ctx?.id ? flueSessionTraces.get(ctx.id) : undefined;
175
- if (!traceparent)
334
+ const registration = ctx?.id ? flueSessionTraces.get(ctx.id) : undefined;
335
+ if (!registration)
176
336
  return undefined;
177
- const extracted = propagation.extract(ROOT_CONTEXT, { traceparent }, defaultTextMapGetter);
337
+ const extracted = propagation.extract(ROOT_CONTEXT, { traceparent: registration.traceparent }, defaultTextMapGetter);
178
338
  const spanContext = traceApi.getSpanContext(extracted);
179
- return spanContext && isSpanContextValid(spanContext) ? extracted : undefined;
339
+ if (!spanContext || !isSpanContextValid(spanContext))
340
+ return undefined;
341
+ return extracted;
180
342
  }
181
343
  let installed = false;
182
344
  let flueInstrumented = false;
@@ -200,7 +362,7 @@ export function installFluePropagation(cfg, log = (m) => console.error(m)) {
200
362
  });
201
363
  traceApi.setGlobalTracerProvider(provider);
202
364
  contextApi.setGlobalContextManager(new AsyncHooksContextManager().enable());
203
- propagation.setGlobalPropagator(new CompositePropagator({ propagators: [new W3CTraceContextPropagator(), new XRequestIdPropagator()] }));
365
+ propagation.setGlobalPropagator(new CompositePropagator({ propagators: [new W3CTraceContextPropagator(), new GatewayHeadersPropagator()] }));
204
366
  registerInstrumentations({ instrumentations: [new HttpInstrumentation(), new UndiciInstrumentation()] });
205
367
  // Set AFTER the fallible registrations above: on a construction-time
206
368
  // throw, the next call must be free to retry — latching `installed`
@@ -223,3 +385,17 @@ export function resetFluePropagationForTest() {
223
385
  installed = false;
224
386
  flueInstrumented = false;
225
387
  }
388
+ /**
389
+ * Whether `installFluePropagation` has actually installed the global
390
+ * TracerProvider/propagator/instrumentations in THIS process. Consulted by
391
+ * `ollama_provider.ts` (see its "Gateway headers" section) so `resolve()`/
392
+ * `modelFor()` know whether the instrumentation above already covers
393
+ * `traceparent`/`x-correlation-id`/`x-spf-agent` per real outbound call —
394
+ * supplying any of the three a second way when this is `true` would double
395
+ * it up on the wire (`UndiciInstrumentation` appends via `addHeader`, it
396
+ * does not replace). `false` — the common case, otel unconfigured — means
397
+ * `ollama_provider.ts` must supply all three itself.
398
+ */
399
+ export function isFluePropagationInstalled() {
400
+ return installed;
401
+ }
@@ -73,6 +73,21 @@ export declare function permitted(p: string, agent: AgentConfig, cfg: SFConfig):
73
73
  * Detection alone would leave the repo holding the unauthorized change while
74
74
  * reporting a failure, so anything the agent introduced outside its allowlist
75
75
  * is rolled back before the phase dies. What it cannot undo, it names.
76
+ *
77
+ * `defaults.read_only_ignore` carves out one exception to "names, fails" —
78
+ * but only where `isSafeToIgnore` says restoring is actually possible (see
79
+ * its own doc comment): a TRUE read-only agent (`writes: []`) that churned
80
+ * a lockfile that was CLEAN before this phase. That one case is STILL
81
+ * rolled back unconditionally like any other breach — an ignored path is
82
+ * never left standing — it just does not, on its own, fail the phase.
83
+ * Everything else `isIgnorableChurn` alone would have matched (a dirty-before
84
+ * path, an agent that reverted uncommitted work, a write-restricted rather
85
+ * than read-only agent) falls through to the real-breach path below instead.
86
+ * `onIgnored`, when given, is called once with every safely-ignored path
87
+ * before returning, so a caller with a logger (`agents.ts`'s `execute()`)
88
+ * can print the one required info line — this module has no logger of its
89
+ * own to call (`RunLike` is only `repo_root`/`cfg`), so the caller does the
90
+ * printing.
76
91
  */
77
- export declare function enforce(run: RunLike, _phase: unknown, agent: AgentConfig, before: Record<string, string>): string[];
92
+ export declare function enforce(run: RunLike, _phase: unknown, agent: AgentConfig, before: Record<string, string>, onIgnored?: (paths: string[]) => void): string[];
78
93
  export {};