@gr8ful/spf 0.17.0 → 0.18.0
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/assets/skill/references/config.md +6 -3
- package/assets/skill/references/observability.md +115 -1
- package/dist/cli/index.js +6 -0
- package/dist/core/agent_cc.d.ts +11 -0
- package/dist/core/agent_cc.js +39 -1
- package/dist/core/agent_flue.js +26 -0
- package/dist/core/agent_opencode.d.ts +105 -3
- package/dist/core/agent_opencode.js +169 -17
- package/dist/core/agents.d.ts +6 -0
- package/dist/core/agents.js +22 -0
- package/dist/core/data_types.d.ts +60 -0
- package/dist/core/data_types.js +69 -0
- package/dist/core/otel.d.ts +182 -48
- package/dist/core/otel.js +373 -158
- package/dist/core/otel_metrics.d.ts +127 -0
- package/dist/core/otel_metrics.js +221 -0
- package/dist/core/otel_propagation.d.ts +159 -0
- package/dist/core/otel_propagation.js +225 -0
- package/package.json +14 -1
|
@@ -110,11 +110,13 @@ the two runs' cost/tokens/gates together.
|
|
|
110
110
|
|---|---|---|
|
|
111
111
|
| `db` | path \| `{kind: sqlite, path?}` \| `{kind: d1, database_id, account_id_env?, api_token_env?}` | Where the trace db lives. A bare string (or the equivalent spelled-out `{kind: sqlite, ...}`) is a repo-relative local sqlite path, default `.spf/data/spf.db`. `{kind: d1, ...}` points at a remote Cloudflare D1 database instead — `database_id` is required; `account_id_env`/`api_token_env` default to `CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_API_TOKEN` (the same env vars the `cloudflare` model provider reads), naming the env vars to read the credentials from, never the credentials themselves. **Egress**: `kind: d1` sends the complete trace — raw request text, tool arguments/results, envelope payloads, gate violation details — to Cloudflare's D1 HTTP API on every run; `kind: sqlite` (the default) never leaves the machine. See `observability.md`'s "Two stores, one truth" and "D1 ships the complete trace off-box" sections for what changes (and what deliberately doesn't) with a D1-backed trace db — notably, the local backend's WAL live-read guarantee does not hold for D1. |
|
|
112
112
|
| `poll_ms` | int | UI live-poll cadence. Default `500`. |
|
|
113
|
-
| `otel.endpoint` | string | OTLP/HTTP collector endpoint (e.g., `https://your-host/v1/traces`). Omit to disable OTel export. |
|
|
113
|
+
| `otel.endpoint` | string | OTLP/HTTP collector endpoint (e.g., `https://your-host/v1/traces`). Omit to disable OTel export (traces AND metrics — both share this one switch). |
|
|
114
114
|
| `otel.headers` | object | Optional HTTP headers (e.g., auth tokens). Each value is a string. |
|
|
115
|
-
| `otel.service_name` | string | Optional service name in exported spans. Default `spf`. |
|
|
115
|
+
| `otel.service_name` | string | Optional service name in exported spans/metrics. Default `spf`. |
|
|
116
|
+
| `otel.metrics` | bool | Default `true`. Set `false` to keep trace export on while opting out of the process-scoped metrics pipeline (`spf.tokens`, `spf.cost_usd`, `spf.phase.duration`, `spf.gate.result`, `spf.agent.calls` — see `observability.md`). Has no effect on activation either way. |
|
|
117
|
+
| `otel.allow_env` | bool | Default `false`. When `true` AND `otel.endpoint` is ALREADY set here, the standard `OTEL_EXPORTER_OTLP_ENDPOINT`/`OTEL_EXPORTER_OTLP_HEADERS` env vars may SUPPLEMENT this block (e.g. a CI-injected collector token) — config-declared values win on conflict, and this can never activate export on its own. |
|
|
116
118
|
|
|
117
|
-
**No ambient env activation**: OTEL export requires explicit `observability.otel` config — the `OTEL_EXPORTER_OTLP_ENDPOINT` shell variable is never consulted.
|
|
119
|
+
**No ambient env activation**: OTEL export requires explicit `observability.otel` config — the `OTEL_EXPORTER_OTLP_ENDPOINT` shell variable is never consulted UNLESS `otel.allow_env: true` and the block above is already active (see that row).
|
|
118
120
|
|
|
119
121
|
**`spf init`'s interview** asks local-vs-D1 only inside the "advanced" gate (declining advanced settings, or `--yes`, leaves `observability.db` unset — local sqlite at its default path, today's behavior, unchanged). Choosing D1 asks for `database_id` (required) and, unless this same interview run already collected `CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_API_TOKEN` for the Cloudflare Workers AI provider, asks for those too — reusing them instead of asking twice when it did. The generated config omits `account_id_env`/`api_token_env` (they match the schema's own defaults above), so a D1 choice writes just `db: {kind: d1, database_id: ...}`.
|
|
120
122
|
|
|
@@ -545,6 +547,7 @@ when `.gitmodules` is present.
|
|
|
545
547
|
| `writes` | no | What this agent may modify **in the repo**, enforced after every call. `undefined`/`null` = unrestricted (still barred from `protected_files`); `[]` = no repo writes; a list = only those paths (trailing `/` = directory prefix, `*` = one path segment, `**` = crosses segments, anything else = exact path). |
|
|
546
548
|
| `env_allowlist` | no | Opt-in filter on the environment handed to this agent's subprocess/sandbox. `undefined` (default) = the full operator environment, unchanged. A list = only those keys, plus the baseline (`PATH`, `HOME`, `USER`, `LANG`, `TERM`, `TMPDIR`) either backend keeps regardless. |
|
|
547
549
|
| `sandbox` | no | Per-agent backend override — see `sandbox` below. `undefined`/`null` = inherit `sandbox.backend`; `"local"` = force local for this agent regardless of the repo-wide setting; a backend name = use that backend for this agent only. A bare name, never a nested block — everything else (`image`, `egress`, `transport`, credentials *policy*) stays repo-wide. |
|
|
550
|
+
| `lora_adapter` | no | Explicit override for the `spf.lora_adapter` OTel span attribute (see `observability.md`). Unset (the common case) falls back to parsing `model` — `provider/base:adapter` (the suffix after the last `:`), else `provider/adapter-name` when the id contains `-lora-` (this org's own vLLM/Switchyard served-model convention). Purely descriptive: never changes routing, dispatch, or which model actually serves the call. |
|
|
548
551
|
|
|
549
552
|
Output types are deliberately absent from config: an entry defines who an
|
|
550
553
|
agent *is*; the call site defines how it's *used*.
|
|
@@ -75,9 +75,123 @@ reading `parent_id`.
|
|
|
75
75
|
`spf.config.yaml`, a lossy, allowlisted projection of phase/agent/tool spans
|
|
76
76
|
(status, model, token/cost counts, gate results — never prompts, envelopes,
|
|
77
77
|
tool arguments, or source code) is also pushed to an OTLP/HTTP collector,
|
|
78
|
-
fire-and-forget
|
|
78
|
+
fire-and-forget, via the real `@opentelemetry/sdk-trace-base` +
|
|
79
|
+
`exporter-trace-otlp-http` packages (SPF's own bespoke sha256 trace/span-id
|
|
80
|
+
scheme and attribute allowlist ride on top of the real SDK's encoder, not a
|
|
81
|
+
hand-rolled one — see `core/otel.ts`'s header for exactly what that means for
|
|
82
|
+
the wire bytes). SQLite remains the source of truth regardless; see
|
|
79
83
|
`config.md`'s `observability.otel.*` rows for the field reference.
|
|
80
84
|
|
|
85
|
+
**Optional OTel metrics.** The same `observability.otel.endpoint` also gates
|
|
86
|
+
a second, PROCESS-scoped pipeline (`core/otel_metrics.ts`, built on
|
|
87
|
+
`@opentelemetry/sdk-metrics` + `exporter-metrics-otlp-http`) — set
|
|
88
|
+
`observability.otel.metrics: false` to keep trace export on while opting out
|
|
89
|
+
of metrics specifically. Five instruments: `spf.tokens` (counter; attrs
|
|
90
|
+
`kind` — input/output/cache_read/cache_write — `agent`, `model`),
|
|
91
|
+
`spf.cost_usd` (counter; `agent`, `model`), `spf.phase.duration` (histogram,
|
|
92
|
+
seconds; `kind`, `owner`, `status`), `spf.gate.result` (counter; `gate`,
|
|
93
|
+
`result` — pass/fail), and `spf.agent.calls` (counter; `agent`, `model`,
|
|
94
|
+
`coding_agent`). Unlike the span exporter (one per run), there is exactly
|
|
95
|
+
one `MeterProvider` for the life of the `spf` process — see `otel_metrics.
|
|
96
|
+
ts`'s own header for why that matters under `spf watch`'s daemon loop.
|
|
97
|
+
|
|
98
|
+
**Outbound trace-context propagation.** When otel is configured, SPF also
|
|
99
|
+
tries to carry `traceparent` onto the OUTBOUND model calls each agent makes,
|
|
100
|
+
so this run's spans join whatever trace the model-serving stack itself
|
|
101
|
+
produces (see "Tracing across the inference stack" below). The three coding
|
|
102
|
+
agent backends get different-confidence treatment:
|
|
103
|
+
- `coding_agent: claude_code` — a real, verified guarantee.
|
|
104
|
+
`agent_cc.ts`'s single `spawn()` choke point sets `TRACEPARENT` and
|
|
105
|
+
`ANTHROPIC_CUSTOM_HEADERS` (`traceparent`/`x-request-id`, newline-
|
|
106
|
+
separated `Name: Value` pairs — the CLI's own documented format,
|
|
107
|
+
requires `claude` CLI >= 2.1.227) on the `claude` subprocess's
|
|
108
|
+
environment for every call.
|
|
109
|
+
- `coding_agent: opencode` — config-verified, behavior best-effort.
|
|
110
|
+
`agent_opencode.ts` injects `TRACEPARENT` onto the subprocess env
|
|
111
|
+
(parity with `claude_code`; unverified whether the opencode CLI itself
|
|
112
|
+
reads it) AND writes `traceparent`/`x-request-id` as static
|
|
113
|
+
`provider.<id>.options.headers` into the temp `opencode.json` —
|
|
114
|
+
opencode's documented per-provider options surface, honored by the AI
|
|
115
|
+
SDK on that provider's requests. Static is correct because one
|
|
116
|
+
`opencode run` subprocess is exactly one agent call. Two caveats,
|
|
117
|
+
documented in the module's own doc comment: a bare model id with no
|
|
118
|
+
`provider/` prefix skips the headers (SPF never guesses a provider id),
|
|
119
|
+
and a target repo's own `opencode.json` merges at higher precedence and
|
|
120
|
+
can override them.
|
|
121
|
+
- `coding_agent: flue` — best-effort. `@flue/opentelemetry`'s own docs say
|
|
122
|
+
plainly that `dispatch()` "does not propagate trace context" on its own,
|
|
123
|
+
so SPF additionally registers `@opentelemetry/instrumentation-http` +
|
|
124
|
+
`-undici` globally, with a propagator that carries both the standard W3C
|
|
125
|
+
`traceparent` and a custom `x-request-id`. This reaches any provider
|
|
126
|
+
whose Node SDK issues requests through `http`/`https`/`undici` (verified)
|
|
127
|
+
— it does NOT reach a provider transport that bypasses both (unverified
|
|
128
|
+
for the Anthropic/Google/Mistral SDKs' internal transports specifically;
|
|
129
|
+
flagged, not assumed). See `core/otel_propagation.ts`'s header for the
|
|
130
|
+
full mechanism. Since #80, flue's spans additionally JOIN SPF's
|
|
131
|
+
deterministic trace: `agent_flue.ts` registers each run's session id
|
|
132
|
+
(== flue's instance id) against that call's traceparent, and the
|
|
133
|
+
installed instrumentation's `resolveRootContext` option — consulted per
|
|
134
|
+
root span, keyed on the event context's instance id — roots each
|
|
135
|
+
session's spans under the right agent-call span. This is done per-span
|
|
136
|
+
rather than by wrapping `dispatch()` in a context because flue's node
|
|
137
|
+
runtime executes all submissions in one process-lifetime claim loop
|
|
138
|
+
whose async context is captured once — a dispatch-time wrap would
|
|
139
|
+
mis-attribute every agent after the first into the first agent's trace
|
|
140
|
+
(verified against `@flue/runtime`'s dist; see `core/otel_propagation
|
|
141
|
+
.ts`'s header). Unmapped sessions (backlog restarts, post-settlement
|
|
142
|
+
stragglers) degrade to the old separate-trace behavior — never
|
|
143
|
+
mis-attributed, never an error; correlate those by `x-request-id`/
|
|
144
|
+
`spf.adw_id`/time window.
|
|
145
|
+
|
|
146
|
+
## Tracing across the inference stack
|
|
147
|
+
|
|
148
|
+
A run's SPF spans are not the only spans in play once a `claude_code` agent
|
|
149
|
+
is pointed at a self-hosted model through Switchyard/vLLM (this repo's own
|
|
150
|
+
inference-platform-aws stack): the `claude` CLI's own request, Switchyard's
|
|
151
|
+
routing hop, and vLLM's own serving span can ALL be emitted to the same Tempo
|
|
152
|
+
(or any OTLP-compatible) backend. What joins them:
|
|
153
|
+
|
|
154
|
+
- **The `traceparent` SPF injects** (see above) is a real W3C header on the
|
|
155
|
+
actual HTTP request `claude` makes to `ANTHROPIC_BASE_URL` — whatever
|
|
156
|
+
receives that request (Switchyard, vLLM directly, an Envoy AI Gateway
|
|
157
|
+
hop) that is ALSO instrumented with OTel and honors an inbound
|
|
158
|
+
`traceparent` will parent its own span under SPF's agent-call span,
|
|
159
|
+
landing in the SAME trace.
|
|
160
|
+
- **SPF's OWN span export is a separate, deterministic trace** — one
|
|
161
|
+
`adw_id` = one trace id (`sha256(adw_id)`), independent of whatever trace
|
|
162
|
+
id the model-serving hop's own OTel SDK would otherwise mint. Since SPF
|
|
163
|
+
controls the OUTBOUND `traceparent` it sends (not merely observes one),
|
|
164
|
+
a `claude_code` call's downstream spans (Switchyard, vLLM) land as
|
|
165
|
+
CHILDREN of SPF's own deterministic trace id, not the other way around —
|
|
166
|
+
a Tempo query for `sha256(adw_id)` finds the whole cross-service picture
|
|
167
|
+
for that run, agent call down through the model server.
|
|
168
|
+
- **The `flue` backend joins the same trace** (since #80): Flue's own
|
|
169
|
+
spans (`flue.coordinator`, `invoke_agent`, `chat <model>`,
|
|
170
|
+
`execute_tool`) inherit SPF's deterministic trace id (SDK-random span
|
|
171
|
+
ids underneath) via the instrumentation's per-span `resolveRootContext`
|
|
172
|
+
rooted on the run's registered session-id -> traceparent map, and the
|
|
173
|
+
`traceparent` the http/undici instrumentations inject onto Flue's
|
|
174
|
+
provider requests carries it too — downstream Switchyard/vLLM spans
|
|
175
|
+
land in the same trace, exactly as the `claude_code` path described
|
|
176
|
+
above. Unmapped submissions (a restarted process draining a durable
|
|
177
|
+
backlog, post-settlement bookkeeping) fall back to a separate trace —
|
|
178
|
+
never MIS-attributed into another agent's trace — correlatable by
|
|
179
|
+
`x-request-id`/`spf.adw_id`/time window; see the "Outbound
|
|
180
|
+
trace-context propagation" section above.
|
|
181
|
+
- **`x-request-id`** rides alongside `traceparent` specifically so a
|
|
182
|
+
collector/log pipeline that correlates by individual REQUEST (rather
|
|
183
|
+
than by trace) has a stable id to key on — it is this call's own
|
|
184
|
+
(SPF-side) span id for `claude_code`, and the currently-active span's id
|
|
185
|
+
for `flue`.
|
|
186
|
+
- **vLLM's own cache-read pass-through**
|
|
187
|
+
(`usage.prompt_tokens_details.cached_tokens`, surfaced here as both
|
|
188
|
+
`spf.tokens.cache_read` and `gen_ai.usage.cache_read.input_tokens`)
|
|
189
|
+
requires the upstream vLLM server to be started with
|
|
190
|
+
`--enable-prompt-tokens-details` (off by default) — SPF has no way to
|
|
191
|
+
detect this at config-validation time; a correctly-wired pipeline reads
|
|
192
|
+
`0` forever against a server that hasn't set the flag, with no bug
|
|
193
|
+
anywhere in SPF's own code.
|
|
194
|
+
|
|
81
195
|
**Spend is itemized per phase.** `agent_end.usage` carries tokens *and*
|
|
82
196
|
dollars for each component Flue reports (matching pi-ai's field names
|
|
83
197
|
one-for-one) — `input`, `output`, `cache_read`, `cache_write` — summed
|
package/dist/cli/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import * as agentFlue from "../core/agent_flue.js";
|
|
|
11
11
|
import * as agentOpencode from "../core/agent_opencode.js";
|
|
12
12
|
import * as notify from "../core/notify/notifier.js";
|
|
13
13
|
import * as otel from "../core/otel.js";
|
|
14
|
+
import * as otelMetrics from "../core/otel_metrics.js";
|
|
14
15
|
import * as paths from "../core/paths.js";
|
|
15
16
|
import * as sandbox from "../core/sandbox.js";
|
|
16
17
|
import { findChain, registerRepoChains, repoChainProblems } from "../chains/index.js";
|
|
@@ -261,5 +262,10 @@ export async function main() {
|
|
|
261
262
|
// deadline, so a fast-exiting command neither drops spans mid-flight nor
|
|
262
263
|
// waits on an unreachable collector. Never throws (core/otel.ts).
|
|
263
264
|
await otel.flushAll();
|
|
265
|
+
// Shuts down the process-scoped OTel MeterProvider (a no-op if metrics
|
|
266
|
+
// were never configured/resolved this process) — see otel_metrics.ts's
|
|
267
|
+
// own module header for why this is process-scoped rather than per-run
|
|
268
|
+
// like the span exporter above. Never throws.
|
|
269
|
+
await otelMetrics.shutdownOtelMetrics();
|
|
264
270
|
}
|
|
265
271
|
}
|
package/dist/core/agent_cc.d.ts
CHANGED
|
@@ -115,6 +115,17 @@ export declare class CcToolCallTracker {
|
|
|
115
115
|
private finish;
|
|
116
116
|
}
|
|
117
117
|
export declare function isKnownToolName(name: string): boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Merges `TRACEPARENT`/`ANTHROPIC_CUSTOM_HEADERS` into `baseEnv` for
|
|
120
|
+
* outbound OTel propagation, or returns `baseEnv` UNCHANGED when
|
|
121
|
+
* `otel` is absent (the common case — `observability.otel` not configured).
|
|
122
|
+
* `ANTHROPIC_CUSTOM_HEADERS`'s verified format is newline-separated
|
|
123
|
+
* `Name: Value` pairs (see this module's own header for the citation); an
|
|
124
|
+
* operator-supplied value already present in `baseEnv` is kept and appended
|
|
125
|
+
* to, not overwritten — a real header injected via config/settings must
|
|
126
|
+
* still reach the wire alongside this module's own.
|
|
127
|
+
*/
|
|
128
|
+
export declare function injectOtelEnv(baseEnv: Record<string, string>, otel: AgentRequest["otel"] | undefined): Record<string, string>;
|
|
118
129
|
/**
|
|
119
130
|
* Resolve `SPF_CLAUDE_CMD` for THIS call, substituting a literal `{model}`
|
|
120
131
|
* token with `model` — see the module doc comment for why a fixed tag baked
|
package/dist/core/agent_cc.js
CHANGED
|
@@ -248,6 +248,27 @@ const EFFORT_MAP = {
|
|
|
248
248
|
xhigh: "xhigh",
|
|
249
249
|
max: "max",
|
|
250
250
|
};
|
|
251
|
+
/**
|
|
252
|
+
* Merges `TRACEPARENT`/`ANTHROPIC_CUSTOM_HEADERS` into `baseEnv` for
|
|
253
|
+
* outbound OTel propagation, or returns `baseEnv` UNCHANGED when
|
|
254
|
+
* `otel` is absent (the common case — `observability.otel` not configured).
|
|
255
|
+
* `ANTHROPIC_CUSTOM_HEADERS`'s verified format is newline-separated
|
|
256
|
+
* `Name: Value` pairs (see this module's own header for the citation); an
|
|
257
|
+
* operator-supplied value already present in `baseEnv` is kept and appended
|
|
258
|
+
* to, not overwritten — a real header injected via config/settings must
|
|
259
|
+
* still reach the wire alongside this module's own.
|
|
260
|
+
*/
|
|
261
|
+
export function injectOtelEnv(baseEnv, otel) {
|
|
262
|
+
if (!otel)
|
|
263
|
+
return baseEnv;
|
|
264
|
+
const ownHeaders = [`traceparent: ${otel.traceparent}`, `x-request-id: ${otel.x_request_id}`].join("\n");
|
|
265
|
+
const existing = baseEnv.ANTHROPIC_CUSTOM_HEADERS;
|
|
266
|
+
return {
|
|
267
|
+
...baseEnv,
|
|
268
|
+
TRACEPARENT: otel.traceparent,
|
|
269
|
+
ANTHROPIC_CUSTOM_HEADERS: existing ? `${existing}\n${ownHeaders}` : ownHeaders,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
251
272
|
/**
|
|
252
273
|
* Resolve `SPF_CLAUDE_CMD` for THIS call, substituting a literal `{model}`
|
|
253
274
|
* token with `model` — see the module doc comment for why a fixed tag baked
|
|
@@ -337,7 +358,24 @@ export async function run(request, onEvent, onSpawn, onExit) {
|
|
|
337
358
|
const isOllamaLaunch = isOllamaLaunchCmd(cmdSpec);
|
|
338
359
|
const needsOllamaLaunchSeparator = isOllamaLaunch && !cmdArgs.includes("--");
|
|
339
360
|
const fullArgs = needsOllamaLaunchSeparator ? [...cmdArgs, "--", ...args] : [...cmdArgs, ...args];
|
|
340
|
-
|
|
361
|
+
// Outbound OTel propagation (SPF's otel-sdk extension): `request.otel` is
|
|
362
|
+
// set only when `observability.otel` is configured for this run (see
|
|
363
|
+
// `agents.ts`'s `send()`) — absent otherwise, so `env` below is
|
|
364
|
+
// byte-identical to before this existed for every repo that hasn't
|
|
365
|
+
// configured otel. `TRACEPARENT` is the standard W3C env var the `claude`
|
|
366
|
+
// CLI's own subprocesses/telemetry already look for; `ANTHROPIC_CUSTOM_
|
|
367
|
+
// HEADERS` additionally puts `traceparent` (redundant with the env var,
|
|
368
|
+
// but this is the only way to reach the ACTUAL outbound HTTP request the
|
|
369
|
+
// CLI itself makes to its configured `ANTHROPIC_BASE_URL`) and
|
|
370
|
+
// `x-request-id` (this agent call's own span id) onto that request's
|
|
371
|
+
// headers. Format verified against Claude Code's own docs (https://
|
|
372
|
+
// code.claude.com/docs/en/env-vars, fetched live for this feature —
|
|
373
|
+
// requires CLI >= 2.1.227): "Custom headers to add to requests (`Name:
|
|
374
|
+
// Value` format, newline-separated for multiple headers)". An
|
|
375
|
+
// operator-supplied `ANTHROPIC_CUSTOM_HEADERS` already present in
|
|
376
|
+
// `request.env` is PRESERVED, not clobbered — this appends to it.
|
|
377
|
+
const env = injectOtelEnv(request.env ?? operatorEnv(), request.otel);
|
|
378
|
+
const child = spawn(cmd, fullArgs, { cwd: request.cwd, env });
|
|
341
379
|
// The prompt travels as a positional argv element, not stdin — closing it
|
|
342
380
|
// immediately avoids a real, observed ~3s "no stdin data received" stall
|
|
343
381
|
// where `claude` otherwise waits to see whether anything is piped in.
|
package/dist/core/agent_flue.js
CHANGED
|
@@ -30,6 +30,7 @@ import { registerOllamaModel } from "./ollama_provider.js";
|
|
|
30
30
|
import { registerCloudflareModel } from "./cloudflare_provider.js";
|
|
31
31
|
import * as sandbox from "./sandbox.js";
|
|
32
32
|
import { nowIso, operatorEnv } from "./utils.js";
|
|
33
|
+
import { installFluePropagation, registerFlueSessionTrace, unregisterFlueSessionTrace } from "./otel_propagation.js";
|
|
33
34
|
const RESULT_SNIPPET_CHARS = 20_000; // tool output rides along whole; clip only guards pathological cases
|
|
34
35
|
const ARG_VALUE_CHARS = 20_000; // args too — the UI scrolls, it must not be handed cut-off data
|
|
35
36
|
const LABEL_CHARS = 80; // "bash: <command>" shown as the event name
|
|
@@ -402,6 +403,14 @@ export async function run(request, onEvent, onSpawn, onExit) {
|
|
|
402
403
|
// real-Bearer-token (not dummy-key) auth.
|
|
403
404
|
if (provider === "cloudflare")
|
|
404
405
|
await registerCloudflareModel(modelId);
|
|
406
|
+
// Outbound OTel propagation (SPF's otel-sdk extension) — see
|
|
407
|
+
// `otel_propagation.ts`'s own header for what this does and does not
|
|
408
|
+
// guarantee. `request.otel` is set only when `observability.otel` is
|
|
409
|
+
// configured for this run (see `agents.ts`'s `send()`); the installer is
|
|
410
|
+
// itself a no-op on `undefined` AND idempotent across every later call in
|
|
411
|
+
// this same process, so this costs nothing for a repo that hasn't
|
|
412
|
+
// configured otel and installs at most once for one that has.
|
|
413
|
+
installFluePropagation(request.otel);
|
|
405
414
|
await ensureRuntime(request.flue_db_path);
|
|
406
415
|
REGISTRY.set(request.session_id, {
|
|
407
416
|
model: request.model,
|
|
@@ -417,6 +426,19 @@ export async function run(request, onEvent, onSpawn, onExit) {
|
|
|
417
426
|
const pid = process.pid ?? -1;
|
|
418
427
|
onSpawn?.(pid);
|
|
419
428
|
const handle = init(SfAgent, { id: request.session_id });
|
|
429
|
+
// Join this session's flue spans to SPF's deterministic trace: the session
|
|
430
|
+
// id (== flue's instance id, which every flue observability event's ctx.id
|
|
431
|
+
// carries — see otel_propagation.ts's header) is registered against this
|
|
432
|
+
// call's traceparent, and the installed instrumentation resolves root
|
|
433
|
+
// spans through that map per span. WRAPPING dispatch() in a context here
|
|
434
|
+
// would be WRONG, not just redundant (verified against @flue/runtime's
|
|
435
|
+
// node dist): execution runs in ONE process-lifetime claim loop whose
|
|
436
|
+
// async context is captured by the first dispatch that started it, so a
|
|
437
|
+
// dispatch-time context wrap would silently mis-attribute every later
|
|
438
|
+
// agent's spans into the FIRST agent's trace. No-op when otel is
|
|
439
|
+
// unconfigured.
|
|
440
|
+
if (request.otel)
|
|
441
|
+
registerFlueSessionTrace(request.session_id, request.otel.traceparent);
|
|
420
442
|
const receipt = await handle.dispatch(request.prompt);
|
|
421
443
|
const slot = { usage: new UsageBreakdown(), context_tokens: 0 };
|
|
422
444
|
pendingUsage.set(receipt.submissionId, slot);
|
|
@@ -455,6 +477,10 @@ export async function run(request, onEvent, onSpawn, onExit) {
|
|
|
455
477
|
}
|
|
456
478
|
finally {
|
|
457
479
|
pendingUsage.delete(receipt.submissionId);
|
|
480
|
+
// Post-settlement bookkeeping spans flue mints after this point resolve
|
|
481
|
+
// to an unparented root (separate trace) — by design; see
|
|
482
|
+
// otel_propagation.ts's unregisterFlueSessionTrace.
|
|
483
|
+
unregisterFlueSessionTrace(request.session_id);
|
|
458
484
|
onExit?.(pid);
|
|
459
485
|
}
|
|
460
486
|
}
|
|
@@ -65,9 +65,48 @@
|
|
|
65
65
|
* docs, a target repo's OWN project-level `opencode.json` (if one exists)
|
|
66
66
|
* merges at HIGHER precedence than the file `OPENCODE_CONFIG` points at.
|
|
67
67
|
* That means a repo carrying its own `opencode.json` can silently override
|
|
68
|
-
* (widen or narrow) the restriction this module writes
|
|
69
|
-
*
|
|
70
|
-
* can paper over
|
|
68
|
+
* (widen or narrow) the restriction this module writes, and likewise
|
|
69
|
+
* override the OTel propagation headers described below — a real,
|
|
70
|
+
* documented gap in both guarantees, not a bug this module can paper over
|
|
71
|
+
* from the outside.
|
|
72
|
+
*
|
|
73
|
+
* OUTBOUND OTEL PROPAGATION [OFFICIAL config surface; best-effort
|
|
74
|
+
* behavior]: when `request.otel` is present (`agents.ts`'s `send()` threads
|
|
75
|
+
* it only when `observability.otel` is configured — absent otherwise, and
|
|
76
|
+
* this whole path is byte-identical to before), this module propagates
|
|
77
|
+
* trace context two ways:
|
|
78
|
+
*
|
|
79
|
+
* 1. `TRACEPARENT` on the child's env — the standard W3C env var, same
|
|
80
|
+
* shape `agent_cc.ts` sets. No published statement confirms the
|
|
81
|
+
* opencode CLI itself reads it (UNVERIFIED either way; set for parity
|
|
82
|
+
* and for any opencode-spawned subprocess telemetry, at zero cost).
|
|
83
|
+
* 2. `traceparent` + `x-request-id` as STATIC provider headers in the
|
|
84
|
+
* temp `opencode.json` (`provider.<id>.options.headers` — opencode's
|
|
85
|
+
* documented per-provider options surface;
|
|
86
|
+
* https://opencode.ai/docs/providers/). Static values are CORRECT
|
|
87
|
+
* here, unlike the general case, because one `opencode run` subprocess
|
|
88
|
+
* IS exactly one SPF agent call — the traceparent can never go stale
|
|
89
|
+
* mid-run. This is the header that actually reaches the wire:
|
|
90
|
+
* opencode's provider requests flow through the AI SDK, which honors
|
|
91
|
+
* `options.headers` for that provider's requests. Subject to the
|
|
92
|
+
* CONFIG PRECEDENCE limitation above: a repo's own `opencode.json` can
|
|
93
|
+
* override these headers.
|
|
94
|
+
*
|
|
95
|
+
* `injectOtelEnv()` / `otelProviderHeaders()` / `tempConfigContents()` are
|
|
96
|
+
* exported pure functions so every fragment is unit-testable without
|
|
97
|
+
* spawning a real subprocess — same discipline as the rest of this module.
|
|
98
|
+
*
|
|
99
|
+
* OPERATOR-CONFIG MERGE: when a caller-provided `OPENCODE_CONFIG` already
|
|
100
|
+
* exists in the base env (operatorEnv() passthrough or an agent's
|
|
101
|
+
* env_allowlist) AND this module needs a temp config of its own (a tools:
|
|
102
|
+
* restriction or the propagation headers), the operator's file is READ and
|
|
103
|
+
* its contents MERGED into the temp file (SPF's own blocks win on
|
|
104
|
+
* conflict — see `mergeOperatorConfig()`), rather than the pre-existing
|
|
105
|
+
* replace-it-outright behavior. This closes a real regression class:
|
|
106
|
+
* enabling OTel or a tools: list on an already-configured workflow used to
|
|
107
|
+
* silently drop the operator's provider routing/credentials config. An
|
|
108
|
+
* unreadable or unparseable operator file falls back to the old replace
|
|
109
|
+
* behavior (and is left untouched on disk) rather than failing the run.
|
|
71
110
|
*
|
|
72
111
|
* `write`/`apply_patch` ARE GATED THROUGH `edit` [OFFICIAL]: opencode's own
|
|
73
112
|
* docs say these two are not independent permission keys — both ride the
|
|
@@ -206,6 +245,69 @@ export declare class OcToolCallTracker {
|
|
|
206
245
|
observe(message: OcStreamMessage): Record<string, any> | null;
|
|
207
246
|
}
|
|
208
247
|
export declare function isKnownToolName(name: string): boolean;
|
|
248
|
+
/**
|
|
249
|
+
* Merges `TRACEPARENT` into `baseEnv` for outbound OTel propagation, or
|
|
250
|
+
* returns `baseEnv` UNCHANGED when `otel` is absent (the common case —
|
|
251
|
+
* `observability.otel` not configured). See the module doc comment's
|
|
252
|
+
* OUTBOUND OTEL PROPAGATION section: unlike `agent_cc.ts`'s sibling, no
|
|
253
|
+
* published statement confirms the opencode CLI itself reads this var, so
|
|
254
|
+
* the REAL propagation path is the temp-config headers below; this env var
|
|
255
|
+
* is set for parity at zero cost.
|
|
256
|
+
*/
|
|
257
|
+
export declare function injectOtelEnv(baseEnv: Record<string, string>, otel: AgentRequest["otel"] | undefined): Record<string, string>;
|
|
258
|
+
export interface OpencodeOtelHeaders {
|
|
259
|
+
provider: string;
|
|
260
|
+
headers: {
|
|
261
|
+
traceparent: string;
|
|
262
|
+
"x-request-id": string;
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Builds the `provider.<id>.options.headers` fragment for a temp
|
|
267
|
+
* `opencode.json` (see the module doc comment's OUTBOUND OTEL PROPAGATION
|
|
268
|
+
* section for why static values are correct here). Returns `null` when
|
|
269
|
+
* `otel` is absent, or when the model id carries no `provider/` prefix —
|
|
270
|
+
* opencode's own `--model` vocabulary is documented as `provider/model-id`,
|
|
271
|
+
* but a bare model name has no provider id this module could key headers
|
|
272
|
+
* under, and guessing the wrong provider id would write a config block
|
|
273
|
+
* opencode merges onto a DIFFERENT provider than the one being called.
|
|
274
|
+
*/
|
|
275
|
+
export declare function otelProviderHeaders(model: string, otel: AgentRequest["otel"] | undefined): OpencodeOtelHeaders | null;
|
|
276
|
+
/**
|
|
277
|
+
* Builds the JSON CONTENT of the temp `opencode.json`: a `permission` map
|
|
278
|
+
* when `toolNames` is an array (including `[]` — restrict to exactly those,
|
|
279
|
+
* mirroring CC's `--tools`), a `provider` block when `otelHeaders` is
|
|
280
|
+
* present, both when both apply. `null` when NEITHER applies — no config
|
|
281
|
+
* file needed at all, `--auto` alone (every tool usable, mirroring CC's
|
|
282
|
+
* `"default"`). Exported as its own pure function, same reasoning as
|
|
283
|
+
* `resolveOpencodeCmdSpec`: this shape is unit-testable without touching
|
|
284
|
+
* the filesystem or spawning a subprocess.
|
|
285
|
+
*/
|
|
286
|
+
export declare function tempConfigContents(toolNames: string[] | null | undefined, otelHeaders: OpencodeOtelHeaders | null): Record<string, unknown> | null;
|
|
287
|
+
/**
|
|
288
|
+
* Recursive plain-object merge of SPF's own temp-config blocks ONTO the
|
|
289
|
+
* operator's existing `OPENCODE_CONFIG` contents — SPF's values win on
|
|
290
|
+
* conflicting plain-object leaves/deeper keys (a `tools:` restriction is a
|
|
291
|
+
* safety gate and the propagation headers are this call's own, so neither
|
|
292
|
+
* may be silently watered down), while every key the operator set and SPF
|
|
293
|
+
* never touches (provider `baseURL`/`apiKey`/model routing, MCP servers,
|
|
294
|
+
* agents) passes through intact. Arrays are replaced, not concatenated:
|
|
295
|
+
* this module never writes arrays itself, so a replaced array is always
|
|
296
|
+
* one of the OPERATOR's own values being deliberately overridden by an
|
|
297
|
+
* SPF block — the same "SPF wins on conflict" rule.
|
|
298
|
+
*/
|
|
299
|
+
export declare function mergeOperatorConfig(base: Record<string, unknown>, overlay: Record<string, unknown>): Record<string, unknown>;
|
|
300
|
+
/**
|
|
301
|
+
* Reads the operator's existing `OPENCODE_CONFIG` file for merging, or
|
|
302
|
+
* returns `null` when there is nothing usable to merge with — file missing
|
|
303
|
+
* / unreadable / not valid JSON with a plain-object root. JSON.parse is
|
|
304
|
+
* deliberately used as-is: opencode's own docs advertise plain-JSON config,
|
|
305
|
+
* and silently treating a JSONC file (comments) as replaceable would repeat
|
|
306
|
+
* the pre-existing clobber the merge exists to fix — so an unparseable file
|
|
307
|
+
* simply falls through to the pre-existing replace behavior, with the
|
|
308
|
+
* operator's own file untouched on disk.
|
|
309
|
+
*/
|
|
310
|
+
export declare function readOperatorConfig(configPath: string): Record<string, unknown> | null;
|
|
209
311
|
/**
|
|
210
312
|
* Resolve `SPF_OPENCODE_CMD` for THIS call, substituting a literal `{model}`
|
|
211
313
|
* token with `model` — same mechanism and rationale as `agent_cc.ts`'s
|
|
@@ -65,9 +65,48 @@
|
|
|
65
65
|
* docs, a target repo's OWN project-level `opencode.json` (if one exists)
|
|
66
66
|
* merges at HIGHER precedence than the file `OPENCODE_CONFIG` points at.
|
|
67
67
|
* That means a repo carrying its own `opencode.json` can silently override
|
|
68
|
-
* (widen or narrow) the restriction this module writes
|
|
69
|
-
*
|
|
70
|
-
* can paper over
|
|
68
|
+
* (widen or narrow) the restriction this module writes, and likewise
|
|
69
|
+
* override the OTel propagation headers described below — a real,
|
|
70
|
+
* documented gap in both guarantees, not a bug this module can paper over
|
|
71
|
+
* from the outside.
|
|
72
|
+
*
|
|
73
|
+
* OUTBOUND OTEL PROPAGATION [OFFICIAL config surface; best-effort
|
|
74
|
+
* behavior]: when `request.otel` is present (`agents.ts`'s `send()` threads
|
|
75
|
+
* it only when `observability.otel` is configured — absent otherwise, and
|
|
76
|
+
* this whole path is byte-identical to before), this module propagates
|
|
77
|
+
* trace context two ways:
|
|
78
|
+
*
|
|
79
|
+
* 1. `TRACEPARENT` on the child's env — the standard W3C env var, same
|
|
80
|
+
* shape `agent_cc.ts` sets. No published statement confirms the
|
|
81
|
+
* opencode CLI itself reads it (UNVERIFIED either way; set for parity
|
|
82
|
+
* and for any opencode-spawned subprocess telemetry, at zero cost).
|
|
83
|
+
* 2. `traceparent` + `x-request-id` as STATIC provider headers in the
|
|
84
|
+
* temp `opencode.json` (`provider.<id>.options.headers` — opencode's
|
|
85
|
+
* documented per-provider options surface;
|
|
86
|
+
* https://opencode.ai/docs/providers/). Static values are CORRECT
|
|
87
|
+
* here, unlike the general case, because one `opencode run` subprocess
|
|
88
|
+
* IS exactly one SPF agent call — the traceparent can never go stale
|
|
89
|
+
* mid-run. This is the header that actually reaches the wire:
|
|
90
|
+
* opencode's provider requests flow through the AI SDK, which honors
|
|
91
|
+
* `options.headers` for that provider's requests. Subject to the
|
|
92
|
+
* CONFIG PRECEDENCE limitation above: a repo's own `opencode.json` can
|
|
93
|
+
* override these headers.
|
|
94
|
+
*
|
|
95
|
+
* `injectOtelEnv()` / `otelProviderHeaders()` / `tempConfigContents()` are
|
|
96
|
+
* exported pure functions so every fragment is unit-testable without
|
|
97
|
+
* spawning a real subprocess — same discipline as the rest of this module.
|
|
98
|
+
*
|
|
99
|
+
* OPERATOR-CONFIG MERGE: when a caller-provided `OPENCODE_CONFIG` already
|
|
100
|
+
* exists in the base env (operatorEnv() passthrough or an agent's
|
|
101
|
+
* env_allowlist) AND this module needs a temp config of its own (a tools:
|
|
102
|
+
* restriction or the propagation headers), the operator's file is READ and
|
|
103
|
+
* its contents MERGED into the temp file (SPF's own blocks win on
|
|
104
|
+
* conflict — see `mergeOperatorConfig()`), rather than the pre-existing
|
|
105
|
+
* replace-it-outright behavior. This closes a real regression class:
|
|
106
|
+
* enabling OTel or a tools: list on an already-configured workflow used to
|
|
107
|
+
* silently drop the operator's provider routing/credentials config. An
|
|
108
|
+
* unreadable or unparseable operator file falls back to the old replace
|
|
109
|
+
* behavior (and is left untouched on disk) rather than failing the run.
|
|
71
110
|
*
|
|
72
111
|
* `write`/`apply_patch` ARE GATED THROUGH `edit` [OFFICIAL]: opencode's own
|
|
73
112
|
* docs say these two are not independent permission keys — both ride the
|
|
@@ -182,7 +221,7 @@
|
|
|
182
221
|
import { spawn } from "node:child_process";
|
|
183
222
|
import { createInterface } from "node:readline";
|
|
184
223
|
import { randomUUID } from "node:crypto";
|
|
185
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
224
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
186
225
|
import os from "node:os";
|
|
187
226
|
import path from "node:path";
|
|
188
227
|
import { UsageBreakdown, makeAgentResult } from "./data_types.js";
|
|
@@ -359,16 +398,114 @@ function permissionMapFor(toolNames) {
|
|
|
359
398
|
return permission;
|
|
360
399
|
}
|
|
361
400
|
/**
|
|
362
|
-
* `
|
|
363
|
-
*
|
|
364
|
-
* `
|
|
365
|
-
*
|
|
366
|
-
*
|
|
401
|
+
* Merges `TRACEPARENT` into `baseEnv` for outbound OTel propagation, or
|
|
402
|
+
* returns `baseEnv` UNCHANGED when `otel` is absent (the common case —
|
|
403
|
+
* `observability.otel` not configured). See the module doc comment's
|
|
404
|
+
* OUTBOUND OTEL PROPAGATION section: unlike `agent_cc.ts`'s sibling, no
|
|
405
|
+
* published statement confirms the opencode CLI itself reads this var, so
|
|
406
|
+
* the REAL propagation path is the temp-config headers below; this env var
|
|
407
|
+
* is set for parity at zero cost.
|
|
408
|
+
*/
|
|
409
|
+
export function injectOtelEnv(baseEnv, otel) {
|
|
410
|
+
if (!otel)
|
|
411
|
+
return baseEnv;
|
|
412
|
+
return { ...baseEnv, TRACEPARENT: otel.traceparent };
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Builds the `provider.<id>.options.headers` fragment for a temp
|
|
416
|
+
* `opencode.json` (see the module doc comment's OUTBOUND OTEL PROPAGATION
|
|
417
|
+
* section for why static values are correct here). Returns `null` when
|
|
418
|
+
* `otel` is absent, or when the model id carries no `provider/` prefix —
|
|
419
|
+
* opencode's own `--model` vocabulary is documented as `provider/model-id`,
|
|
420
|
+
* but a bare model name has no provider id this module could key headers
|
|
421
|
+
* under, and guessing the wrong provider id would write a config block
|
|
422
|
+
* opencode merges onto a DIFFERENT provider than the one being called.
|
|
423
|
+
*/
|
|
424
|
+
export function otelProviderHeaders(model, otel) {
|
|
425
|
+
if (!otel)
|
|
426
|
+
return null;
|
|
427
|
+
const slash = model.indexOf("/");
|
|
428
|
+
if (slash <= 0)
|
|
429
|
+
return null;
|
|
430
|
+
return { provider: model.slice(0, slash), headers: { traceparent: otel.traceparent, "x-request-id": otel.x_request_id } };
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Builds the JSON CONTENT of the temp `opencode.json`: a `permission` map
|
|
434
|
+
* when `toolNames` is an array (including `[]` — restrict to exactly those,
|
|
435
|
+
* mirroring CC's `--tools`), a `provider` block when `otelHeaders` is
|
|
436
|
+
* present, both when both apply. `null` when NEITHER applies — no config
|
|
437
|
+
* file needed at all, `--auto` alone (every tool usable, mirroring CC's
|
|
438
|
+
* `"default"`). Exported as its own pure function, same reasoning as
|
|
439
|
+
* `resolveOpencodeCmdSpec`: this shape is unit-testable without touching
|
|
440
|
+
* the filesystem or spawning a subprocess.
|
|
441
|
+
*/
|
|
442
|
+
export function tempConfigContents(toolNames, otelHeaders) {
|
|
443
|
+
const contents = {};
|
|
444
|
+
if (toolNames != null)
|
|
445
|
+
contents.permission = permissionMapFor(toolNames);
|
|
446
|
+
if (otelHeaders)
|
|
447
|
+
contents.provider = { [otelHeaders.provider]: { options: { headers: otelHeaders.headers } } };
|
|
448
|
+
return Object.keys(contents).length === 0 ? null : contents;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Recursive plain-object merge of SPF's own temp-config blocks ONTO the
|
|
452
|
+
* operator's existing `OPENCODE_CONFIG` contents — SPF's values win on
|
|
453
|
+
* conflicting plain-object leaves/deeper keys (a `tools:` restriction is a
|
|
454
|
+
* safety gate and the propagation headers are this call's own, so neither
|
|
455
|
+
* may be silently watered down), while every key the operator set and SPF
|
|
456
|
+
* never touches (provider `baseURL`/`apiKey`/model routing, MCP servers,
|
|
457
|
+
* agents) passes through intact. Arrays are replaced, not concatenated:
|
|
458
|
+
* this module never writes arrays itself, so a replaced array is always
|
|
459
|
+
* one of the OPERATOR's own values being deliberately overridden by an
|
|
460
|
+
* SPF block — the same "SPF wins on conflict" rule.
|
|
461
|
+
*/
|
|
462
|
+
export function mergeOperatorConfig(base, overlay) {
|
|
463
|
+
const out = { ...base };
|
|
464
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
465
|
+
const existing = out[key];
|
|
466
|
+
out[key] =
|
|
467
|
+
existing && typeof existing === "object" && !Array.isArray(existing) && value && typeof value === "object" && !Array.isArray(value)
|
|
468
|
+
? mergeOperatorConfig(existing, value)
|
|
469
|
+
: value;
|
|
470
|
+
}
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Reads the operator's existing `OPENCODE_CONFIG` file for merging, or
|
|
475
|
+
* returns `null` when there is nothing usable to merge with — file missing
|
|
476
|
+
* / unreadable / not valid JSON with a plain-object root. JSON.parse is
|
|
477
|
+
* deliberately used as-is: opencode's own docs advertise plain-JSON config,
|
|
478
|
+
* and silently treating a JSONC file (comments) as replaceable would repeat
|
|
479
|
+
* the pre-existing clobber the merge exists to fix — so an unparseable file
|
|
480
|
+
* simply falls through to the pre-existing replace behavior, with the
|
|
481
|
+
* operator's own file untouched on disk.
|
|
482
|
+
*/
|
|
483
|
+
export function readOperatorConfig(configPath) {
|
|
484
|
+
try {
|
|
485
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
486
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Writes `contents` (see `tempConfigContents`) to a temp `opencode.json`.
|
|
494
|
+
* Caller is responsible for `rmSync`-ing `dir` when done — see `run()`'s
|
|
495
|
+
* `finally`.
|
|
367
496
|
*/
|
|
368
|
-
function
|
|
497
|
+
function writeTempConfig(contents) {
|
|
369
498
|
const dir = mkdtempSync(path.join(os.tmpdir(), "spf-opencode-"));
|
|
370
499
|
const configPath = path.join(dir, "opencode.json");
|
|
371
|
-
|
|
500
|
+
try {
|
|
501
|
+
writeFileSync(configPath, JSON.stringify(contents, null, 2));
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
// This runs BEFORE run()'s try/finally — a failed write would otherwise
|
|
505
|
+
// leak the just-minted temp dir with nobody responsible for it.
|
|
506
|
+
rmSync(dir, { recursive: true, force: true });
|
|
507
|
+
throw err;
|
|
508
|
+
}
|
|
372
509
|
return { dir, configPath };
|
|
373
510
|
}
|
|
374
511
|
// SPF's off|minimal|low|medium|high|xhigh|max -> opencode's --variant.
|
|
@@ -464,12 +601,27 @@ export async function run(request, onEvent, onSpawn, onExit) {
|
|
|
464
601
|
const [cmd, ...cmdArgs] = cmdTokens;
|
|
465
602
|
const fullArgs = [...cmdArgs, ...args];
|
|
466
603
|
const baseEnv = request.env ?? operatorEnv();
|
|
467
|
-
// `request.tools` null/undefined -> every tool, no
|
|
468
|
-
// all (see
|
|
469
|
-
//
|
|
470
|
-
// OPENCODE_CONFIG on the CHILD's env
|
|
471
|
-
|
|
472
|
-
const
|
|
604
|
+
// `request.tools` null/undefined AND no otel config -> every tool, no
|
|
605
|
+
// config file written at all (see tempConfigContents' own doc comment).
|
|
606
|
+
// Otherwise -> a real temp opencode.json (tool restriction, OTel provider
|
|
607
|
+
// headers, or both), pointed at via OPENCODE_CONFIG on the CHILD's env
|
|
608
|
+
// only — never mutates process.env.
|
|
609
|
+
const otelHeaders = otelProviderHeaders(request.model, request.otel);
|
|
610
|
+
let configContents = tempConfigContents(request.tools, otelHeaders);
|
|
611
|
+
// A caller-provided OPENCODE_CONFIG (operatorEnv() passthrough or an
|
|
612
|
+
// agent's env_allowlist) is MERGED into the temp file, never replaced —
|
|
613
|
+
// SPF's own blocks win on conflict (see mergeOperatorConfig), but
|
|
614
|
+
// everything else the operator configured (provider baseURL/apiKey/model
|
|
615
|
+
// routing, MCP servers) survives. Without this, enabling OTel or a tools:
|
|
616
|
+
// list on an already-configured workflow would silently re-route or break
|
|
617
|
+
// agent calls.
|
|
618
|
+
if (configContents && baseEnv.OPENCODE_CONFIG) {
|
|
619
|
+
const operatorCfg = readOperatorConfig(baseEnv.OPENCODE_CONFIG);
|
|
620
|
+
if (operatorCfg)
|
|
621
|
+
configContents = mergeOperatorConfig(operatorCfg, configContents);
|
|
622
|
+
}
|
|
623
|
+
const tempConfig = configContents ? writeTempConfig(configContents) : null;
|
|
624
|
+
const childEnv = injectOtelEnv(tempConfig ? { ...baseEnv, OPENCODE_CONFIG: tempConfig.configPath } : baseEnv, request.otel);
|
|
473
625
|
try {
|
|
474
626
|
const child = spawn(cmd, fullArgs, { cwd: request.cwd, env: childEnv });
|
|
475
627
|
// See the module doc comment's STDIN note — closed immediately to avoid
|