@inflexa-ai/harness 0.36.2 → 0.36.4

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Step-summary OTel metrics — Middleware.io routing follows the global
2
+ * Step-summary OTel metrics — export follows the global
3
3
  * meter provider (see `harness/lib/otel.ts`).
4
4
  *
5
5
  * cortex.summary.null_count — counter, incremented when a step-summary
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Step-summary OTel metrics — Middleware.io routing follows the global
2
+ * Step-summary OTel metrics — export follows the global
3
3
  * meter provider (see `harness/lib/otel.ts`).
4
4
  *
5
5
  * cortex.summary.null_count — counter, incremented when a step-summary
@@ -5,7 +5,7 @@
5
5
  * DBOS names a workflow span after the registered workflow and a step span
6
6
  * after the step function, and it runs each body under its span, so every
7
7
  * child (a step, a patched fetch, a pg query) sits under the workflow span.
8
- * Three shapes of that output are noise in a trace store, and each is stopped
8
+ * Four shapes of that output are noise in a trace store, and each is stopped
9
9
  * in the process, before export, rather than in a collector downstream. The
10
10
  * decisions live where the workflows and steps are written; this module holds
11
11
  * the mechanism only:
@@ -23,9 +23,21 @@
23
23
  * name itself is untouched: DBOS compares it against the recorded name on
24
24
  * replay and throws `DBOSUnexpectedStepError` on a mismatch, so a rename
25
25
  * would break every workflow in flight across a deploy.
26
+ * - A step whose span only repeats the spans under it runs inside
27
+ * `passThroughSpan(name, ...)`. `HarnessTracerProvider` gives that span as
28
+ * a pass-through: it records nothing, and each child attaches to the span
29
+ * that was active when the step started. `passThroughTracer` applies the
30
+ * same mechanism to a tracer that a library takes as an option.
31
+ *
32
+ * A pass-through is the only way to omit a span in the middle of a tree. The
33
+ * SDK fixes the parent of a child when the child starts, so a processor that
34
+ * drops the span at `onEnd` leaves its children with a parent that was never
35
+ * exported. A sampler is no better: `ParentBased` sampling gives the children
36
+ * the same decision, and they are not recorded either.
26
37
  */
27
- import { type Attributes, type Context } from "@opentelemetry/api";
38
+ import { type Attributes, type Context, type SpanOptions, type Tracer, type TracerOptions } from "@opentelemetry/api";
28
39
  import { type ReadableSpan, type Sampler, type Span, type SpanProcessor } from "@opentelemetry/sdk-trace-base";
40
+ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
29
41
  /**
30
42
  * Declare a registered DBOS workflow untraced: no root span for it is
31
43
  * recorded, and no child span under it. Call it next to the
@@ -60,6 +72,48 @@ export declare const ATTR_INFLEXA_ATTEMPT = "inflexa.attempt";
60
72
  * leaves a missing or non-recording span alone.
61
73
  */
62
74
  export declare function stableSpan(dbosName: string, name: string, attributes: Attributes): void;
75
+ /**
76
+ * Send a request to sandbox-server with the active trace context in its
77
+ * headers and with tracing suppressed, so the host's HTTP instrumentation
78
+ * writes no client span. Sandbox-server records no spans and echoes the exec
79
+ * request's `traceparent` on its callbacks to the host, so a client span would
80
+ * show the host calling itself; the step span around the call already covers it.
81
+ */
82
+ export declare function untracedFetch(fetchImpl: typeof fetch, url: string, init: RequestInit & {
83
+ headers: Record<string, string>;
84
+ }): Promise<Response>;
85
+ /**
86
+ * Decides whether the span that `startSpan` is about to start passes through.
87
+ * `context` is the parent context of that span.
88
+ */
89
+ export type PassThroughRule = (name: string, options: SpanOptions, context: Context) => boolean;
90
+ /**
91
+ * Wrap `tracer` so that a span that `passesThrough` selects is not written.
92
+ * In its place the tracer gives `trace.wrapSpanContext` of the parent span
93
+ * context. That span records nothing, and a span started under it becomes a
94
+ * child of the parent. A span with no valid parent, or a span that asks to be
95
+ * a root, starts as usual, because a pass-through there would make each child
96
+ * a root of its own trace.
97
+ */
98
+ export declare function passThroughTracer(tracer: Tracer, passesThrough: PassThroughRule): Tracer;
99
+ /**
100
+ * Run `fn` so that a span named `name` that starts inside it is a
101
+ * pass-through: `HarnessTracerProvider` writes no span for it, and the
102
+ * children of that span attach to the span that is active now. Wrap the call
103
+ * that starts the span, for example a `DBOS.runStep` with the step name
104
+ * `name`. A span with a different name starts as usual.
105
+ */
106
+ export declare function passThroughSpan<T>(name: string, fn: () => T): T;
107
+ /**
108
+ * The TracerProvider of the harness. Each tracer that it gives obeys
109
+ * `passThroughSpan`. The API keeps the first provider that is registered, and
110
+ * DBOS asks the API for its tracer at each span, so the rule applies to the
111
+ * DBOS step spans only because this provider is the registered one.
112
+ */
113
+ export declare class HarnessTracerProvider extends NodeTracerProvider {
114
+ private readonly passThroughTracers;
115
+ getTracer(name: string, version?: string, options?: TracerOptions): Tracer;
116
+ }
63
117
  /**
64
118
  * Sits in front of the exporting processor and forwards every span except a
65
119
  * replayed one (`cached=true`), which it drops at `onEnd`.
@@ -5,7 +5,7 @@
5
5
  * DBOS names a workflow span after the registered workflow and a step span
6
6
  * after the step function, and it runs each body under its span, so every
7
7
  * child (a step, a patched fetch, a pg query) sits under the workflow span.
8
- * Three shapes of that output are noise in a trace store, and each is stopped
8
+ * Four shapes of that output are noise in a trace store, and each is stopped
9
9
  * in the process, before export, rather than in a collector downstream. The
10
10
  * decisions live where the workflows and steps are written; this module holds
11
11
  * the mechanism only:
@@ -23,9 +23,22 @@
23
23
  * name itself is untouched: DBOS compares it against the recorded name on
24
24
  * replay and throws `DBOSUnexpectedStepError` on a mismatch, so a rename
25
25
  * would break every workflow in flight across a deploy.
26
+ * - A step whose span only repeats the spans under it runs inside
27
+ * `passThroughSpan(name, ...)`. `HarnessTracerProvider` gives that span as
28
+ * a pass-through: it records nothing, and each child attaches to the span
29
+ * that was active when the step started. `passThroughTracer` applies the
30
+ * same mechanism to a tracer that a library takes as an option.
31
+ *
32
+ * A pass-through is the only way to omit a span in the middle of a tree. The
33
+ * SDK fixes the parent of a child when the child starts, so a processor that
34
+ * drops the span at `onEnd` leaves its children with a parent that was never
35
+ * exported. A sampler is no better: `ParentBased` sampling gives the children
36
+ * the same decision, and they are not recorded either.
26
37
  */
27
- import { trace } from "@opentelemetry/api";
38
+ import { context, createContextKey, propagation, trace, } from "@opentelemetry/api";
39
+ import { suppressTracing } from "@opentelemetry/core";
28
40
  import { ParentBasedSampler, SamplingDecision, } from "@opentelemetry/sdk-trace-base";
