@inflexa-ai/harness 0.35.0 → 0.35.2

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/README.md CHANGED
@@ -9,6 +9,7 @@ Requirements:
9
9
  - Node.js `>=24` (the runtime; Bun is used only to run tests)
10
10
  - Postgres with pgvector (app tables + durable workflow state)
11
11
  - a sandbox backend, selected by `SANDBOX_BACKEND` (`docker` | `k8s`)
12
+ - a package store, plus a `farmSource` that names where the package farm of an analysis comes from
12
13
  - a chat/embedding provider — Anthropic, or any OpenAI-compatible endpoint
13
14
 
14
15
  ```bash
@@ -18,6 +19,8 @@ tsc -p tsconfig.json # build: emit dist/ from src/ (also `npm run b
18
19
  bun test # run the test suite
19
20
  ```
20
21
 
22
+ The runtime image bakes no R or Python package, so the packages arrive from a host **package store** that the backend mounts read-only at `/mnt/libs`. `farmSource` is a **required** backend config field, and it has two kinds: `fixed` names one farm for every analysis (the managed shape), and `per-analysis` supplies a resolver of the embedder. `ExtendAnalysisFarm` is the optional link seam behind the `link_packages` tool. The harness composes no farm and installs nothing — the embedder owns the store — and the harness names the missing packages but no host command, because a managed deployment holds no `inflexa` binary.
23
+
21
24
  The package emits `dist/` from `src/` and publishes only `dist`. Most configuration arrives through dependency objects passed to the composition point — the LLM backend, for instance, is a fully injected provider config (endpoint/key/model or a `LanguageModel` instance), not read from env; helper modules read conventional env vars (`DB_PG_*`, sandbox limits) — see `CONTEXT.md` and the specs for the catalog.
22
25
 
23
26
  ## Public surface
@@ -1,7 +1,18 @@
1
1
  /**
2
2
  * DBOS realization of the `RunLauncher` seam — the single production adapter,
3
- * shared by every embedder. Host-neutral: it references only DBOS, which the harness
4
- * already depends on, so it ships in the harness.
3
+ * shared by every embedder. Host-neutral: it references only DBOS and the OTel
4
+ * API, which the harness already depends on, so it ships in the harness.
5
+ *
6
+ * A launch detaches the run from the caller's trace. The DBOS workflow span
7
+ * takes the active context as its parent, and the caller is a chat request
8
+ * (or another short-lived operation) whose span ends in milliseconds while the
9
+ * run lasts hours. So the launch opens a root span of its own, runs
10
+ * `startWorkflow` under it, and joins the two traces by a span link back to
11
+ * the originating span plus `inflexa.run_id` on both sides. With no
12
+ * TracerProvider registered every call here is a no-op on the API's
13
+ * non-recording span.
5
14
  */
6
15
  import type { RunLauncher } from "./run-launcher.js";
16
+ /** Attribute carrying the run id on the originating span and on the run's root span. */
17
+ export declare const ATTR_INFLEXA_RUN_ID = "inflexa.run_id";
7
18
  export declare function createDbosRunLauncher(): RunLauncher;
@@ -1,13 +1,38 @@
1
1
  /**
2
2
  * DBOS realization of the `RunLauncher` seam — the single production adapter,
3
- * shared by every embedder. Host-neutral: it references only DBOS, which the harness
4
- * already depends on, so it ships in the harness.
3
+ * shared by every embedder. Host-neutral: it references only DBOS and the OTel
4
+ * API, which the harness already depends on, so it ships in the harness.
5
+ *
6
+ * A launch detaches the run from the caller's trace. The DBOS workflow span
7
+ * takes the active context as its parent, and the caller is a chat request
8
+ * (or another short-lived operation) whose span ends in milliseconds while the
9
+ * run lasts hours. So the launch opens a root span of its own, runs
10
+ * `startWorkflow` under it, and joins the two traces by a span link back to
11
+ * the originating span plus `inflexa.run_id` on both sides. With no
12
+ * TracerProvider registered every call here is a no-op on the API's
13
+ * non-recording span.
5
14
  */
6
15
  import { DBOS } from "@dbos-inc/dbos-sdk";
16
+ import { context, isSpanContextValid, ROOT_CONTEXT, trace } from "@opentelemetry/api";
17
+ /** Attribute carrying the run id on the originating span and on the run's root span. */
18
+ export const ATTR_INFLEXA_RUN_ID = "inflexa.run_id";
19
+ const TRACER_NAME = "inflexa.harness.run-launcher";
7
20
  export function createDbosRunLauncher() {
8
21
  return {
9
22
  async launch(workflow, opts, input) {
10
- await DBOS.startWorkflow(workflow, { workflowID: opts.workflowId })(input);
23
+ const originating = trace.getSpan(context.active());
24
+ const links = [];
25
+ if (originating !== undefined && isSpanContextValid(originating.spanContext())) {
26
+ originating.setAttribute(ATTR_INFLEXA_RUN_ID, opts.workflowId);
27
+ links.push({ context: originating.spanContext() });
28
+ }
29
+ const root = trace.getTracer(TRACER_NAME).startSpan("launch run", { links, attributes: { [ATTR_INFLEXA_RUN_ID]: opts.workflowId } }, ROOT_CONTEXT);
30
+ try {
31
+ await context.with(trace.setSpan(ROOT_CONTEXT, root), () => DBOS.startWorkflow(workflow, { workflowID: opts.workflowId })(input));
32
+ }
33
+ finally {
34
+ root.end();
35
+ }
11
36
  },
12
37
  };
13
38
  }
@@ -23,7 +23,7 @@ import { stat } from "node:fs/promises";
23
23
  import path from "node:path";
24
24
  import { createNoopLogger } from "../lib/console-logger.js";
25
25
  import { classifyWithinRoot, computeSha256File, hasValidContentHash } from "../lib/fs-helpers.js";
26
- import { artifactReconcileDropped, lineageInputDropped } from "../lib/metrics.js";
26
+ import { recordArtifactReconcileDropped, recordLineageInputDropped } from "../lib/metrics.js";
27
27
  export async function reconcileManifestWithDisk(input) {
28
28
  const { workspaceRoot, resourceId, runId, stepId, agentId, manifest, collector } = input;
29
29
  const logger = (input.logger ?? createNoopLogger()).named("reconcile-manifest").with({ runId, stepId, agentId });
@@ -62,7 +62,7 @@ export async function reconcileManifestWithDisk(input) {
62
62
  logger.debug("dropping phantom entry", { path: entry.path });
63
63
  collector.removeRecord(entry.path);
64
64
  droppedCount++;
65
- artifactReconcileDropped.add(1, { agent_id: agentId, step_id: stepId });
65
+ recordArtifactReconcileDropped({ agentId });
66
66
  continue;
67
67
  }
68
68
  throw err;
@@ -75,7 +75,7 @@ export async function reconcileManifestWithDisk(input) {
75
75
  logger.debug("dropping non-file entry", { path: entry.path });
76
76
  collector.removeRecord(entry.path);
77
77
  droppedCount++;
78
- artifactReconcileDropped.add(1, { agent_id: agentId, step_id: stepId });
78
+ recordArtifactReconcileDropped({ agentId });
79
79
  continue;
80
80
  }
81
81
  // The registration payload reads output hashes from `entries` (this
@@ -140,14 +140,14 @@ async function fillInputHashesFromDisk(collector, workspaceRoot, resourceId, run
140
140
  const containerPrefix = `/${resourceId}`;
141
141
  if (ref.path !== containerPrefix && !ref.path.startsWith(containerPrefix + "/")) {
142
142
  logger.warn("dropping out-of-tree input from lineage", { ...attestation, boundSite: "container-prefix" });
143
- lineageInputDropped.add(1, { agent_id: agentId, step_id: stepId, reason: "container-prefix" });
143
+ recordLineageInputDropped({ agentId, reason: "container-prefix" });
144
144
  collector.dropInput(ref);
145
145
  continue;
146
146
  }
147
147
  const hostPath = path.join(resourceRoot, ref.path.slice(containerPrefix.length + 1));
148
148
  if (hostPath !== resourceRoot && !hostPath.startsWith(resourceRoot + path.sep)) {
149
149
  logger.warn("dropping out-of-tree input from lineage", { ...attestation, hostPath, boundSite: "workspace-root" });
150
- lineageInputDropped.add(1, { agent_id: agentId, step_id: stepId, reason: "workspace-root" });
150
+ recordLineageInputDropped({ agentId, reason: "workspace-root" });
151
151
  collector.dropInput(ref);
152
152
  continue;
153
153
  }
@@ -159,7 +159,7 @@ async function fillInputHashesFromDisk(collector, workspaceRoot, resourceId, run
159
159
  // verdict (a dangling link) falls through to the ENOENT drop below.
160
160
  if ((await classifyWithinRoot(resourceRoot, hostPath)) === "escaped") {
161
161
  logger.warn("dropping out-of-tree input from lineage", { ...attestation, hostPath, boundSite: "realpath" });
162
- lineageInputDropped.add(1, { agent_id: agentId, step_id: stepId, reason: "symlink-escape" });
162
+ recordLineageInputDropped({ agentId, reason: "symlink-escape" });
163
163
  collector.dropInput(ref);
164
164
  continue;
165
165
  }
@@ -173,7 +173,7 @@ async function fillInputHashesFromDisk(collector, workspaceRoot, resourceId, run
173
173
  // consumed, and the count of those is how a reader tells a noisy
174
174
  // hook from an artifact that genuinely vanished under the step.
175
175
  logger.warn("dropping input not present at reconcile from lineage", { ...attestation, hostPath, dropSite: "input-enoent" });
176
- lineageInputDropped.add(1, { agent_id: agentId, step_id: stepId, reason: "missing" });
176
+ recordLineageInputDropped({ agentId, reason: "missing" });
177
177
  collector.dropInput(ref);
178
178
  continue;
179
179
  }
@@ -187,7 +187,7 @@ async function fillInputHashesFromDisk(collector, workspaceRoot, resourceId, run
187
187
  // non-file drop above. A genuinely missing FILE (ENOENT) still fails fast
188
188
  // as drift; a directory is not drift.
189
189
  logger.debug("dropping non-file input from lineage", attestation);
190
- lineageInputDropped.add(1, { agent_id: agentId, step_id: stepId, reason: "directory" });
190
+ recordLineageInputDropped({ agentId, reason: "directory" });
191
191
  collector.dropInput(ref);
192
192
  continue;
193
193
  }
package/dist/index.d.ts CHANGED
@@ -71,7 +71,7 @@ export type { AiSdkProviderConfig, ConfiguredAiSdkProviderDeps } from "./provide
71
71
  export { createEmbeddingProvider } from "./providers/embedding.js";
72
72
  export type { EmbeddingProviderDeps } from "./providers/embedding.js";
73
73
  export type { AgentChat, ChatProvider, EmbeddingProvider, ChatRequest, ChatResponse, ChatStreamEvent, ChatUsage, ModelMessage, PromptCachePolicy, ReasoningPolicy, } from "./providers/types.js";
74
- export { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions } from "./providers/prompt-cache.js";
74
+ export { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions, withPromptCacheBreakpoint } from "./providers/prompt-cache.js";
75
75
  export { DEFAULT_REASONING } from "./providers/reasoning.js";
76
76
  export { toProviderError, isProviderError } from "./providers/errors.js";
77
77
  export type { ProviderError } from "./providers/errors.js";
package/dist/index.js CHANGED
@@ -110,8 +110,14 @@ export { createEmbeddingProvider } from "./providers/embedding.js";
110
110
  // re-sends its prefix every iteration and breaks even immediately). Hosts whose
111
111
  // endpoint ignores cache directives — notably the Claude Max OAuth path — pass
112
112
  // `"off"`; the `cortex.harness.agent.cache_*_tokens` metrics show which case a
113
- // deployment is actually in.
114
- export { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions } from "./providers/prompt-cache.js";
113
+ // deployment is actually in. `withPromptCacheBreakpoint` is the placement: it
114
+ // puts the ONE marker of a request on its last message, where every hop upstream
115
+ // can count it, in the namespace of each vendor that needs an explicit
116
+ // breakpoint (Anthropic and Bedrock; the OpenAI family and Gemini cache without
117
+ // one). Never attach `promptCacheProviderOptions` to a request itself — that
118
+ // emits a top-level field an intermediary cannot see, and a proxy that trims to
119
+ // Anthropic's cap of four breakpoints then sends five.
120
+ export { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions, withPromptCacheBreakpoint } from "./providers/prompt-cache.js";
115
121
  // Reasoning depth. `ReasoningPolicy` is the vendor-neutral name for how deep a
116
122
  // model reasons; a composition root sets it per run through
117
123
  // `RunAgentOptions.reasoning`. It defaults to `DEFAULT_REASONING` — `xhigh` —
@@ -4,6 +4,24 @@
4
4
  * Instruments:
5
5
  * - cortex.artifact.reconcile.dropped — counter for missing manifest entries
6
6
  * - cortex.artifact.reconcile.input_dropped — counter for lineage input drops
7
+ *
8
+ * Both carry `agent_id` (about 15 values) and never a per-step or per-run
9
+ * identifier: a label with unbounded values multiplies the series count.
10
+ *
11
+ * Instruments are lazy so the record site binds to whichever `MeterProvider`
12
+ * is globally registered at first use. The metrics API has no late-binding
13
+ * proxy: a meter taken at module load, before `initOtel` runs, would stay
14
+ * on the no-op provider for the life of the process.
7
15
  */
8
- export declare const artifactReconcileDropped: import("@opentelemetry/api").Counter<import("@opentelemetry/api").Attributes>;
9
- export declare const lineageInputDropped: import("@opentelemetry/api").Counter<import("@opentelemetry/api").Attributes>;
16
+ /** Record one manifest entry dropped at reconcile. */
17
+ export declare function recordArtifactReconcileDropped(args: {
18
+ readonly agentId: string;
19
+ }): void;
20
+ export type LineageInputDropReason = "container-prefix" | "workspace-root" | "symlink-escape" | "missing" | "directory";
21
+ /** Record one tracked input read dropped from lineage at reconcile. */
22
+ export declare function recordLineageInputDropped(args: {
23
+ readonly agentId: string;
24
+ readonly reason: LineageInputDropReason;
25
+ }): void;
26
+ /** Test hook — drop memoised instruments to rebind a fresh MeterProvider. */
27
+ export declare function __resetReconcileMetricsForTest(): void;
@@ -4,16 +4,44 @@
4
4
  * Instruments:
5
5
  * - cortex.artifact.reconcile.dropped — counter for missing manifest entries
6
6
  * - cortex.artifact.reconcile.input_dropped — counter for lineage input drops
7
+ *
8
+ * Both carry `agent_id` (about 15 values) and never a per-step or per-run
9
+ * identifier: a label with unbounded values multiplies the series count.
10
+ *
11
+ * Instruments are lazy so the record site binds to whichever `MeterProvider`
12
+ * is globally registered at first use. The metrics API has no late-binding
13
+ * proxy: a meter taken at module load, before `initOtel` runs, would stay
14
+ * on the no-op provider for the life of the process.
7
15
  */
8
16
  import { metrics } from "@opentelemetry/api";
9
- const meter = metrics.getMeter("cortex");
10
- export const artifactReconcileDropped = meter.createCounter("cortex.artifact.reconcile.dropped", {
11
- description: "Manifest entries dropped because their on-disk file was missing at " +
12
- "registration time (writes-then-deletes, renames). Tagged by agent_id, step_id.",
13
- });
14
- export const lineageInputDropped = meter.createCounter("cortex.artifact.reconcile.input_dropped", {
15
- description: "Tracked input reads dropped from lineage at reconcile because they are " +
16
- "not content-attestable files of the analysis (directory reads, " +
17
- "out-of-tree resolutions, paths absent at reconcile). Tagged by " +
18
- "agent_id, step_id, reason.",
19
- });
17
+ let instruments;
18
+ function getInstruments() {
19
+ if (instruments === undefined) {
20
+ const meter = metrics.getMeter("cortex");
21
+ instruments = {
22
+ artifactReconcileDropped: meter.createCounter("cortex.artifact.reconcile.dropped", {
23
+ description: "Manifest entries dropped because their on-disk file was missing at " +
24
+ "registration time (writes-then-deletes, renames). Tagged by agent_id.",
25
+ }),
26
+ lineageInputDropped: meter.createCounter("cortex.artifact.reconcile.input_dropped", {
27
+ description: "Tracked input reads dropped from lineage at reconcile because they are " +
28
+ "not content-attestable files of the analysis (directory reads, " +
29
+ "out-of-tree resolutions, paths absent at reconcile). Tagged by " +
30
+ "agent_id, reason.",
31
+ }),
32
+ };
33
+ }
34
+ return instruments;
35
+ }
36
+ /** Record one manifest entry dropped at reconcile. */
37
+ export function recordArtifactReconcileDropped(args) {
38
+ getInstruments().artifactReconcileDropped.add(1, { agent_id: args.agentId });
39
+ }
40
+ /** Record one tracked input read dropped from lineage at reconcile. */
41
+ export function recordLineageInputDropped(args) {
42
+ getInstruments().lineageInputDropped.add(1, { agent_id: args.agentId, reason: args.reason });
43
+ }
44
+ /** Test hook — drop memoised instruments to rebind a fresh MeterProvider. */
45
+ export function __resetReconcileMetricsForTest() {
46
+ instruments = undefined;
47
+ }
@@ -1,14 +1,22 @@
1
1
  /**
2
2
  * OpenTelemetry SDK initialization — traces + metrics.
3
3
  *
4
- * Exported as `initOtel()` and called explicitly from index.ts to prevent
5
- * the bundler from tree-shaking it (side-effect-only imports get dropped).
4
+ * Exported as `initOtel()` and called explicitly from the host's boot sequence
5
+ * so the bundler cannot tree-shake it (side-effect-only imports get dropped).
6
6
  *
7
7
  * Traces: NodeTracerProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT
8
8
  * is set.
9
9
  *
10
- * Metrics: MeterProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT
11
- * is set. Custom Cortex metrics are defined in metrics.ts.
10
+ * Metrics: MeterProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is
11
+ * set and OTEL_METRICS_EXPORTER is not `none`. The export interval is
12
+ * OTEL_METRIC_EXPORT_INTERVAL (ms), default 60 s. Custom Cortex
13
+ * metrics are defined in metrics.ts.
14
+ *
15
+ * Resource: the host's `serviceName` / `serviceVersion` merged with the SDK
16
+ * env detector (`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`);
17
+ * the environment wins on a conflict, so a deployment can set
18
+ * `deployment.environment.name` or override the service name without
19
+ * a code change. Traces and metrics share the one resource.
12
20
  *
13
21
  * Note: OTEL's instrumentation-http patches node:http but does not cover
14
22
  * Hono's request handling or Node 22's undici-based globalThis.fetch,
@@ -16,16 +24,45 @@
16
24
  * not currently wired.
17
25
  */
18
26
  import type { Logger } from "./logger.js";
27
+ /** The SDK's own default when neither the host nor the environment says otherwise. */
28
+ export declare const DEFAULT_METRIC_EXPORT_INTERVAL_MS = 60000;
29
+ export interface InitOtelOptions {
30
+ /** Diagnostics sink; omitted falls back to no-op. */
31
+ readonly logger?: Logger;
32
+ /** `service.name` when `OTEL_SERVICE_NAME` is unset. Default `cortex`. */
33
+ readonly serviceName?: string;
34
+ /** `service.version` — the host's package version. Omitted leaves the attribute unset. */
35
+ readonly serviceVersion?: string;
36
+ }
37
+ /**
38
+ * `OTEL_METRICS_EXPORTER=none` turns metric export off while the OTLP endpoint
39
+ * stays set for traces. Any other value (including unset) keeps the OTLP
40
+ * exporter, the only one the harness ships.
41
+ */
42
+ export declare function metricsExportDisabled(env?: NodeJS.ProcessEnv): boolean;
43
+ /**
44
+ * Export interval from `OTEL_METRIC_EXPORT_INTERVAL` (milliseconds). An unset,
45
+ * non-numeric, or non-positive value falls back to the 60 s default.
46
+ */
47
+ export declare function metricExportIntervalMs(env?: NodeJS.ProcessEnv): number;
19
48
  /**
20
49
  * Initialize the OpenTelemetry SDK. Must be called before any code that
21
50
  * calls trace.getTracer() or metrics.getMeter().
22
51
  *
23
52
  * Safe to call multiple times — only the first call has effect.
24
53
  */
25
- export declare function initOtel(injected?: Logger): void;
54
+ export declare function initOtel(options?: InitOtelOptions): void;
26
55
  /**
27
56
  * Flush and shut down the OTel exporters. Called by the graceful-shutdown
28
57
  * sequence so in-flight batch spans/metrics make it to the collector
29
58
  * before the process exits. Never throws.
30
59
  */
31
60
  export declare function shutdownOtel(): Promise<void>;
61
+ /**
62
+ * Test hook: shut the providers down and unregister every global the init
63
+ * touched, so a test can drive `initOtel` again under a different environment.
64
+ * Test-only.
65
+ */
66
+ export declare function __resetOtelForTest(): Promise<void>;
67
+ /** Test hook: whether `initOtel` registered a metric-exporting MeterProvider. Test-only. */
68
+ export declare function __otelMeterProviderRegisteredForTest(): boolean;
package/dist/lib/otel.js CHANGED
@@ -1,21 +1,29 @@
1
1
  /**
2
2
  * OpenTelemetry SDK initialization — traces + metrics.
3
3
  *
4
- * Exported as `initOtel()` and called explicitly from index.ts to prevent
5
- * the bundler from tree-shaking it (side-effect-only imports get dropped).
4
+ * Exported as `initOtel()` and called explicitly from the host's boot sequence
5
+ * so the bundler cannot tree-shake it (side-effect-only imports get dropped).
6
6
  *
7
7
  * Traces: NodeTracerProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT
8
8
  * is set.
9
9
  *
10
- * Metrics: MeterProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT
11
- * is set. Custom Cortex metrics are defined in metrics.ts.
10
+ * Metrics: MeterProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is
11
+ * set and OTEL_METRICS_EXPORTER is not `none`. The export interval is
12
+ * OTEL_METRIC_EXPORT_INTERVAL (ms), default 60 s. Custom Cortex
13
+ * metrics are defined in metrics.ts.
14
+ *
15
+ * Resource: the host's `serviceName` / `serviceVersion` merged with the SDK
16
+ * env detector (`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`);
17
+ * the environment wins on a conflict, so a deployment can set
18
+ * `deployment.environment.name` or override the service name without
19
+ * a code change. Traces and metrics share the one resource.
12
20
  *
13
21
  * Note: OTEL's instrumentation-http patches node:http but does not cover
14
22
  * Hono's request handling or Node 22's undici-based globalThis.fetch,
15
23
  * so inbound/outbound trace-context propagation across those paths is
16
24
  * not currently wired.
17
25
  */
18
- import { propagation, metrics, trace } from "@opentelemetry/api";
26
+ import { context, propagation, metrics, trace } from "@opentelemetry/api";
19
27
  import { createNoopLogger } from "./console-logger.js";
20
28
  import { W3CTraceContextPropagator } from "@opentelemetry/core";
21
29
  import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
@@ -23,25 +31,59 @@ import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
23
31
  import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
24
32
  import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
25
33
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
26
- import { resourceFromAttributes } from "@opentelemetry/resources";
27
- import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
34
+ import { detectResources, envDetector, resourceFromAttributes } from "@opentelemetry/resources";
35
+ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
36
+ /** The SDK's own default when neither the host nor the environment says otherwise. */
37
+ export const DEFAULT_METRIC_EXPORT_INTERVAL_MS = 60_000;
28
38
  let initialized = false;
29
39
  let registeredTracerProvider;
30
40
  let registeredMeterProvider;
41
+ /**
42
+ * `OTEL_METRICS_EXPORTER=none` turns metric export off while the OTLP endpoint
43
+ * stays set for traces. Any other value (including unset) keeps the OTLP
44
+ * exporter, the only one the harness ships.
45
+ */
46
+ export function metricsExportDisabled(env = process.env) {
47
+ return env.OTEL_METRICS_EXPORTER === "none";
48
+ }
49
+ /**
50
+ * Export interval from `OTEL_METRIC_EXPORT_INTERVAL` (milliseconds). An unset,
51
+ * non-numeric, or non-positive value falls back to the 60 s default.
52
+ */
53
+ export function metricExportIntervalMs(env = process.env) {
54
+ const raw = env.OTEL_METRIC_EXPORT_INTERVAL;
55
+ if (raw === undefined || raw.trim() === "")
56
+ return DEFAULT_METRIC_EXPORT_INTERVAL_MS;
57
+ const parsed = Number(raw);
58
+ if (!Number.isFinite(parsed) || parsed <= 0)
59
+ return DEFAULT_METRIC_EXPORT_INTERVAL_MS;
60
+ return Math.floor(parsed);
61
+ }
62
+ function buildResource(options) {
63
+ const manual = {
64
+ [ATTR_SERVICE_NAME]: options.serviceName ?? "cortex",
65
+ };
66
+ if (options.serviceVersion)
67
+ manual[ATTR_SERVICE_VERSION] = options.serviceVersion;
68
+ // `merge` gives precedence to the argument: the environment overrides the
69
+ // host's values, which is the OTel SDK convention for env configuration.
70
+ return resourceFromAttributes(manual).merge(detectResources({ detectors: [envDetector] }));
71
+ }
31
72
  /**
32
73
  * Initialize the OpenTelemetry SDK. Must be called before any code that
33
74
  * calls trace.getTracer() or metrics.getMeter().
34
75
  *
35
76
  * Safe to call multiple times — only the first call has effect.
36
77
  */
37
- export function initOtel(injected) {
78
+ export function initOtel(options = {}) {
38
79
  if (initialized)
39
80
  return;
40
81
  initialized = true;
41
- const logger = (injected ?? createNoopLogger()).named("otel");
82
+ const logger = (options.logger ?? createNoopLogger()).named("otel");
42
83
  // ── W3C Trace Context propagation ──────────────────────────────────
43
84
  propagation.setGlobalPropagator(new W3CTraceContextPropagator());
44
85
  const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
86
+ const resource = buildResource(options);
45
87
  // ── Traces ─────────────────────────────────────────────────────────
46
88
  // Always register a TracerProvider so trace.getTracer() returns real spans
47
89
  // with unique IDs. Without this, the OtelBridge gets no-op spans (all zeros)
@@ -52,10 +94,6 @@ export function initOtel(injected) {
52
94
  const base = endpoint.replace(/\/+$/, "");
53
95
  spanProcessors.push(new BatchSpanProcessor(new OTLPTraceExporter({ url: `${base}/v1/traces` })));
54
96
  }
55
- const serviceName = process.env.OTEL_SERVICE_NAME || "cortex";
56
- const resource = resourceFromAttributes({
57
- [ATTR_SERVICE_NAME]: serviceName,
58
- });
59
97
  const tracerProvider = new NodeTracerProvider({
60
98
  resource,
61
99
  spanProcessors,
@@ -71,20 +109,29 @@ export function initOtel(injected) {
71
109
  // `initTelemetry: () => {}` — its TUI owns stdout, so the banner corrupted
72
110
  // the screen and the whole of the harness's traces + metrics were switched
73
111
  // off to avoid it. At debug through the injected seam it costs nothing.
74
- logger.debug("TracerProvider registered", { spanId: ctx.spanId, noop: isNoop, endpoint: endpoint ?? null, processors: spanProcessors.length });
112
+ logger.debug("TracerProvider registered", {
113
+ spanId: ctx.spanId,
114
+ noop: isNoop,
115
+ endpoint: endpoint ?? null,
116
+ processors: spanProcessors.length,
117
+ resource: resource.attributes,
118
+ });
75
119
  testSpan.end();
76
120
  }
77
121
  // ── Metrics ────────────────────────────────────────────────────────
78
- if (endpoint) {
122
+ // With no MeterProvider registered the API's global no-op provider serves
123
+ // every instrument, so the record sites stay valid when export is off.
124
+ if (endpoint && !metricsExportDisabled()) {
79
125
  const base = endpoint.replace(/\/+$/, "");
80
126
  const metricExporter = new OTLPMetricExporter({
81
127
  url: `${base}/v1/metrics`,
82
128
  });
83
129
  const meterProvider = new MeterProvider({
130
+ resource,
84
131
  readers: [
85
132
  new PeriodicExportingMetricReader({
86
133
  exporter: metricExporter,
87
- exportIntervalMillis: 30_000,
134
+ exportIntervalMillis: metricExportIntervalMs(),
88
135
  }),
89
136
  ],
90
137
  });
@@ -105,3 +152,22 @@ export async function shutdownOtel() {
105
152
  tasks.push(registeredMeterProvider.shutdown());
106
153
  await Promise.allSettled(tasks);
107
154
  }
155
+ /**
156
+ * Test hook: shut the providers down and unregister every global the init
157
+ * touched, so a test can drive `initOtel` again under a different environment.
158
+ * Test-only.
159
+ */
160
+ export async function __resetOtelForTest() {
161
+ await shutdownOtel();
162
+ registeredTracerProvider = undefined;
163
+ registeredMeterProvider = undefined;
164
+ trace.disable();
165
+ metrics.disable();
166
+ context.disable();
167
+ propagation.disable();
168
+ initialized = false;
169
+ }
170
+ /** Test hook: whether `initOtel` registered a metric-exporting MeterProvider. Test-only. */
171
+ export function __otelMeterProviderRegisteredForTest() {
172
+ return registeredMeterProvider !== undefined;
173
+ }
@@ -7,7 +7,7 @@ import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
7
7
  import { markInterruptedMessage, syntheticUserMessage } from "../memory/ai-sdk-message-storage.js";
8
8
  import { stripUnansweredToolCalls } from "../memory/tool-call-integrity.js";
9
9
  import { classifyProviderError } from "../providers/errors.js";
10
- import { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions } from "../providers/prompt-cache.js";
10
+ import { DEFAULT_PROMPT_CACHE, withPromptCacheBreakpoint } from "../providers/prompt-cache.js";
11
11
  import { DEFAULT_REASONING } from "../providers/reasoning.js";
12
12
  import { resultStep } from "./run-step.js";
13
13
  import { AskRejectedError, UnavailableAsk } from "../tools/approval/contract.js";
@@ -82,10 +82,11 @@ export async function runAgent(agent, initial, session, opts) {
82
82
  // `debug` records name the tools when that distinction is not enough.
83
83
  let toolCallCount = 0;
84
84
  let toolErrorCount = 0;
85
- // Resolved once, not per iteration: an identical options object across every
86
- // call is itself part of the cache contract — the request prefix has to be
87
- // byte-identical to be read back.
88
- const providerOptions = promptCacheProviderOptions(opts.promptCache ?? DEFAULT_PROMPT_CACHE);
85
+ // Resolved once, not per iteration: an identical policy across every call is
86
+ // itself part of the cache contract — the request prefix has to be
87
+ // byte-identical to be read back. The breakpoint it places is re-derived per
88
+ // call, because it rides the last message and the transcript grows.
89
+ const promptCache = opts.promptCache ?? DEFAULT_PROMPT_CACHE;
89
90
  // The depth is a neutral name, and the provider package resolves it for the
90
91
  // model. A vendor key here would turn that resolution off.
91
92
  const reasoning = opts.reasoning ?? DEFAULT_REASONING;
@@ -232,10 +233,9 @@ export async function runAgent(agent, initial, session, opts) {
232
233
  iterations = i + 1;
233
234
  const request = {
234
235
  system: agent.systemPrompt,
235
- messages,
236
+ messages: withPromptCacheBreakpoint(messages, promptCache),
236
237
  tools: toolDefs,
237
238
  ...(opts.toolChoice !== undefined ? { toolChoice: opts.toolChoice } : {}),
238
- providerOptions,
239
239
  reasoning,
240
240
  };
241
241
  const llmStepName = formatStepName.llm(i);
@@ -343,11 +343,11 @@ export async function runAgent(agent, initial, session, opts) {
343
343
  // Cache defeater (known; not fixed here). Emptying the tool set changes the
344
344
  // very front of the request prefix — tool definitions are cached ahead of
345
345
  // system and history — so this call reads *nothing* back from the cache and
346
- // rewrites the whole prefix from scratch. It still carries the cache options
346
+ // rewrites the whole prefix from scratch. It still places the breakpoint
347
347
  // because it is the one call whose write is pure waste, and the
348
348
  // cache_write_tokens counter is what makes that waste visible.
349
349
  const wrapUpStepName = formatStepName.llm(agent.maxIterations);
350
- const wrapUp = await resultStep(runStep)(wrapUpStepName, () => provider.chat({ system: agent.systemPrompt, messages, tools: {}, toolChoice: "none", providerOptions, reasoning }, session, signal));
350
+ const wrapUp = await resultStep(runStep)(wrapUpStepName, () => provider.chat({ system: agent.systemPrompt, messages: withPromptCacheBreakpoint(messages, promptCache), tools: {}, toolChoice: "none", reasoning }, session, signal));
351
351
  accountForCall(wrapUp, wrapUpStepName);
352
352
  if (wrapUp.finishReason === "aborted") {
353
353
  // An abort during the tool-less wrap-up is still the user cutting the turn — the
@@ -15,12 +15,53 @@
15
15
  * error — on an OpenAI-compatible model, which is exactly what we want: that
16
16
  * family does automatic server-side prefix caching and needs no directive.
17
17
  *
18
- * ## What the Anthropic namespace does
18
+ * ## What the Anthropic namespace does, and where the marker must go
19
19
  *
20
- * A request-level `cacheControl` makes the provider emit a single top-level
21
- * `cache_control` marker; the server then places the breakpoint on the last
22
- * cacheable block, so the whole prefix (tools → system → history) is cached
23
- * without the harness hand-placing per-block markers.
20
+ * The options go on the LAST MESSAGE of the request, never on the request
21
+ * itself. Both placements cache the same bytes — one breakpoint at the end of
22
+ * the messages covers the whole prefix (tools → system → history), because the
23
+ * cache keys on a prefix — but only the message placement survives an
24
+ * intermediary.
25
+ *
26
+ * A REQUEST-level `cacheControl` makes the provider emit a top-level
27
+ * `cache_control` field instead of a per-block marker, and the server then
28
+ * places the breakpoint itself. That is one fewer thing for the harness to get
29
+ * right, and it is why this module used to do it. It is also invisible: an
30
+ * intermediary that counts breakpoints to stay under Anthropic's cap of four
31
+ * counts BLOCKS, so a top-level field is a breakpoint nothing on the way
32
+ * upstream can see. CLIProxyAPI — what the OSS CLI routes through on the Claude
33
+ * OAuth path — injects up to four block markers of its own and trims to four by
34
+ * that blind count, so the harness's invisible fifth breakpoint made every
35
+ * request past a thread's first turn fail with `A maximum of 4 blocks with
36
+ * cache_control may be provided. Found 5.` (HTTP 400, non-retryable — the thread
37
+ * is wedged, because the next turn rebuilds the same shape).
38
+ *
39
+ * A marker on a message block is counted, trimmed, and reasoned about by every
40
+ * hop. Keep it there. {@link withPromptCacheBreakpoint} is the only writer.
41
+ *
42
+ * ## Which vendors need a marker at all
43
+ *
44
+ * Only two of them, and both take it on a message:
45
+ *
46
+ * - **Anthropic** — `anthropic.cacheControl`. Fully opt-in: no marker, no
47
+ * caching, at full price.
48
+ * - **Amazon Bedrock** — `bedrock.cachePoint`. Also fully opt-in.
49
+ *
50
+ * The OpenAI family caches prefixes server-side, unprompted, and exposes no
51
+ * breakpoint to place (`promptCacheKey` and `promptCacheRetention` are request
52
+ * settings for affinity and retention, not placement — a separate concern from
53
+ * this policy). Gemini caches implicitly by default, and its explicit mode is a
54
+ * cache RESOURCE created out of band and referenced by name, which is not a
55
+ * marker and does not fit a ttl policy.
56
+ *
57
+ * So both markers ride together on the one chosen message. A provider reads only
58
+ * its own namespace, thus the pair is free to whichever vendor is not serving
59
+ * the call, and the placement never learns which one that is.
60
+ *
61
+ * The asymmetry worth remembering: under-marking silently costs money, while
62
+ * over-marking hard-fails. Anthropic answers 400 past four breakpoints, and the
63
+ * legacy Bedrock integration answers 400 on a top-level `cache_control`. Every
64
+ * failure in this area comes from asking for too much.
24
65
  *
25
66
  * ## Cache defeaters — what silently kills the hit rate
26
67
  *
@@ -43,15 +84,29 @@
43
84
  * `prompts/briefing.ts`). Keep it that way — one interpolated id or path there
44
85
  * makes every step's ~20k-char prefix unique.
45
86
  *
46
- * ## Where caching is a no-op regardless
87
+ * ## What the cache-token metrics do and do not prove
88
+ *
89
+ * Caching DOES engage on the Claude OAuth path the OSS CLI routes through a
90
+ * local CLIProxyAPI: measured, 2050-2151 cache-read tokens on the turns after
91
+ * the first. An earlier note here claimed that path ignores cache directives
92
+ * outright. It does not.
47
93
  *
48
- * The Claude Max OAuth path does not honour cache directives, and the OSS CLI
49
- * defaults to routing through a local CLIProxyAPI on exactly that path. Caching
50
- * only engages against a direct API key or a gateway. The cache-token metrics
51
- * (`loop/metrics.ts`) are what tell the two apart at runtime — a flat-zero
52
- * `cache_read_tokens` counter is the symptom.
94
+ * But a read there is not evidence that OUR breakpoint earned it. While cloaking
95
+ * — which is what that path does — the proxy places breakpoints of its own on
96
+ * the system block, the first user message, the system message it relocates, and
97
+ * the last message. Those cache the prefix whatever the harness sends: with
98
+ * `promptCache: "off"` and no marker of ours on the wire, a repeat request still
99
+ * read 1902 tokens back.
100
+ *
101
+ * So on that path `cache_read_tokens` (`loop/metrics.ts`) reports whether the
102
+ * deployment gets caching at all, which is what the counter is for. It does not
103
+ * attribute the read, and it cannot tell a working harness breakpoint from a
104
+ * proxy that is quietly doing the work. Against a direct API key or a gateway,
105
+ * where nothing injects markers, the counter does mean exactly that — and a
106
+ * flat-zero read counter beside a non-zero write counter is still the signature
107
+ * of an endpoint billing for writes and serving no reads.
53
108
  */
54
- import type { PromptCachePolicy, ProviderOptions } from "./types.js";
109
+ import type { ModelMessage, PromptCachePolicy, ProviderOptions } from "./types.js";
55
110
  /**
56
111
  * The default policy: cache with the 5-minute TTL.
57
112
  *
@@ -68,7 +123,47 @@ export declare const DEFAULT_PROMPT_CACHE: PromptCachePolicy;
68
123
  /**
69
124
  * Translate a neutral cache policy into provider wire options.
70
125
  *
71
- * Returns `undefined` for `"off"` so the caller can leave `providerOptions`
72
- * entirely unset rather than sending an empty bag.
126
+ * Returns `undefined` for `"off"` so the caller can leave the bag entirely unset
127
+ * rather than sending an empty one.
128
+ *
129
+ * Both vendors that take an explicit marker get one, in their own namespace. A
130
+ * provider reads only its own key, so the pair costs nothing to whichever one is
131
+ * not serving the call, and the placement needs no idea which vendor it is
132
+ * talking to. The two shapes differ in more than spelling: Anthropic marks the
133
+ * last content block of the message, while Bedrock appends a `cachePoint` block
134
+ * after it. Both end up at the same position in the prefix.
135
+ *
136
+ * The `satisfies` clauses are the point of naming the vendor types at all. This
137
+ * bag is a `Record<string, JSONObject>`, thus nothing here is checked by
138
+ * default, and a renamed key would compile and silently stop caching —
139
+ * `cacheControl` has already been renamed once, and the provider still accepts
140
+ * `cache_control` as its alias.
141
+ *
142
+ * CAUTION: this is the marker's VALUE, not its placement. Attaching it to a
143
+ * `ChatRequest` emits the invisible top-level field the module header warns
144
+ * about. Place it with {@link withPromptCacheBreakpoint}.
73
145
  */
74
146
  export declare function promptCacheProviderOptions(policy: PromptCachePolicy): ProviderOptions | undefined;
147
+ /**
148
+ * Place the request's ONE cache breakpoint, on the last message that can host it.
149
+ *
150
+ * Returns a copy — the caller's array is the transcript the loop keeps pushing
151
+ * onto and the host later persists, and a marker written into it would ride into
152
+ * the stored thread and come back on every later turn, one more breakpoint per
153
+ * turn until the request is refused. Only the request-shaped copy carries one.
154
+ *
155
+ * The placement is deliberately ROLLING, not pinned to the end of the stable
156
+ * prefix: within one run the loop appends an assistant reply and its tool results
157
+ * on every iteration, and a breakpoint that advances with them lets iteration N+1
158
+ * read back everything iteration N wrote. A pinned breakpoint would re-process
159
+ * the whole tool transcript uncached on every iteration, which is the opposite of
160
+ * what an agent loop needs.
161
+ *
162
+ * A marker already on any other message is REMOVED — the invariant is exactly one
163
+ * breakpoint per request, and a stored message that carries one from an older
164
+ * build (`memory/ai-sdk-message-storage.ts` reads `cache_control` back off a
165
+ * stored block) would otherwise spend a slot that the harness never budgeted.
166
+ * `"off"` strips and places nothing, so turning caching off really does send no
167
+ * directive at all.
168
+ */
169
+ export declare function withPromptCacheBreakpoint<M extends ModelMessage>(messages: readonly M[], policy: PromptCachePolicy): readonly M[];
@@ -15,12 +15,53 @@
15
15
  * error — on an OpenAI-compatible model, which is exactly what we want: that
16
16
  * family does automatic server-side prefix caching and needs no directive.
17
17
  *
18
- * ## What the Anthropic namespace does
18
+ * ## What the Anthropic namespace does, and where the marker must go
19
19
  *
20
- * A request-level `cacheControl` makes the provider emit a single top-level
21
- * `cache_control` marker; the server then places the breakpoint on the last
22
- * cacheable block, so the whole prefix (tools → system → history) is cached
23
- * without the harness hand-placing per-block markers.
20
+ * The options go on the LAST MESSAGE of the request, never on the request
21
+ * itself. Both placements cache the same bytes — one breakpoint at the end of
22
+ * the messages covers the whole prefix (tools → system → history), because the
23
+ * cache keys on a prefix — but only the message placement survives an
24
+ * intermediary.
25
+ *
26
+ * A REQUEST-level `cacheControl` makes the provider emit a top-level
27
+ * `cache_control` field instead of a per-block marker, and the server then
28
+ * places the breakpoint itself. That is one fewer thing for the harness to get
29
+ * right, and it is why this module used to do it. It is also invisible: an
30
+ * intermediary that counts breakpoints to stay under Anthropic's cap of four
31
+ * counts BLOCKS, so a top-level field is a breakpoint nothing on the way
32
+ * upstream can see. CLIProxyAPI — what the OSS CLI routes through on the Claude
33
+ * OAuth path — injects up to four block markers of its own and trims to four by
34
+ * that blind count, so the harness's invisible fifth breakpoint made every
35
+ * request past a thread's first turn fail with `A maximum of 4 blocks with
36
+ * cache_control may be provided. Found 5.` (HTTP 400, non-retryable — the thread
37
+ * is wedged, because the next turn rebuilds the same shape).
38
+ *
39
+ * A marker on a message block is counted, trimmed, and reasoned about by every
40
+ * hop. Keep it there. {@link withPromptCacheBreakpoint} is the only writer.
41
+ *
42
+ * ## Which vendors need a marker at all
43
+ *
44
+ * Only two of them, and both take it on a message:
45
+ *
46
+ * - **Anthropic** — `anthropic.cacheControl`. Fully opt-in: no marker, no
47
+ * caching, at full price.
48
+ * - **Amazon Bedrock** — `bedrock.cachePoint`. Also fully opt-in.
49
+ *
50
+ * The OpenAI family caches prefixes server-side, unprompted, and exposes no
51
+ * breakpoint to place (`promptCacheKey` and `promptCacheRetention` are request
52
+ * settings for affinity and retention, not placement — a separate concern from
53
+ * this policy). Gemini caches implicitly by default, and its explicit mode is a
54
+ * cache RESOURCE created out of band and referenced by name, which is not a
55
+ * marker and does not fit a ttl policy.
56
+ *
57
+ * So both markers ride together on the one chosen message. A provider reads only
58
+ * its own namespace, thus the pair is free to whichever vendor is not serving
59
+ * the call, and the placement never learns which one that is.
60
+ *
61
+ * The asymmetry worth remembering: under-marking silently costs money, while
62
+ * over-marking hard-fails. Anthropic answers 400 past four breakpoints, and the
63
+ * legacy Bedrock integration answers 400 on a top-level `cache_control`. Every
64
+ * failure in this area comes from asking for too much.
24
65
  *
25
66
  * ## Cache defeaters — what silently kills the hit rate
26
67
  *
@@ -43,13 +84,27 @@
43
84
  * `prompts/briefing.ts`). Keep it that way — one interpolated id or path there
44
85
  * makes every step's ~20k-char prefix unique.
45
86
  *
46
- * ## Where caching is a no-op regardless
87
+ * ## What the cache-token metrics do and do not prove
47
88
  *
48
- * The Claude Max OAuth path does not honour cache directives, and the OSS CLI
49
- * defaults to routing through a local CLIProxyAPI on exactly that path. Caching
50
- * only engages against a direct API key or a gateway. The cache-token metrics
51
- * (`loop/metrics.ts`) are what tell the two apart at runtime — a flat-zero
52
- * `cache_read_tokens` counter is the symptom.
89
+ * Caching DOES engage on the Claude OAuth path the OSS CLI routes through a
90
+ * local CLIProxyAPI: measured, 2050-2151 cache-read tokens on the turns after
91
+ * the first. An earlier note here claimed that path ignores cache directives
92
+ * outright. It does not.
93
+ *
94
+ * But a read there is not evidence that OUR breakpoint earned it. While cloaking
95
+ * — which is what that path does — the proxy places breakpoints of its own on
96
+ * the system block, the first user message, the system message it relocates, and
97
+ * the last message. Those cache the prefix whatever the harness sends: with
98
+ * `promptCache: "off"` and no marker of ours on the wire, a repeat request still
99
+ * read 1902 tokens back.
100
+ *
101
+ * So on that path `cache_read_tokens` (`loop/metrics.ts`) reports whether the
102
+ * deployment gets caching at all, which is what the counter is for. It does not
103
+ * attribute the read, and it cannot tell a working harness breakpoint from a
104
+ * proxy that is quietly doing the work. Against a direct API key or a gateway,
105
+ * where nothing injects markers, the counter does mean exactly that — and a
106
+ * flat-zero read counter beside a non-zero write counter is still the signature
107
+ * of an endpoint billing for writes and serving no reads.
53
108
  */
54
109
  /**
55
110
  * The default policy: cache with the 5-minute TTL.
@@ -67,11 +122,138 @@ export const DEFAULT_PROMPT_CACHE = { ttl: "5m" };
67
122
  /**
68
123
  * Translate a neutral cache policy into provider wire options.
69
124
  *
70
- * Returns `undefined` for `"off"` so the caller can leave `providerOptions`
71
- * entirely unset rather than sending an empty bag.
125
+ * Returns `undefined` for `"off"` so the caller can leave the bag entirely unset
126
+ * rather than sending an empty one.
127
+ *
128
+ * Both vendors that take an explicit marker get one, in their own namespace. A
129
+ * provider reads only its own key, so the pair costs nothing to whichever one is
130
+ * not serving the call, and the placement needs no idea which vendor it is
131
+ * talking to. The two shapes differ in more than spelling: Anthropic marks the
132
+ * last content block of the message, while Bedrock appends a `cachePoint` block
133
+ * after it. Both end up at the same position in the prefix.
134
+ *
135
+ * The `satisfies` clauses are the point of naming the vendor types at all. This
136
+ * bag is a `Record<string, JSONObject>`, thus nothing here is checked by
137
+ * default, and a renamed key would compile and silently stop caching —
138
+ * `cacheControl` has already been renamed once, and the provider still accepts
139
+ * `cache_control` as its alias.
140
+ *
141
+ * CAUTION: this is the marker's VALUE, not its placement. Attaching it to a
142
+ * `ChatRequest` emits the invisible top-level field the module header warns
143
+ * about. Place it with {@link withPromptCacheBreakpoint}.
72
144
  */
73
145
  export function promptCacheProviderOptions(policy) {
74
146
  if (policy === "off")
75
147
  return undefined;
76
- return { anthropic: { cacheControl: { type: "ephemeral", ttl: policy.ttl } } };
148
+ const anthropic = { cacheControl: { type: "ephemeral", ttl: policy.ttl } };
149
+ const bedrock = { cachePoint: { type: "default", ttl: policy.ttl } };
150
+ return { anthropic, bedrock };
151
+ }
152
+ /**
153
+ * Every namespace that can carry a breakpoint, and the key it carries it under.
154
+ *
155
+ * The writer and the stripper both read this list, thus they cannot drift apart,
156
+ * and the one-breakpoint invariant covers each vendor rather than only the one
157
+ * that happens to be serving the call. `@ai-sdk/amazon-bedrock` reads either
158
+ * `bedrock` or `amazonBedrock`; its own documentation uses `bedrock`.
159
+ */
160
+ const BREAKPOINT_SITES = [
161
+ ["anthropic", "cacheControl"],
162
+ ["bedrock", "cachePoint"],
163
+ ];
164
+ /**
165
+ * Whether a message can HOST the breakpoint — that is, whether the marker put on
166
+ * it actually reaches the wire.
167
+ *
168
+ * The Anthropic provider resolves a message-level marker onto the message's last
169
+ * content part. A thinking block cannot carry `cache_control`, so a marker on an
170
+ * assistant message that ends in one is dropped by the provider: no error, no
171
+ * breakpoint, and a silent cache miss on every call after it. Walking back past
172
+ * such a message is what the server-side placement did for us before, so this
173
+ * keeps the behaviour rather than introducing a new failure mode.
174
+ *
175
+ * An empty content array hosts nothing either — there is no last part to mark.
176
+ * The content is read as `unknown` because the four message roles carry four
177
+ * different part unions and this predicate cares about exactly one field.
178
+ */
179
+ function canHostBreakpoint(message) {
180
+ const content = message.content;
181
+ if (typeof content === "string")
182
+ return content.length > 0;
183
+ if (!Array.isArray(content))
184
+ return false;
185
+ const last = content.at(-1);
186
+ if (typeof last !== "object" || last === null)
187
+ return false;
188
+ return last.type !== "reasoning";
189
+ }
190
+ /** The index of the last message that can host the breakpoint, or `-1` when none can. */
191
+ function lastHostIndex(messages) {
192
+ for (let i = messages.length - 1; i >= 0; i--) {
193
+ const message = messages[i];
194
+ if (message !== undefined && canHostBreakpoint(message))
195
+ return i;
196
+ }
197
+ return -1;
198
+ }
199
+ /** Whether this message already carries a cache marker in ANY breakpoint namespace. */
200
+ function carriesBreakpoint(message) {
201
+ return BREAKPOINT_SITES.some(([namespace, key]) => message.providerOptions?.[namespace]?.[key] !== undefined);
202
+ }
203
+ /** The same message with each namespace of `options` merged into its own bag. */
204
+ function withBreakpoint(message, options) {
205
+ const merged = { ...message.providerOptions };
206
+ for (const [namespace] of BREAKPOINT_SITES) {
207
+ const marker = options[namespace];
208
+ if (marker === undefined)
209
+ continue;
210
+ merged[namespace] = { ...message.providerOptions?.[namespace], ...marker };
211
+ }
212
+ return { ...message, providerOptions: merged };
213
+ }
214
+ /** The same message with every marker removed, and each namespace dropped when it held nothing else. */
215
+ function withoutBreakpoint(message) {
216
+ if (!carriesBreakpoint(message))
217
+ return message;
218
+ const remaining = { ...message.providerOptions };
219
+ for (const [namespace, key] of BREAKPOINT_SITES) {
220
+ const bag = remaining[namespace];
221
+ if (bag?.[key] === undefined)
222
+ continue;
223
+ const { [key]: _dropped, ...rest } = bag;
224
+ if (Object.keys(rest).length === 0)
225
+ delete remaining[namespace];
226
+ else
227
+ remaining[namespace] = rest;
228
+ }
229
+ return { ...message, providerOptions: remaining };
230
+ }
231
+ /**
232
+ * Place the request's ONE cache breakpoint, on the last message that can host it.
233
+ *
234
+ * Returns a copy — the caller's array is the transcript the loop keeps pushing
235
+ * onto and the host later persists, and a marker written into it would ride into
236
+ * the stored thread and come back on every later turn, one more breakpoint per
237
+ * turn until the request is refused. Only the request-shaped copy carries one.
238
+ *
239
+ * The placement is deliberately ROLLING, not pinned to the end of the stable
240
+ * prefix: within one run the loop appends an assistant reply and its tool results
241
+ * on every iteration, and a breakpoint that advances with them lets iteration N+1
242
+ * read back everything iteration N wrote. A pinned breakpoint would re-process
243
+ * the whole tool transcript uncached on every iteration, which is the opposite of
244
+ * what an agent loop needs.
245
+ *
246
+ * A marker already on any other message is REMOVED — the invariant is exactly one
247
+ * breakpoint per request, and a stored message that carries one from an older
248
+ * build (`memory/ai-sdk-message-storage.ts` reads `cache_control` back off a
249
+ * stored block) would otherwise spend a slot that the harness never budgeted.
250
+ * `"off"` strips and places nothing, so turning caching off really does send no
251
+ * directive at all.
252
+ */
253
+ export function withPromptCacheBreakpoint(messages, policy) {
254
+ const options = promptCacheProviderOptions(policy);
255
+ const target = options === undefined ? -1 : lastHostIndex(messages);
256
+ if (target < 0 && !messages.some(carriesBreakpoint))
257
+ return messages;
258
+ return messages.map((message, index) => (index === target && options !== undefined ? withBreakpoint(message, options) : withoutBreakpoint(message)));
77
259
  }
@@ -12,6 +12,7 @@
12
12
  *
13
13
  * See `openspec/changes/harness-dbos-runtime`.
14
14
  */
15
+ import { type DBOSConfig, type DLogger } from "@dbos-inc/dbos-sdk";
15
16
  import type { Pool } from "pg";
16
17
  import type { Logger } from "../lib/logger.js";
17
18
  /**
@@ -31,18 +32,60 @@ export interface DbosConfig {
31
32
  /** Stable per-pod executor id (e.g. `process.env.HOSTNAME ?? "local-dev"`). */
32
33
  readonly executorId: string;
33
34
  /**
34
- * SDK log verbosity (winston levels; the SDK's own default is `info`).
35
- * Interactive embedders pass `warn` so the launch banner and migration
36
- * chatter don't interleave with their own command output; the server root
37
- * omits it and keeps the informative default.
35
+ * Least severe SDK record forwarded to the host's `Logger` (winston level
36
+ * names; the SDK's own default is `info`). The SDK's lines ride the
37
+ * harness `Logger` seam under `[dbos.sdk]`, so this threshold is applied by
38
+ * the bridge, not by the SDK. Interactive embedders pass `warn` so the
39
+ * launch banner and migration chatter stay out of their logs; the server
40
+ * root omits it and keeps the informative default.
38
41
  */
39
42
  readonly logLevel?: string;
43
+ /**
44
+ * Emit DBOS workflow and step spans on the globally registered
45
+ * TracerProvider, and run every workflow and step body under an active
46
+ * OTel context. Default `false`.
47
+ *
48
+ * DBOS installs nothing of its own here: with no OTLP endpoint of its own it
49
+ * probes the global provider and, when one is registered, emits under the
50
+ * instrumentation scope `dbos-tracer` through that provider's processors.
51
+ * A host that registers no provider (the CLI passes a no-op
52
+ * `initTelemetry`) must leave this off, otherwise the SDK installs its own
53
+ * `BasicTracerProvider` and every span is created and never exported.
54
+ *
55
+ * Attribute names follow the `dbos.*` semantic-convention layout
56
+ * (`otelAttributeFormat: "semconv"`).
57
+ *
58
+ * Known SDK behavior a host must plan for:
59
+ * - a recovered workflow starts a new root trace, with no link to the trace
60
+ * of the original execution
61
+ * - every already-completed step re-emits a `cached=true` span on replay,
62
+ * so a restart with many in-flight steps bursts spans; size
63
+ * `OTEL_BSP_MAX_QUEUE_SIZE` for it
64
+ * - step span names embed the step, tool-use, or exec id
65
+ */
66
+ readonly tracingEnabled?: boolean;
40
67
  }
41
68
  /** DBOS lifecycle facts a host's readiness probe reports on. */
42
69
  export interface DbosState {
43
70
  launched: boolean;
44
71
  recoveryStarted: boolean;
45
72
  }
73
+ /**
74
+ * Bridge the SDK's internal logger onto the harness `Logger` seam, so the
75
+ * SDK's own lines (`Recovering N workflows…`, migration chatter) land in the
76
+ * host's sink instead of on the process console. The SDK does not filter by
77
+ * `logLevel` before it delegates to a custom logger, so the threshold is
78
+ * applied here. Records arrive as strings; an `Error` arrives as its message
79
+ * with the stack (and cause chain) in `metadata.stack`; inside a workflow or
80
+ * step body `metadata.span.attributes` carries the operation context, which
81
+ * rides as fields.
82
+ */
83
+ export declare function dbosSdkLogger(logger: Logger, logLevel: string | undefined): DLogger;
84
+ /**
85
+ * The SDK config `launchDbos` sets. Pure over `DbosConfig`, so a test can
86
+ * assert on the mapping without a launch.
87
+ */
88
+ export declare function dbosSdkConfig(config: DbosConfig, logger: Logger): DBOSConfig;
46
89
  /**
47
90
  * Launch DBOS. Idempotent — a second call is a no-op so tests that drive
48
91
  * the harness twice (or accidentally double-import) don't re-launch.
@@ -45,6 +45,76 @@ function systemDatabaseUrl(config) {
45
45
  const database = encodeURIComponent(config.dbName);
46
46
  return `postgresql://${user}:${password}@${host}:${port}/${database}?sslmode=${dbosSslMode(config.dbSslMode)}`;
