@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.
- package/dist/execution/step-summary-metrics.d.ts +1 -1
- package/dist/execution/step-summary-metrics.js +1 -1
- package/dist/lib/otel-spans.d.ts +56 -2
- package/dist/lib/otel-spans.js +93 -2
- package/dist/lib/otel.d.ts +15 -11
- package/dist/lib/otel.js +25 -18
- package/dist/lib/zod-issue-shape.d.ts +21 -0
- package/dist/lib/zod-issue-shape.js +122 -0
- package/dist/loop/genai-spans.d.ts +15 -2
- package/dist/loop/genai-spans.js +54 -8
- package/dist/loop/metrics.d.ts +1 -1
- package/dist/loop/metrics.js +1 -1
- package/dist/loop/run-agent.js +21 -16
- package/dist/providers/ai-sdk.js +12 -2
- package/dist/runtime/dbos.d.ts +3 -2
- package/dist/sandbox/await-exec.js +3 -3
- package/dist/sandbox/submit-exec.js +2 -2
- package/dist/tools/lib/alphafold-client.d.ts +5 -3
- package/dist/tools/lib/alphafold-client.js +12 -7
- package/dist/tools/lib/api-utils.d.ts +3 -0
- package/dist/tools/lib/api-utils.js +9 -1
- package/package.json +1 -1
package/dist/lib/otel-spans.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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`.
|
package/dist/lib/otel-spans.js
CHANGED
|
@@ -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
|
-
*
|
|
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`.
|
package/dist/lib/otel.d.ts
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:
|
|
8
|
-
* is set. The provider carries the span
|
|
9
|
-
* root span of a workflow declared with
|
|
10
|
-
* recorded (and its children with it), a
|
|
11
|
-
* is dropped before export,
|
|
12
|
-
* is exported under a stable name with its id
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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:
|
|
8
|
-
* is set. The provider carries the span
|
|
9
|
-
* root span of a workflow declared with
|
|
10
|
-
* recorded (and its children with it), a
|
|
11
|
-
* is dropped before export,
|
|
12
|
-
* is exported under a stable name with its id
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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
|
|
79
|
-
//
|
|
80
|
-
|
|
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
|
|
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
|
-
/**
|
|
21
|
-
|
|
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 {};
|
package/dist/loop/genai-spans.js
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
+
}
|
package/dist/loop/metrics.d.ts
CHANGED
package/dist/loop/metrics.js
CHANGED
package/dist/loop/run-agent.js
CHANGED
|
@@ -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
|
|
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
|
-
...(
|
|
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(
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
|
621
|
-
|
|
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) {
|
package/dist/providers/ai-sdk.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/dist/runtime/dbos.d.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
18
|
-
*
|
|
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.
|
|
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,
|
|
18
|
-
*
|
|
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
|
|
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.
|
|
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 (
|
|
64
|
-
|
|
65
|
-
|
|
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