41
+ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
29
42
  /**
30
43
  * Names of the DBOS workflows whose traces are not recorded. A registering
31
44
  * module adds its own name at registration, which runs at boot inside
@@ -92,6 +105,84 @@ export function stableSpan(dbosName, name, attributes) {
92
105
  span.updateName(name);
93
106
  span.setAttributes(attributes);
94
107
  }
108
+ /**
109
+ * Send a request to sandbox-server with the active trace context in its
110
+ * headers and with tracing suppressed, so the host's HTTP instrumentation
111
+ * writes no client span. Sandbox-server records no spans and echoes the exec
112
+ * request's `traceparent` on its callbacks to the host, so a client span would
113
+ * show the host calling itself; the step span around the call already covers it.
114
+ */
115
+ export function untracedFetch(fetchImpl, url, init) {
116
+ const active = context.active();
117
+ const headers = { ...init.headers };
118
+ propagation.inject(active, headers);
119
+ return context.with(suppressTracing(active), () => fetchImpl(url, { ...init, headers }));
120
+ }
121
+ /**
122
+ * Wrap `tracer` so that a span that `passesThrough` selects is not written.
123
+ * In its place the tracer gives `trace.wrapSpanContext` of the parent span
124
+ * context. That span records nothing, and a span started under it becomes a
125
+ * child of the parent. A span with no valid parent, or a span that asks to be
126
+ * a root, starts as usual, because a pass-through there would make each child
127
+ * a root of its own trace.
128
+ */
129
+ export function passThroughTracer(tracer, passesThrough) {
130
+ return new PassThroughTracer(tracer, passesThrough);
131
+ }
132
+ class PassThroughTracer {
133
+ inner;
134
+ passesThrough;
135
+ constructor(inner, passesThrough) {
136
+ this.inner = inner;
137
+ this.passesThrough = passesThrough;
138
+ }
139
+ startSpan(name, options = {}, parentContext = context.active()) {
140
+ const parent = options.root === true ? undefined : trace.getSpanContext(parentContext);
141
+ if (parent !== undefined && trace.isSpanContextValid(parent) && this.passesThrough(name, options, parentContext)) {
142
+ return trace.wrapSpanContext(parent);
143
+ }
144
+ return this.inner.startSpan(name, options, parentContext);
145
+ }
146
+ // The inner tracer cannot take this call: its own `startActiveSpan` calls its own `startSpan`, past the rule.
147
+ startActiveSpan(name, ...rest) {
148
+ const fn = rest[rest.length - 1];
149
+ const options = rest.length > 1 ? rest[0] : {};
150
+ const parentContext = (rest.length > 2 ? rest[1] : undefined) ?? context.active();
151
+ const span = this.startSpan(name, options, parentContext);
152
+ return context.with(trace.setSpan(parentContext, span), () => fn(span));
153
+ }
154
+ }
155
+ /** The name of the span that the enclosing `passThroughSpan` declared. */
156
+ const PASS_THROUGH_SPAN_NAME = createContextKey("inflexa.pass_through_span_name");
157
+ /**
158
+ * Run `fn` so that a span named `name` that starts inside it is a
159
+ * pass-through: `HarnessTracerProvider` writes no span for it, and the
160
+ * children of that span attach to the span that is active now. Wrap the call
161
+ * that starts the span, for example a `DBOS.runStep` with the step name
162
+ * `name`. A span with a different name starts as usual.
163
+ */
164
+ export function passThroughSpan(name, fn) {
165
+ return context.with(context.active().setValue(PASS_THROUGH_SPAN_NAME, name), fn);
166
+ }
167
+ const declaredPassThrough = (name, _options, parentContext) => parentContext.getValue(PASS_THROUGH_SPAN_NAME) === name;
168
+ /**
169
+ * The TracerProvider of the harness. Each tracer that it gives obeys
170
+ * `passThroughSpan`. The API keeps the first provider that is registered, and
171
+ * DBOS asks the API for its tracer at each span, so the rule applies to the
172
+ * DBOS step spans only because this provider is the registered one.
173
+ */
174
+ export class HarnessTracerProvider extends NodeTracerProvider {
175
+ passThroughTracers = new WeakMap();
176
+ getTracer(name, version, options) {
177
+ const tracer = super.getTracer(name, version, options);
178
+ let wrapped = this.passThroughTracers.get(tracer);
179
+ if (wrapped === undefined) {
180
+ wrapped = passThroughTracer(tracer, declaredPassThrough);
181
+ this.passThroughTracers.set(tracer, wrapped);
182
+ }
183
+ return wrapped;
184
+ }
185
+ }
95
186
  /**
96
187
  * Sits in front of the exporting processor and forwards every span except a
97
188
  * replayed one (`cached=true`), which it drops at `onEnd`.
@@ -4,13 +4,15 @@
4
4
  * Exported as `initOtel()` and called explicitly from the host's boot sequence
5
5
  * so the bundler cannot tree-shake it (side-effect-only imports get dropped).
6
6
  *
7
- * Traces: NodeTracerProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT
8
- * is set. The provider carries the span policy of otel-spans.ts: the
9
- * root span of a workflow declared with `untracedWorkflow` is not
10
- * recorded (and its children with it), a replayed `cached=true` span
11
- * is dropped before export, and a step body that calls `stableSpan`
12
- * is exported under a stable name with its id in an `inflexa.*`
13
- * attribute.
7
+ * Traces: HarnessTracerProvider exports to OTLP when
8
+ * OTEL_EXPORTER_OTLP_ENDPOINT is set. The provider carries the span
9
+ * policy of otel-spans.ts: the root span of a workflow declared with
10
+ * `untracedWorkflow` is not recorded (and its children with it), a
11
+ * replayed `cached=true` span is dropped before export, a step body
12
+ * that calls `stableSpan` is exported under a stable name with its id
13
+ * in an `inflexa.*` attribute, and a span declared with
14
+ * `passThroughSpan` is not written, its children attached to its
15
+ * parent.
14
16
  *
15
17
  * Metrics: MeterProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is
16
18
  * set and OTEL_METRICS_EXPORTER is not `none`. The export interval is
@@ -21,11 +23,13 @@
21
23
  * to their record sites. Every instrument binds lazily to the
22
24
  * provider registered here.
23
25
  *
24
- * Resource: the host's `serviceName` / `serviceVersion` merged with the SDK
25
- * env detector (`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`);
26
- * the environment wins on a conflict, so a deployment can set
26
+ * Resource: the SDK's default resource (`telemetry.sdk.*`), under the host's
27
+ * `serviceName` / `serviceVersion`, under the SDK env detector
28
+ * (`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`). A later layer
29
+ * wins on a conflict, so a deployment can set
27
30
  * `deployment.environment.name` or override the service name without
28
- * a code change. Traces and metrics share the one resource.
31
+ * a code change, and the default `unknown_service` name never
32
+ * reaches an export. Traces and metrics share the one resource.
29
33
  *
30
34
  * Note: OTEL's instrumentation-http patches node:http but does not cover
31
35
  * Hono's request handling or Node 22's undici-based globalThis.fetch,
package/dist/lib/otel.js CHANGED
@@ -4,13 +4,15 @@
4
4
  * Exported as `initOtel()` and called explicitly from the host's boot sequence
5
5
  * so the bundler cannot tree-shake it (side-effect-only imports get dropped).
6
6
  *
7
- * Traces: NodeTracerProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT
8
- * is set. The provider carries the span policy of otel-spans.ts: the
9
- * root span of a workflow declared with `untracedWorkflow` is not
10
- * recorded (and its children with it), a replayed `cached=true` span
11
- * is dropped before export, and a step body that calls `stableSpan`
12
- * is exported under a stable name with its id in an `inflexa.*`
13
- * attribute.
7
+ * Traces: HarnessTracerProvider exports to OTLP when
8
+ * OTEL_EXPORTER_OTLP_ENDPOINT is set. The provider carries the span
9
+ * policy of otel-spans.ts: the root span of a workflow declared with
10
+ * `untracedWorkflow` is not recorded (and its children with it), a
11
+ * replayed `cached=true` span is dropped before export, a step body
12
+ * that calls `stableSpan` is exported under a stable name with its id
13
+ * in an `inflexa.*` attribute, and a span declared with
14
+ * `passThroughSpan` is not written, its children attached to its
15
+ * parent.
14
16
  *
15
17
  * Metrics: MeterProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is
16
18
  * set and OTEL_METRICS_EXPORTER is not `none`. The export interval is
@@ -21,11 +23,13 @@
21
23
  * to their record sites. Every instrument binds lazily to the
22
24
  * provider registered here.
23
25
  *
24
- * Resource: the host's `serviceName` / `serviceVersion` merged with the SDK
25
- * env detector (`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`);
26
- * the environment wins on a conflict, so a deployment can set
26
+ * Resource: the SDK's default resource (`telemetry.sdk.*`), under the host's
27
+ * `serviceName` / `serviceVersion`, under the SDK env detector
28
+ * (`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`). A later layer
29
+ * wins on a conflict, so a deployment can set
27
30
  * `deployment.environment.name` or override the service name without
28
- * a code change. Traces and metrics share the one resource.
31
+ * a code change, and the default `unknown_service` name never
32
+ * reaches an export. Traces and metrics share the one resource.
29
33
  *
30
34
  * Note: OTEL's instrumentation-http patches node:http but does not cover
31
35
  * Hono's request handling or Node 22's undici-based globalThis.fetch,
@@ -34,14 +38,13 @@
34
38
  */