47
47
  }
48
+ /** Winston severity ranks, ascending verbosity — the scale `DbosConfig.logLevel` names. */
49
+ const SDK_LOG_LEVEL_RANK = {
50
+ error: 0,
51
+ warn: 1,
52
+ info: 2,
53
+ http: 3,
54
+ verbose: 4,
55
+ debug: 5,
56
+ silly: 6,
57
+ };
58
+ const SDK_DEFAULT_LOG_LEVEL = "info";
59
+ /**
60
+ * Bridge the SDK's internal logger onto the harness `Logger` seam, so the
61
+ * SDK's own lines (`Recovering N workflows…`, migration chatter) land in the
62
+ * host's sink instead of on the process console. The SDK does not filter by
63
+ * `logLevel` before it delegates to a custom logger, so the threshold is
64
+ * applied here. Records arrive as strings; an `Error` arrives as its message
65
+ * with the stack (and cause chain) in `metadata.stack`; inside a workflow or
66
+ * step body `metadata.span.attributes` carries the operation context, which
67
+ * rides as fields.
68
+ */
69
+ export function dbosSdkLogger(logger, logLevel) {
70
+ const sink = logger.named("sdk");
71
+ const threshold = SDK_LOG_LEVEL_RANK[logLevel ?? SDK_DEFAULT_LOG_LEVEL] ?? SDK_LOG_LEVEL_RANK[SDK_DEFAULT_LOG_LEVEL];
72
+ const passes = (level) => SDK_LOG_LEVEL_RANK[level] <= threshold;
73
+ const fields = (metadata) => {
74
+ const attributes = metadata?.span?.attributes;
75
+ const stack = metadata?.stack;
76
+ if (attributes === undefined && stack === undefined)
77
+ return undefined;
78
+ return { ...attributes, ...(stack !== undefined ? { stack } : {}) };
79
+ };
80
+ const message = (entry) => (typeof entry === "string" ? entry : String(entry));
81
+ return {
82
+ debug: (entry, metadata) => {
83
+ if (passes("debug"))
84
+ sink.debug(message(entry), fields(metadata));
85
+ },
86
+ info: (entry, metadata) => {
87
+ if (passes("info"))
88
+ sink.info(message(entry), fields(metadata));
89
+ },
90
+ warn: (entry, metadata) => {
91
+ if (passes("warn"))
92
+ sink.warn(message(entry), fields(metadata));
93
+ },
94
+ error: (entry, metadata) => {
95
+ if (passes("error"))
96
+ sink.error(message(entry), fields(metadata));
97
+ },
98
+ };
99
+ }
100
+ /**
101
+ * The SDK config `launchDbos` sets. Pure over `DbosConfig`, so a test can
102
+ * assert on the mapping without a launch.
103
+ */
104
+ export function dbosSdkConfig(config, logger) {
105
+ return {
106
+ name: config.appName,
107
+ systemDatabaseUrl: systemDatabaseUrl(config),
108
+ systemDatabasePoolSize: DBOS_SYSTEM_POOL_SIZE,
109
+ executorID: config.executorId,
110
+ applicationVersion: config.applicationVersion,
111
+ adminPort: parseInt(config.adminPort, 10),
112
+ logLevel: config.logLevel,
113
+ logger: dbosSdkLogger(logger, config.logLevel),
114
+ tracingEnabled: config.tracingEnabled ?? false,
115
+ otelAttributeFormat: "semconv",
116
+ };
117
+ }
48
118
  /**
49
119
  * Launch DBOS. Idempotent — a second call is a no-op so tests that drive
50
120
  * the harness twice (or accidentally double-import) don't re-launch.
@@ -57,25 +127,17 @@ export async function launchDbos({ config, logger: injected }) {
57
127
  if (state.launched)
58
128
  return;
59
129
  const logger = injected.named("dbos");
60
- const executorID = config.executorId;
61
- const adminPort = parseInt(config.adminPort, 10);
62
- DBOS.setConfig({
63
- name: config.appName,
64
- systemDatabaseUrl: systemDatabaseUrl(config),
65
- systemDatabasePoolSize: DBOS_SYSTEM_POOL_SIZE,
66
- executorID,
67
- applicationVersion: config.applicationVersion,
68
- adminPort,
69
- logLevel: config.logLevel,
70
- });
130
+ const sdkConfig = dbosSdkConfig(config, logger);
131
+ DBOS.setConfig(sdkConfig);
71
132
  const start = performance.now();
72
133
  await DBOS.launch();
73
134
  state.launched = true;
74
135
  state.recoveryStarted = true;
75
136
  logger.info("launched", {
76
- executorID,
137
+ executorID: sdkConfig.executorID,
77
138
  applicationVersion: config.applicationVersion,
78
- adminPort,
139
+ adminPort: sdkConfig.adminPort,
140
+ tracingEnabled: sdkConfig.tracingEnabled,
79
141
  durationMs: Math.round(performance.now() - start),
80
142
  });
81
143
  }
@@ -1,15 +1,10 @@
1
1
  /**
2
- * Workflow-side OTel metrics → Middleware.io.
2
+ * Workflow-side OTel metrics.
3
3
  *
4
- * Two signals so 3-hour runs and pause-resume cascades are observable:
5
4
  * - cortex.workflow.parent.cancelled_children — counter, incremented once
6
5
  * per child the fail-fast or pause cascade reaps. A baseline near 0
7
6
  * means runs land cleanly; a spike means siblings keep colliding with
8
7
  * a single failing step.
9
- * - cortex.workflow.stream.bytes — counter, incremented by
10
- * the byte count of every UI part written onto the DBOS stream. The
11
- * leading indicator for 3-hour stream growth is monotonic — a runaway
12
- * emitter shows up as an unbounded slope.
13
8
  *
14
9
  * Instruments are lazy so the workflow body binds to whichever
15
10
  * `MeterProvider` is globally registered at startup.
@@ -23,15 +18,5 @@
23
18
  export declare function recordCancelledChild(args: {
24
19
  readonly cause: "fail_fast" | "budget_exceeded" | "external_cancel";
25
20
  }): void;
26
- /**
27
- * Record one UI-part write. `bytes` is the serialised JSON size of the
28
- * part as it lands on the stream; `kind` is the part `type` for
29
- * dashboarding. The workflow body wraps its `DBOS.writeStream` call site
30
- * so a single emitter records once per part.
31
- */
32
- export declare function recordStreamWrite(args: {
33
- readonly bytes: number;
34
- readonly kind: string;
35
- }): void;
36
21
  /** Test hook — drop memoised instruments to rebind a fresh MeterProvider. */