35
39
  import { context, propagation, metrics, trace } from "@opentelemetry/api";
36
40
  import { createNoopLogger } from "./console-logger.js";
37
- import { createHarnessSampler, DbosSpanProcessor } from "./otel-spans.js";
41
+ import { createHarnessSampler, DbosSpanProcessor, HarnessTracerProvider } from "./otel-spans.js";
38
42
  import { W3CTraceContextPropagator } from "@opentelemetry/core";
39
43
  import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
40
44
  import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
41
- import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
42
45
  import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
43
46
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
44
- import { detectResources, envDetector, resourceFromAttributes } from "@opentelemetry/resources";
47
+ import { defaultResource, detectResources, envDetector, resourceFromAttributes } from "@opentelemetry/resources";
45
48
  import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
46
49
  /** The SDK's own default when neither the host nor the environment says otherwise. */
47
50
  export const DEFAULT_METRIC_EXPORT_INTERVAL_MS = 60_000;
@@ -75,9 +78,13 @@ function buildResource(options) {
75
78
  };
76
79
  if (options.serviceVersion)
77
80
  manual[ATTR_SERVICE_VERSION] = options.serviceVersion;
78
- // `merge` gives precedence to the argument: the environment overrides the
79
- // host's values, which is the OTel SDK convention for env configuration.
80
- return resourceFromAttributes(manual).merge(detectResources({ detectors: [envDetector] }));
81
+ // `merge` gives precedence to the argument. A provider that gets an explicit
82
+ // resource does not add the SDK default itself, so it is the bottom layer here.
83
+ // The environment overrides the host's values, which is the OTel SDK
84
+ // convention for env configuration.
85
+ return defaultResource()
86
+ .merge(resourceFromAttributes(manual))
87
+ .merge(detectResources({ detectors: [envDetector] }));
81
88
  }
82
89
  /**
83
90
  * Initialize the OpenTelemetry SDK. Must be called before any code that
@@ -104,7 +111,7 @@ export function initOtel(options = {}) {
104
111
  const base = endpoint.replace(/\/+$/, "");
105
112
  spanProcessors.push(new DbosSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter({ url: `${base}/v1/traces` }))));
106
113
  }
107
- const tracerProvider = new NodeTracerProvider({
114
+ const tracerProvider = new HarnessTracerProvider({
108
115
  resource,
109
116
  sampler: createHarnessSampler(),
110
117
  spanProcessors,
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The shape of a Zod validation failure, with no value of the input in it.
3
+ *
4
+ * The span and the log of a rejected tool call may not carry the arguments of
5
+ * the model. A Zod `message` can quote the input, and so can the `keys` of an
6
+ * `unrecognized_keys` issue and the string segments of a path under a record.
7
+ * Thus each entry is built only from parts that the schema defines:
8
+ *
9
+ * - the path, where a string segment is kept only when the schema declares
10
+ * that key, and each other string segment becomes `*`. An array index is
11
+ * kept as a number.
12
+ * - the issue `code`.
13
+ * - schema-side detail: the `expected` type, the `origin` and bound of a size
14
+ * check, the string `format`, the divisor, the allowed literals, and the
15
+ * discriminator of a union. For `unrecognized_keys`, only the count of keys.
16
+ *
17
+ * Example: `runId: invalid_type (expected string)`.
18
+ */
19
+ import type { z } from "zod";
20
+ /** Describe the issues of `error` against `schema`, one entry for each issue. */
21
+ export declare function describeZodIssueShapes(error: z.ZodError, schema: z.ZodType): string[];
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The shape of a Zod validation failure, with no value of the input in it.
3
+ *
4
+ * The span and the log of a rejected tool call may not carry the arguments of
5
+ * the model. A Zod `message` can quote the input, and so can the `keys` of an
6
+ * `unrecognized_keys` issue and the string segments of a path under a record.
7
+ * Thus each entry is built only from parts that the schema defines:
8
+ *
9
+ * - the path, where a string segment is kept only when the schema declares
10
+ * that key, and each other string segment becomes `*`. An array index is
11
+ * kept as a number.
12
+ * - the issue `code`.
13
+ * - schema-side detail: the `expected` type, the `origin` and bound of a size
14
+ * check, the string `format`, the divisor, the allowed literals, and the
15
+ * discriminator of a union. For `unrecognized_keys`, only the count of keys.
16
+ *
17
+ * Example: `runId: invalid_type (expected string)`.
18
+ */
19
+ /** The most issues that one failure describes. */
20
+ const MAX_ISSUES = 10;
21
+ /** The cap of one entry, in characters. */
22
+ const MAX_ENTRY_CHARS = 200;
23
+ /** Describe the issues of `error` against `schema`, one entry for each issue. */
24
+ export function describeZodIssueShapes(error, schema) {
25
+ const entries = error.issues.slice(0, MAX_ISSUES).map((issue) => {
26
+ const detail = issueDetail(issue);
27
+ const entry = `${schemaPath(schema, issue.path)}: ${issue.code}${detail === undefined ? "" : ` (${detail})`}`;
28
+ return entry.length > MAX_ENTRY_CHARS ? `${entry.slice(0, MAX_ENTRY_CHARS - 1)}…` : entry;
29
+ });
30
+ const rest = error.issues.length - MAX_ISSUES;
31
+ if (rest > 0)
32
+ entries.push(`+${rest} more issues`);
33
+ return entries;
34
+ }
35
+ function issueDetail(issue) {
36
+ switch (issue.code) {
37
+ case "invalid_type":
38
+ return `expected ${issue.expected}`;
39
+ case "too_small":
40
+ return `${issue.origin} ${issue.exact === true ? "exactly" : "min"} ${issue.minimum}`;
41
+ case "too_big":
42
+ return `${issue.origin} ${issue.exact === true ? "exactly" : "max"} ${issue.maximum}`;
43
+ case "invalid_format":
44
+ return `format ${issue.format}`;
45
+ case "not_multiple_of":
46
+ return `divisor ${issue.divisor}`;
47
+ case "unrecognized_keys":
48
+ return `${issue.keys.length} keys`;
49
+ case "invalid_value":
50
+ return `expected ${issue.values.map(String).join("|")}`;
51
+ case "invalid_union":
52
+ return issue.discriminator === undefined ? undefined : `discriminator ${issue.discriminator}`;
53
+ default:
54
+ return undefined;
55
+ }
56
+ }
57
+ /** The path of an issue, with each string segment that the schema does not declare replaced by `*`. */
58
+ function schemaPath(schema, path) {
59
+ if (path.length === 0)
60
+ return "(root)";
61
+ let current = schema;
62
+ const segments = path.map((segment) => {
63
+ if (typeof segment === "number") {
64
+ current = current === undefined ? undefined : elementOf(current, segment);
65
+ return String(segment);
66
+ }
67
+ const field = current === undefined || typeof segment !== "string" ? undefined : fieldOf(current, segment);
68
+ current = field;
69
+ return field === undefined ? "*" : String(segment);
70
+ });
71
+ return segments.join(".");
72
+ }
73
+ /** The schema of the declared field `key`, looking through wrappers and unions. */
74
+ function fieldOf(schema, key) {
75
+ const def = unwrap(schema)._zod.def;
76
+ if (def.type === "object")
77
+ return Object.hasOwn(def.shape, key) ? def.shape[key] : undefined;
78
+ if (def.type === "union") {
79
+ for (const option of def.options) {
80
+ const field = fieldOf(option, key);
81
+ if (field !== undefined)
82
+ return field;
83
+ }
84
+ }
85
+ if (def.type === "intersection")
86
+ return fieldOf(def.left, key) ?? fieldOf(def.right, key);
87
+ return undefined;
88
+ }
89
+ function elementOf(schema, index) {
90
+ const def = unwrap(schema)._zod.def;
91
+ if (def.type === "array")
92
+ return def.element;
93
+ if (def.type === "tuple")
94
+ return def.items[index] ?? def.rest ?? undefined;
95
+ return undefined;
96
+ }
97
+ function unwrap(schema) {
98
+ let current = schema;
99
+ for (let depth = 0; depth < 16; depth++) {
100
+ const def = current._zod.def;
101
+ switch (def.type) {
102
+ case "optional":
103
+ case "nullable":
104
+ case "default":
105
+ case "prefault":
106
+ case "nonoptional":
107
+ case "catch":
108
+ case "readonly":
109
+ current = def.innerType;
110
+ break;
111
+ case "pipe":
112
+ current = def.in;
113
+ break;
114
+ case "lazy":
115
+ current = def.getter();
116
+ break;
117
+ default:
118
+ return current;
119
+ }
120
+ }
121
+ return current;
122
+ }
@@ -4,6 +4,11 @@
4
4
  * SDK traces the model calls (`providers/ai-sdk.ts`). It never sees the loop or
5
5
  * the tools, because the harness runs both itself.
6
6
  *
7
+ * The root of a turn opens its own trace, and a link carries it back to the run.
8
+ * A run lasts hours and writes tens of thousands of spans, thus one trace of a
9
+ * whole run passes the size limit of the trace store and no reader can follow
10
+ * it. `inflexa.run_id` holds the turns of one run together.
11
+ *
7
12
  * An `execute_tool` span is ERROR when our code fails the call. A failure that a
8
13
  * tool returns as data, for example a sandbox command that exits non-zero, is
9
14
  * normal iteration of the agent, and its span stays OK.
@@ -17,8 +22,11 @@ interface AgentRun {
17
22
  readonly cappedOut: boolean;
18
23
  };
19
24
  }
20
- /** Run one agent loop under an `invoke_agent` span. */
21
- export declare function traceAgentRun<T extends AgentRun>(agentName: string, session: AgentSession, run: () => Promise<T>): Promise<T>;
25
+ /**
26
+ * Run one agent loop under an `invoke_agent` span. A turn root starts a new
27
+ * trace; a loop under a turn stays in the trace of that turn.
28
+ */
29
+ export declare function traceAgentRun<T extends AgentRun>(agentName: string, session: AgentSession, turnRoot: boolean, run: () => Promise<T>): Promise<T>;
22
30
  /** Run one tool call under an `execute_tool` span that records how the call ended. */
23
31
  export declare function traceToolCall<T>(call: Pick<ToolCallPart, "toolName" | "toolCallId">, run: () => Promise<T>, outcomeOf: (value: T) => ToolOutcome): Promise<T>;
24
32
  /**
@@ -31,4 +39,9 @@ export declare function recordToolException(err: Error): void;
31
39
  * span. `message` is text that our tool wrote, never text from the model.
32
40
  */
33
41
  export declare function labelToolFailure(type: string, message?: string): void;
42
+ /**
43
+ * Label a tool call whose input failed validation on the active `execute_tool`
44
+ * span. `issues` is the shape of the failure, with no value of the input.
45
+ */
46
+ export declare function labelToolValidationFailure(issues: readonly string[]): void;
34
47
  export {};
@@ -4,15 +4,29 @@
4
4
  * SDK traces the model calls (`providers/ai-sdk.ts`). It never sees the loop or
5
5
  * the tools, because the harness runs both itself.
6
6
  *
7
+ * The root of a turn opens its own trace, and a link carries it back to the run.
8
+ * A run lasts hours and writes tens of thousands of spans, thus one trace of a
9
+ * whole run passes the size limit of the trace store and no reader can follow
10
+ * it. `inflexa.run_id` holds the turns of one run together.
11
+ *
7
12
  * An `execute_tool` span is ERROR when our code fails the call. A failure that a
8
13
  * tool returns as data, for example a sandbox command that exits non-zero, is
9
14
  * normal iteration of the agent, and its span stays OK.
10
15
  */