37
22
  export declare function __resetWorkflowMetricsForTest(): void;
@@ -1,15 +1,10 @@
1
1
  /**
2
- * Workflow-side OTel metrics → Middleware.io.
2
+ * Workflow-side OTel metrics.
3
3
  *
4
- * Two signals so 3-hour runs and pause-resume cascades are observable:
5
4
  * - cortex.workflow.parent.cancelled_children — counter, incremented once
6
5
  * per child the fail-fast or pause cascade reaps. A baseline near 0
7
6
  * means runs land cleanly; a spike means siblings keep colliding with
8
7
  * a single failing step.
9
- * - cortex.workflow.stream.bytes — counter, incremented by
10
- * the byte count of every UI part written onto the DBOS stream. The
11
- * leading indicator for 3-hour stream growth is monotonic — a runaway
12
- * emitter shows up as an unbounded slope.
13
8
  *
14
9
  * Instruments are lazy so the workflow body binds to whichever
15
10
  * `MeterProvider` is globally registered at startup.
@@ -23,10 +18,6 @@ function getInstruments() {
23
18
  cancelledChildren: meter.createCounter("cortex.workflow.parent.cancelled_children", {
24
19
  description: "Children cancelled by the parent's fail-fast or pause cascade",
25
20
  }),
26
- streamBytes: meter.createCounter("cortex.workflow.stream.bytes", {
27
- description: "Bytes written to the parent's DBOS UI-parts stream — proxy for " + "3-hour stream growth",
28
- unit: "By",
29
- }),
30
21
  };
31
22
  }
32
23
  return instruments;
@@ -40,15 +31,6 @@ function getInstruments() {
40
31
  export function recordCancelledChild(args) {
41
32
  getInstruments().cancelledChildren.add(1, { cause: args.cause });
42
33
  }
43
- /**
44
- * Record one UI-part write. `bytes` is the serialised JSON size of the
45
- * part as it lands on the stream; `kind` is the part `type` for
46
- * dashboarding. The workflow body wraps its `DBOS.writeStream` call site
47
- * so a single emitter records once per part.
48
- */
49
- export function recordStreamWrite(args) {
50
- getInstruments().streamBytes.add(args.bytes, { kind: args.kind });
51
- }
52
34
  /** Test hook — drop memoised instruments to rebind a fresh MeterProvider. */
53
35
  export function __resetWorkflowMetricsForTest() {
54
36
  instruments = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.35.0",
3
+ "version": "0.35.2",
4
4
  "description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -94,6 +94,7 @@
94
94
  "uuid": "^11.1.1"
95
95
  },
96
96
  "devDependencies": {
97
+ "@ai-sdk/amazon-bedrock": "^5.0.74",
97
98
  "@eslint/js": "^10.0.1",
98
99
  "@types/bun": "latest",
99
100
  "@types/dockerode": "^3.3.47",