11
- import { SpanStatusCode, trace } from "@opentelemetry/api";
12
- /** Run one agent loop under an `invoke_agent` span. */
13
- export function traceAgentRun(agentName, session, run) {
14
- const attributes = { "gen_ai.operation.name": "invoke_agent", "gen_ai.agent.name": agentName, ...runFrameAttributes(session) };
15
- return withSpan(`invoke_agent ${agentName}`, attributes, async (span) => {
16
+ import { context, SpanStatusCode, trace, TraceFlags } from "@opentelemetry/api";
17
+ import { isTracingSuppressed } from "@opentelemetry/core";
18
+ /**
19
+ * Run one agent loop under an `invoke_agent` span. A turn root starts a new
20
+ * trace; a loop under a turn stays in the trace of that turn.
21
+ */
22
+ export function traceAgentRun(agentName, session, turnRoot, run) {
23
+ const attributes = {
24
+ "gen_ai.operation.name": "invoke_agent",
25
+ "gen_ai.agent.name": agentName,
26
+ ...conversationAttributes(session),
27
+ ...runFrameAttributes(session),
28
+ };
29
+ return withSpan(`invoke_agent ${agentName}`, turnRoot ? turnRootOptions(attributes) : { attributes }, async (span) => {
16
30
  const result = await run();
17
31
  span.setAttributes({ "gen_ai.response.finish_reasons": [result.finish.reason], "inflexa.agent.capped_out": result.finish.cappedOut });
18
32
  return result;
@@ -21,7 +35,7 @@ export function traceAgentRun(agentName, session, run) {
21
35
  /** Run one tool call under an `execute_tool` span that records how the call ended. */
22
36
  export function traceToolCall(call, run, outcomeOf) {
23
37
  const attributes = { "gen_ai.operation.name": "execute_tool", "gen_ai.tool.name": call.toolName, "gen_ai.tool.call.id": call.toolCallId };
24
- return withSpan(`execute_tool ${call.toolName}`, attributes, async (span) => {
38
+ return withSpan(`execute_tool ${call.toolName}`, { attributes }, async (span) => {
25
39
  const value = await run();
26
40
  const outcome = outcomeOf(value);
27
41
  span.setAttribute("inflexa.tool.outcome", outcome);
@@ -55,14 +69,42 @@ export function labelToolFailure(type, message) {
55
69
  if (message !== undefined)
56
70
  span.setAttribute("inflexa.tool.error", message.slice(0, TOOL_ERROR_MAX_CHARS));
57
71
  }
72
+ /**
73
+ * Label a tool call whose input failed validation on the active `execute_tool`
74
+ * span. `issues` is the shape of the failure, with no value of the input.
75
+ */
76
+ export function labelToolValidationFailure(issues) {
77
+ const span = trace.getActiveSpan();
78
+ if (span === undefined)
79
+ return;
80
+ span.setAttributes({ "error.type": "validation", "inflexa.tool.validation.issues": [...issues] });
81
+ }
58
82
  function errorTypeOf(err) {
59
83
  const code = "code" in err ? err.code : undefined;
60
84
  if ((typeof code === "string" && code !== "") || typeof code === "number")
61
85
  return String(code);
62
86
  return err.name === "" ? "_OTHER" : err.name;
63
87
  }
64
- function withSpan(name, attributes, run) {
65
- return trace.getTracer("@inflexa-ai/harness").startActiveSpan(name, { attributes }, async (span) => {
88
+ /**
89
+ * The options of a turn root. `root: true` drops the parent before the sampler
90
+ * runs, thus the sampler takes its root branch and a decision from above is
91
+ * lost. A turn under suppressed tracing, or under a trace that is not sampled
92
+ * (an untraced workflow), therefore keeps its parent and stays unrecorded.
93
+ */
94
+ function turnRootOptions(attributes) {
95
+ const active = context.active();
96
+ if (isTracingSuppressed(active))
97
+ return { attributes };
98
+ const ambient = trace.getSpanContext(active);
99
+ if (ambient === undefined || !trace.isSpanContextValid(ambient))
100
+ return { root: true, attributes };
101
+ if ((ambient.traceFlags & TraceFlags.SAMPLED) === 0)
102
+ return { attributes };
103
+ // The span that started the turn: the workflow span of the run, or the request span.
104
+ return { root: true, links: [{ context: ambient, attributes: { "inflexa.link.kind": "caller" } }], attributes };
105
+ }
106
+ function withSpan(name, options, run) {
107
+ return trace.getTracer("@inflexa-ai/harness").startActiveSpan(name, options, async (span) => {
66
108
  try {
67
109
  return await run(span);
68
110
  }
@@ -86,3 +128,7 @@ function runFrameAttributes({ runFrame }) {
86
128
  return {};
87
129
  return { "inflexa.run_id": runFrame.runId, ...(runFrame.stepId === undefined ? {} : { "inflexa.step_id": runFrame.stepId }) };
88
130
  }
131
+ /** The thread is the conversation. The analysis is not: it holds many threads. */
132
+ function conversationAttributes({ scope }) {
133
+ return scope.threadId === undefined ? {} : { "gen_ai.conversation.id": scope.threadId };
134
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Agent-loop OTel metrics → Middleware.io.
2
+ * Agent-loop OTel metrics, exported through the global MeterProvider.
3
3
  *
4
4
  * Every `runAgent` completion records, keyed by `agent_id`:
5
5
  * - cortex.harness.agent.iterations — histogram of LLM iterations per run.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Agent-loop OTel metrics → Middleware.io.
2
+ * Agent-loop OTel metrics, exported through the global MeterProvider.
3
3
  *
4
4
  * Every `runAgent` completion records, keyed by `agent_id`:
5
5
  * - cortex.harness.agent.iterations — histogram of LLM iterations per run.
@@ -3,8 +3,9 @@ import { jsonSchema, tool as aiTool, } from "ai";
3
3
  import { createNoopUsageRecorder } from "../billing/noop-usage-recorder.js";
4
4
  import { stripNulCharacters } from "../input-sanitization.js";
5
5
  import { createNoopLogger } from "../lib/console-logger.js";
6
- import { ATTR_INFLEXA_TOOL_USE_ID, stableSpan } from "../lib/otel-spans.js";
6
+ import { ATTR_INFLEXA_TOOL_USE_ID, passThroughSpan, stableSpan } from "../lib/otel-spans.js";
7
7
  import { ResultError } from "../lib/result.js";
8
+ import { describeZodIssueShapes } from "../lib/zod-issue-shape.js";
8
9
  import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
9
10
  import { markInterruptedMessage, syntheticUserMessage } from "../memory/ai-sdk-message-storage.js";
10
11
  import { stripUnansweredToolCalls } from "../memory/tool-call-integrity.js";
@@ -14,7 +15,7 @@ import { DEFAULT_REASONING } from "../providers/reasoning.js";
14
15
  import { resultStep } from "./run-step.js";
15
16
  import { AskRejectedError, UnavailableAsk } from "../tools/approval/contract.js";
16
17
  import { isToolError, readToolResultImages } from "../tools/define-tool.js";
17
- import { labelToolFailure, recordToolException, traceAgentRun, traceToolCall } from "./genai-spans.js";
18
+ import { labelToolFailure, labelToolValidationFailure, recordToolException, traceAgentRun, traceToolCall } from "./genai-spans.js";
18
19
  import { addChatUsage, hasReportedUsage, recordAgentRun } from "./metrics.js";
19
20
  import { computeDetail, computeResultDetail } from "./tool-detail.js";
20
21
  import { toolOutcomeForOutputType } from "./tool-outcome.js";
@@ -24,11 +25,17 @@ export const DEFAULT_STEP_NAME_FORMATTER = {
24
25
  llm: (i) => `llm-${i}`,
25
26
  tool: (name, id) => `tool-${name}-${id}`,
26
27
  };
28
+ /** A run that receives no turn accumulator is the turn's root. See `RunAgentOptions.turnUsage`. */
29
+ const isTurnRoot = (opts) => opts.turnUsage === undefined;
27
30
  export function runAgent(agent, initial, session, opts) {
28
- return traceAgentRun(agent.id, session, () => runAgentLoop(agent, initial, session, opts));
31
+ return traceAgentRun(agent.id, session, isTurnRoot(opts), () => runAgentLoop(agent, initial, session, opts));
29
32
  }
30
33
  async function runAgentLoop(agent, initial, session, opts) {
31
34
  const { provider, signal, emit, runStep } = opts;
35
+ // The step of a model call or a tool call writes no span. The `chat` span and
36
+ // the `execute_tool` span under it describe the call already, and they attach
37
+ // to the `invoke_agent` span of this run.
38
+ const callStep = (name, fn) => passThroughSpan(name, () => runStep(name, fn));
32
39
  const formatStepName = opts.formatStepName ?? DEFAULT_STEP_NAME_FORMATTER;
33
40
  const configuredFatalLoopError = opts.isFatalLoopError ?? (() => false);
34
41
  // AbortError is control flow, not a tool failure. Always compose it with the
@@ -70,7 +77,7 @@ async function runAgentLoop(agent, initial, session, opts) {
70
77
  // descendant loop folds into, and it is the only loop entitled to report a
71
78
  // turn total. Descendants receive the root's object and mutate it in place.
72
79
  const turnUsage = opts.turnUsage ?? {};
73
- const isTurnRoot = opts.turnUsage === undefined;
80
+ const turnRoot = isTurnRoot(opts);
74
81
  const toolCtx = (tu) => ({
75
82
  invocationId: tu.toolCallId,
76
83
  session,
@@ -167,7 +174,7 @@ async function runAgentLoop(agent, initial, session, opts) {
167
174
  /** The rollups this loop stamps on its finish — each absent when nothing reported. */
168
175
  const finishUsage = () => ({
169
176
  ...(hasReportedUsage(usage) ? { usage: { ...usage } } : {}),
170
- ...(isTurnRoot && hasReportedUsage(turnUsage) ? { turnUsage: { ...turnUsage } } : {}),
177
+ ...(turnRoot && hasReportedUsage(turnUsage) ? { turnUsage: { ...turnUsage } } : {}),
171
178
  });
172
179
  // The user said no. A subsequent model call would only let the agent argue
173
180
  // with the decision, or spend a call acknowledging it; the denial tool result
@@ -251,7 +258,7 @@ async function runAgentLoop(agent, initial, session, opts) {
251
258
  reasoning,
252
259
  };
253
260
  const llmStepName = formatStepName.llm(i);
254
- const reply = await resultStep(runStep)(llmStepName, () => provider.chat(request, session, signal));
261
+ const reply = await resultStep(callStep)(llmStepName, () => provider.chat(request, session, signal));
255
262
  accountForCall(reply, llmStepName);
256
263
  if (reply.finishReason === "aborted") {
257
264
  // An interrupted turn keeps whatever the model produced before the cut, but
@@ -289,7 +296,7 @@ async function runAgentLoop(agent, initial, session, opts) {
289
296
  for (const [idx, tu] of earlier.entries()) {
290
297
  await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input, ...detailField(earlierDetails[idx]) });
291
298
  }
292
- const { results, durations, resultDetails } = await dispatchTools(earlier, toolsById, toolCtx, isFatalLoopError, runStep, formatStepName.tool, encoding);
299
+ const { results, durations, resultDetails } = await dispatchTools(earlier, toolsById, toolCtx, isFatalLoopError, callStep, formatStepName.tool, encoding);
293
300
  const errored = await settleRound(earlier, results, earlierDetails, resultDetails, durations);
294
301
  results.push(errorResult(trailing, TRUNCATED_TOOL_USE_ERROR));
295
302
  // The trailing call was never dispatched, but it reaches the model as an error
@@ -341,7 +348,7 @@ async function runAgentLoop(agent, initial, session, opts) {
341
348
  for (const [idx, tu] of toolCalls.entries()) {
342
349
  await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input, ...detailField(details[idx]) });
343
350
  }
344
- const { results, durations, resultDetails } = await dispatchTools(toolCalls, toolsById, toolCtx, isFatalLoopError, runStep, formatStepName.tool, encoding);
351
+ const { results, durations, resultDetails } = await dispatchTools(toolCalls, toolsById, toolCtx, isFatalLoopError, callStep, formatStepName.tool, encoding);
345
352
  const errored = await settleRound(toolCalls, results, details, resultDetails, durations);
346
353
  if (errored.length > 0)
347
354
  log.debug("tool results returned errors", { iteration: i, tools: errored });
@@ -359,7 +366,7 @@ async function runAgentLoop(agent, initial, session, opts) {
359
366
  // because it is the one call whose write is pure waste, and the
360
367
  // cache_write_tokens counter is what makes that waste visible.
361
368
  const wrapUpStepName = formatStepName.llm(agent.maxIterations);
362
- const wrapUp = await resultStep(runStep)(wrapUpStepName, () => provider.chat({ system: agent.systemPrompt, messages: withPromptCacheBreakpoint(messages, promptCache), tools: {}, toolChoice: "none", reasoning }, session, signal));
369
+ const wrapUp = await resultStep(callStep)(wrapUpStepName, () => provider.chat({ system: agent.systemPrompt, messages: withPromptCacheBreakpoint(messages, promptCache), tools: {}, toolChoice: "none", reasoning }, session, signal));
363
370
  accountForCall(wrapUp, wrapUpStepName);
364
371
  if (wrapUp.finishReason === "aborted") {
365
372
  // An abort during the tool-less wrap-up is still the user cutting the turn — the
@@ -553,11 +560,7 @@ async function dispatchTools(toolUses, toolsById, toolCtx, isFatalLoopError, run
553
560
  }
554
561
  await Promise.all(stepTools.map(({ tu, idx }) => {
555
562
  const startedAt = performance.now();
556
- const stepName = toolStepName(tu.toolName, tu.toolCallId);
557
- return runStep(stepName, () => {
558
- stableSpan(stepName, `tool:${tu.toolName}`, { [ATTR_INFLEXA_TOOL_USE_ID]: tu.toolCallId });
559
- return dispatch(tu);
560
- }).then((settled) => {
563
+ return runStep(toolStepName(tu.toolName, tu.toolCallId), () => dispatch(tu)).then((settled) => {
561
564
  durations[idx] = elapsedMs(startedAt);
562
565
  const dispatched = readDispatchedStep(settled);
563
566
  results[idx] = dispatched.result;
@@ -617,8 +620,10 @@ async function dispatchTool(tu, toolsById, ctx, isFatalLoopError, encoding) {
617
620
  const repaired = repairedInput === undefined ? undefined : tool.inputSchema.safeParse(repairedInput);
618
621
  if (repaired?.success === true)
619
622
  return execute(tu, tool, repaired.data, ctx, isFatalLoopError, encoding);
620
- // The Zod text can quote the input, thus the span gets no message.
621
- labelToolFailure("validation");
623
+ // The Zod text can quote the input, thus the span and the log get only the shape of the failure.
624
+ const issues = describeZodIssueShapes(parsed.error, tool.inputSchema);
625
+ labelToolValidationFailure(issues);
626
+ encoding.log.warn("tool call rejected", { tool: tool.id, toolCallId: tu.toolCallId, issues });
622
627
  return { result: errorResult(tu, `input validation failed: ${formatZodIssues(parsed.error, tu.input)}`) };
623
628
  }
624
629
  async function execute(tu, tool, input, ctx, isFatalLoopError, encoding) {
@@ -5,9 +5,11 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
5
5
  import { OpenTelemetry } from "@ai-sdk/otel";
6
6
  import { APICallError } from "@ai-sdk/provider";
7
7
  import { retryWithExponentialBackoff } from "@ai-sdk/provider-utils";
8
+ import { trace } from "@opentelemetry/api";
8
9
  import { ResultAsync, err, ok } from "neverthrow";
9
10
  import { scopeWorkloadId } from "../auth/types.js";
10
11
  import { createNoopLogger } from "../lib/console-logger.js";
12
+ import { passThroughTracer } from "../lib/otel-spans.js";
11
13
  import { classifyProviderError, RequestTimeoutError, toProviderError } from "./errors.js";
12
14
  /**
13
15
  * The harness-owned retry envelope. The AI SDK's built-in retry exposes only a
@@ -414,6 +416,10 @@ function abortedStreamFailure(signal, reason, requestTimeoutMs) {
414
416
  return new RequestTimeoutError(requestTimeoutMs);
415
417
  return new Error(reason ?? "The provider aborted the stream.");
416
418
  }
419
+ /** The `gen_ai.operation.name` of the operation span and of the step span that the AI SDK opens around each `chat` span. */
420
+ function isSdkWrapperSpan(operation) {
421
+ return operation === "invoke_agent" || operation === "agent_step";
422
+ }
417
423
  export function createAiSdkProvider(deps) {
418
424
  const capabilities = {
419
425
  toolCalling: deps.capabilities?.toolCalling ?? true,
@@ -435,8 +441,12 @@ export function createAiSdkProvider(deps) {
435
441
  // Each model call emits OpenTelemetry GenAI spans through the AI SDK, with no
436
442
  // prompt or completion text on them. The integration rides on each call
437
443
  // instead of the global registry, so the harness never traces the embedder's
438
- // own AI SDK calls.
439
- const telemetry = { integrations: new OpenTelemetry(), recordInputs: false, recordOutputs: false };
444
+ // own AI SDK calls. Of the SDK spans only `chat` is written. The operation
445
+ // span and the step span around it repeat what `chat` and the span of the
446
+ // caller (the loop's `invoke_agent`) say already. Thus they pass through, and
447
+ // `chat` attaches to the span of the caller.
448
+ const tracer = passThroughTracer(trace.getTracer("gen_ai"), (_name, options) => isSdkWrapperSpan(options.attributes?.["gen_ai.operation.name"]));
449
+ const telemetry = { integrations: new OpenTelemetry({ tracer }), recordInputs: false, recordOutputs: false };
440
450
  routeSdkWarningsTo(logger);
441
451
  /**
442
452
  * Record a call that the envelope gave up on.
@@ -57,9 +57,10 @@ export interface DbosConfig {
57
57
  *
58
58
  * The harness TracerProvider shapes what the SDK emits (`lib/otel-spans.ts`):
59
59
  * a workflow declared with `untracedWorkflow` is not recorded, a replayed
60
- * `cached=true` span is dropped before export, and a step body that calls
60
+ * `cached=true` span is dropped before export, a step body that calls
61
61
  * `stableSpan` is exported under a stable name with its id in an
62
- * `inflexa.*` attribute. The step name DBOS records is unchanged.
62
+ * `inflexa.*` attribute, and the step of an agent-loop model call or tool
63
+ * call writes no span. The step name DBOS records is unchanged.
63
64
  *
64
65
  * Known SDK behavior a host must plan for: a recovered workflow starts a
65
66
  * new root trace, with no link to the trace of the original execution.
@@ -55,7 +55,7 @@
55
55
  * varied between replays would desynchronise the recorded function-ID sequence.
56
56
  */
57
57
  import { DBOS } from "@dbos-inc/dbos-sdk";
58
- import { ATTR_INFLEXA_ATTEMPT, ATTR_INFLEXA_EXEC_ID, stableSpan } from "../lib/otel-spans.js";
58
+ import { ATTR_INFLEXA_ATTEMPT, ATTR_INFLEXA_EXEC_ID, stableSpan, untracedFetch } from "../lib/otel-spans.js";
59
59
  import { digestBody } from "./digest.js";
60
60
  import { signCallback, verifyCallback } from "./hmac.js";
61
61
  import { createEscalationPolicy, probeLiveness, syntheticFailureReason, syntheticFailureResult } from "./liveness.js";
@@ -105,7 +105,7 @@ async function pullExecResult(fetchImpl, ref, execId) {
105
105
  // cached result is replayed without re-executing the body.
106
106
  const reqTimestamp = Math.floor(Date.now() / 1000);
107
107
  const reqSignature = signCallback({ execId, body: "", timestamp: reqTimestamp, secret: ref.callbackSecret });
108
- const res = await fetchImpl(`http://${ref.host}:${ref.port}/exec/${encodeURIComponent(execId)}`, {
108
+ const res = await untracedFetch(fetchImpl, `http://${ref.host}:${ref.port}/exec/${encodeURIComponent(execId)}`, {
109
109
  method: "GET",
110
110
  headers: {
111
111
  "x-sandbox-signature": reqSignature,
@@ -272,7 +272,7 @@ async function pollExecOnce(fetchImpl, ref, execId, cursor) {
272
272
  // replayed without re-executing the body.
273
273
  const reqTimestamp = Math.floor(Date.now() / 1000);
274
274
  const reqSignature = signCallback({ execId, body: "", timestamp: reqTimestamp, secret: ref.callbackSecret });
275
- const res = await fetchImpl(`http://${ref.host}:${ref.port}/exec/${encodeURIComponent(execId)}?since=${cursor}`, {
275
+ const res = await untracedFetch(fetchImpl, `http://${ref.host}:${ref.port}/exec/${encodeURIComponent(execId)}?since=${cursor}`, {
276
276
  method: "GET",
277
277
  headers: {
278
278
  "x-sandbox-signature": reqSignature,
@@ -17,7 +17,7 @@
17
17
  * untouched.
18
18
  */
19
19
  import { DBOS } from "@dbos-inc/dbos-sdk";
20
- import { ATTR_INFLEXA_EXEC_ID, stableSpan } from "../lib/otel-spans.js";
20
+ import { ATTR_INFLEXA_EXEC_ID, stableSpan, untracedFetch } from "../lib/otel-spans.js";
21
21
  import { EXEC_STREAM_BYTE_CAP } from "../tools/workspace/result-bounds.js";
22
22
  import { signCallback } from "./hmac.js";
23
23
  const SUBMIT_HTTP_TIMEOUT_MS = 30_000;
@@ -33,7 +33,7 @@ async function postExec(fetchImpl, ref, body) {
33
33
  const raw = JSON.stringify(body);
34
34
  const timestamp = Math.floor(Date.now() / 1000);
35
35
  const signature = signCallback({ execId: body.execId, body: raw, timestamp, secret: ref.callbackSecret });
36
- const res = await fetchImpl(`http://${ref.host}:${ref.port}/exec`, {
36
+ const res = await untracedFetch(fetchImpl, `http://${ref.host}:${ref.port}/exec`, {
37
37
  method: "POST",
38
38
  headers: {
39
39
  "content-type": "application/json",
@@ -14,8 +14,9 @@
14
14
  * identifier that does not parse as a UniProt accession or an AlphaFold DB id
15
15
  * gives 400 with an `Invalid identifier format` body. A well-formed accession
16
16
  * that AlphaFold holds no model for gives 404 with an empty object. Both are
17
- * the same outcome for a caller, and `isUnexpectedApiError` classifies each
18
- * `http_status` in the 4xx range as expected, thus one branch answers both.
17
+ * the same outcome for a caller, thus one branch answers both. Only these two
18
+ * codes mean absence. A different 4xx, such as a 403 refusal, throws, because
19
+ * `found: false` tells the agent to report the absence and not to retry.
19
20
  */
20
21
  import { z } from "zod";
21
22
  /**
@@ -46,7 +47,8 @@ export type AlphaFoldPrediction = z.infer<typeof AlphaFoldPredictionSchema>;
46
47
  * Fetch the AlphaFold structure prediction for one UniProt accession.
47
48
  *
48
49
  * Gives `null` when AlphaFold holds no model for the accession, and `null` for
49
- * an identifier it cannot parse. When the array holds more than one entry —
50
+ * an identifier it cannot parse. Throws on each other failure. When the array
51
+ * holds more than one entry —
50
52
  * the isoforms of one canonical accession — the entry whose own
51
53
  * `uniprotAccession` matches the queried accession wins. A query that names no
52
54
  * exact isoform falls back to the first entry, which AlphaFold orders
@@ -14,11 +14,12 @@
14
14
  * identifier that does not parse as a UniProt accession or an AlphaFold DB id
15
15
  * gives 400 with an `Invalid identifier format` body. A well-formed accession
16
16
  * that AlphaFold holds no model for gives 404 with an empty object. Both are
17
- * the same outcome for a caller, and `isUnexpectedApiError` classifies each
18
- * `http_status` in the 4xx range as expected, thus one branch answers both.
17
+ * the same outcome for a caller, thus one branch answers both. Only these two
18
+ * codes mean absence. A different 4xx, such as a 403 refusal, throws, because
19
+ * `found: false` tells the agent to report the absence and not to retry.
19
20
  */
20
21
  import { z } from "zod";
21
- import { apiFetchValidated, describeApiError, isUnexpectedApiError } from "./api-utils.js";
22
+ import { apiFetchValidated, describeApiError } from "./api-utils.js";
22
23
  const ALPHAFOLD_BASE = "https://alphafold.ebi.ac.uk/api/prediction";
23
24
  /**
24
25
  * One entry of the AlphaFold DB prediction array. A query by a canonical
@@ -50,7 +51,8 @@ export const AlphaFoldPredictionSchema = z.object({
50
51
  * Fetch the AlphaFold structure prediction for one UniProt accession.
51
52
  *
52
53
  * Gives `null` when AlphaFold holds no model for the accession, and `null` for
53
- * an identifier it cannot parse. When the array holds more than one entry —
54
+ * an identifier it cannot parse. Throws on each other failure. When the array
55
+ * holds more than one entry —
54
56
  * the isoforms of one canonical accession — the entry whose own
55
57
  * `uniprotAccession` matches the queried accession wins. A query that names no
56
58
  * exact isoform falls back to the first entry, which AlphaFold orders
@@ -60,9 +62,9 @@ export async function fetchAlphaFoldPrediction(uniprotAccession) {
60
62
  const accession = uniprotAccession.trim();
61
63
  const res = await apiFetchValidated(`${ALPHAFOLD_BASE}/${encodeURIComponent(accession)}`, z.array(AlphaFoldPredictionSchema));
62
64
  if (res.isErr()) {
63
- if (isUnexpectedApiError(res.error))
64
- throw new Error(describeApiError(res.error));
65
- return null;
65
+ if (isAbsence(res.error))
66
+ return null;
67
+ throw new Error(describeApiError(res.error));
66
68
  }
67
69
  const entries = res.value;
68
70
  if (entries.length === 0)
@@ -70,3 +72,6 @@ export async function fetchAlphaFoldPrediction(uniprotAccession) {
70
72
  const exact = entries.find((entry) => entry.uniprotAccession.toUpperCase() === accession.toUpperCase());
71
73
  return exact ?? entries[0];
72
74
  }
75
+ function isAbsence(e) {
76
+ return e.type === "http_status" && (e.status === 400 || e.status === 404);
77
+ }
@@ -48,6 +48,7 @@ export type ApiError = {
48
48
  readonly type: "invalid_response";
49
49
  readonly issues: string;
50
50
  };
51
+ export declare const HARNESS_USER_AGENT = "inflexa-harness (+https://inflexa.ai)";
51
52
  /**
52
53
  * Fetch a URL with retry on 429/502/503/504, exponential backoff, and timeout.
53
54
  * `err` carries the structured failure; a non-ok HTTP status is reported as
@@ -61,6 +62,8 @@ export type ApiError = {
61
62
  *
62
63
  * Between two attempts the wait obeys `Retry-After` when the response carries it,
63
64
  * and every wait stays at or below `maxRetryDelayMs`.
65
+ *
66
+ * Each request sends `HARNESS_USER_AGENT`, unless `headers` sets a User-Agent.
64
67
  */
65
68
  export declare function apiFetch<T = unknown>(url: string, options?: ApiFetchOptions): ResultAsync<T, ApiError>;
66
69
  /**
@@ -15,6 +15,9 @@ import { sleep } from "../../lib/async-utils.js";
15
15
  // provider answers with one of them while the origin restarts, and it sends an
16
16
  // HTML body. That answer is transient, thus a retry gets the real payload.
17
17
  const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
18
+ // AlphaFold DB answers 403 to the default User-Agent of the runtime (`node`,
19
+ // `Bun/<version>`, or none), and a descriptive value passes.
20
+ export const HARNESS_USER_AGENT = "inflexa-harness (+https://inflexa.ai)";
18
21
  /**
19
22
  * Fetch a URL with retry on 429/502/503/504, exponential backoff, and timeout.
20
23
  * `err` carries the structured failure; a non-ok HTTP status is reported as
@@ -28,6 +31,8 @@ const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
28
31
  *
29
32
  * Between two attempts the wait obeys `Retry-After` when the response carries it,
30
33
  * and every wait stays at or below `maxRetryDelayMs`.
34
+ *
35
+ * Each request sends `HARNESS_USER_AGENT`, unless `headers` sets a User-Agent.
31
36
  */
32
37
  export function apiFetch(url, options = {}) {
33
38
  return new ResultAsync(runFetch(url, options));
@@ -92,12 +97,15 @@ function retryAfterMilliseconds(res) {
92
97
  }
93
98
  async function runFetch(url, options) {
94
99
  const { method = "GET", headers = {}, body, maxRetries = 3, retryDelayMs = 1000, maxRetryDelayMs = 30_000, sleep: wait = sleep, timeoutMs = 90_000, parseAs = "json", } = options;
100
+ const requestHeaders = new Headers(headers);
101
+ if (!requestHeaders.has("user-agent"))
102
+ requestHeaders.set("User-Agent", HARNESS_USER_AGENT);
95
103
  let lastError = "";
96
104
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
97
105
  try {
98
106
  const res = await fetch(url, {
99
107
  method,
100
- headers,
108
+ headers: requestHeaders,
101
109
  body,
102
110
  signal: AbortSignal.timeout(timeoutMs),
103
111
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.36.2",
3
+ "version": "0.36.4",
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",