Skip to content

Commit 7744e0a

Browse files
feat: update opencode patch with ATOF JSONL export (NVIDIA#62)
#### Overview Refreshes the OpenCode integration patch so NeMo Flow can preserve raw ATOF events as a JSONL sidecar artifact while keeping the existing optional direct ATIF export path. The raw ATOF JSONL artifact is intentionally preserved as the lossless publisher output. ATIF remains the normalized evaluation artifact, but keeping ATOF lets us validate the harness publisher independently, debug converter issues, and regenerate ATIF/evaluation artifacts offline without rerunning the agent task. This is especially useful while the NeMo-Flow harness integrations and ATOF-to-ATIF mapping are still evolving. - [x] I confirm this contribution is my own work, or I have the right to submit it under this project's license. - [x] I searched existing issues and open pull requests, and this does not duplicate existing work. #### Details - Adds a raw ATOF JSONL subscriber controlled by `NEMO_FLOW_ATOF_DIR`, writing `events.jsonl`. - Keeps direct ATIF export optional via `NEMO_FLOW_ATIF_DIR` for comparison/debug output. - Updates the OpenCode patch so wrapped tools return OpenCode's original JavaScript values while NeMo Flow observes JSON-safe snapshots. - Avoids duplicate tool execution if native event observation fails after the wrapped callback has already run. - Ensures batch tool scopes are popped with a `finally` block. - Documents the artifact paths and validation flow in `third_party/README-opencode.md`. #### Where should the reviewer start? Start with `patches/opencode/0001-add-nemo-flow-integration.patch`, especially the `src/nemo_flow/index.ts` additions for the ATOF JSONL subscriber and tool wrapper behavior. Then review `third_party/README-opencode.md` for the documented contract. Validation run locally: - `git diff --check` - `./scripts/apply-patches.sh --check` - `bun run typecheck` in `third_party/opencode/packages/opencode` - `uv run pre-commit run --files patches/opencode/0001-add-nemo-flow-integration.patch third_party/README-opencode.md` #### Related Issues: (use one of the action keywords Closes / Fixes / Resolves / Relates to) - Relates to NAT-6, Harbor / NAT integration smoke testing for OpenCode with NeMo Flow ATOF export ## Summary by CodeRabbit * **New Features** * Optional NemoFlow integration for real-time tracing, monitoring, and agent/tool scope management * Session-based trajectory export (including ATIF JSONL) and NemoFlow-backed exporters * LLM requests now accept custom HTTP headers (extraHeaders passthrough) * **Chores** * Manifest updates to declare NemoFlow as an optional dependency and enable opt-in configuration flag Authors: - Anuradha Karuppiah (https://github.com/AnuradhaKaruppiah) Approvers: - Will Killian (https://github.com/willkill07) URL: NVIDIA#62
1 parent 30245c3 commit 7744e0a

2 files changed

Lines changed: 81 additions & 24 deletions

File tree

patches/opencode/0001-add-nemo-flow-integration.patch

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
diff --git a/bun.lock b/bun.lock
2-
index 35841622b..5c58f4a65 100644
2+
index 35841622b..1f99d0b39 100644
33
--- a/bun.lock
44
+++ b/bun.lock
55
@@ -23,6 +23,9 @@
@@ -308,7 +308,7 @@ index 35841622b..5c58f4a65 100644
308308

309309
+ "typedoc/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
310310
+
311-
+ "typedoc/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
311+
+ "typedoc/yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="],
312312
+
313313
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
314314

@@ -595,7 +595,7 @@ index 5bde2608f..b2b087e12 100644
595595
await Plugin.trigger(
596596
"tool.execute.after",
597597
diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts
598-
index 00c22bfe6..2648c2243 100644
598+
index 00c22bfe6..343816b57 100644
599599
--- a/packages/opencode/src/tool/batch.ts
600600
+++ b/packages/opencode/src/tool/batch.ts
601601
@@ -2,6 +2,7 @@ import z from "zod"
@@ -617,24 +617,30 @@ index 00c22bfe6..2648c2243 100644
617617
const attachments = result.attachments?.map((attachment) => ({
618618
...attachment,
619619
id: PartID.ascending(),
620-
@@ -130,7 +133,9 @@ export const BatchTool = Tool.define("batch", async () => {
620+
@@ -130,7 +133,13 @@ export const BatchTool = Tool.define("batch", async () => {
621621
}
622622
}
623623

624+
- const results = await Promise.all(toolCalls.map((call) => executeCall(call)))
624625
+ const batchScope = NemoFlow.pushFunctionScope("batch-parallel", NemoFlow.SCOPE_ATTR_PARALLEL)
625-
const results = await Promise.all(toolCalls.map((call) => executeCall(call)))
626-
+ NemoFlow.popScope(batchScope)
626+
+ let results: Awaited<ReturnType<typeof executeCall>>[]
627+
+ try {
628+
+ results = await Promise.all(toolCalls.map((call) => executeCall(call)))
629+
+ } finally {
630+
+ NemoFlow.popScope(batchScope)
631+
+ }
627632

628633
// Add discarded calls as errors
629634
const now = Date.now()
630635
diff --git a/packages/opencode/src/nemo_flow/index.ts b/packages/opencode/src/nemo_flow/index.ts
631636
new file mode 100644
632-
index 000000000..2cfe4d267
637+
index 000000000..f8cd52955
633638
--- /dev/null
634639
+++ b/packages/opencode/src/nemo_flow/index.ts
635-
@@ -0,0 +1,259 @@
640+
@@ -0,0 +1,292 @@
636641
+import { Log } from "../util/log"
637642
+import { Flag } from "../flag/flag"
643+
+import fsSync from "fs"
638644
+
639645
+const log = Log.create({ service: "nemo_flow" })
640646
+
@@ -644,6 +650,16 @@ index 000000000..2cfe4d267
644650
+let initDone = false
645651
+
646652
+const exporters = new Map<string, any>()
653+
+let atofJsonlSubscriberName: string | null = null
654+
+
655+
+function toJsonSafe(value: any): any {
656+
+ if (value === undefined) return null
657+
+ try {
658+
+ return JSON.parse(JSON.stringify(value))
659+
+ } catch {
660+
+ return null
661+
+ }
662+
+}
647663
+
648664
+export namespace NemoFlow {
649665
+ export let SCOPE_ATTR_PARALLEL = 0
@@ -706,19 +722,21 @@ index 000000000..2cfe4d267
706722
+ fn: (args: any) => Promise<T>,
707723
+ ): Promise<T> {
708724
+ if (!enabled || !lib) return fn(args)
725+
+ let originalResult: T
726+
+ let executed = false
709727
+ try {
710-
+ // Capture the original JS result to avoid the lossy NAPI JSON round-trip.
711-
+ // Tool results may contain non-JSON types (class instances, LSP diagnostics)
712-
+ // that don't survive serde_json serialization. NemoFlow still sees a JSON copy
713-
+ // for its events/intercepts; the caller gets the original object.
714-
+ let originalResult: T
715-
+ await lib.toolCallExecuteAsync(name, args, async (a: any) => {
716-
+ originalResult = await fn(a)
717-
+ return originalResult
728+
+ // Keep OpenCode's tool execution on its original JS values. The native
729+
+ // observer only needs JSON-safe snapshots; handing it OpenCode-owned
730+
+ // objects can make later structuredClone(part) calls fail.
731+
+ await lib.toolCallExecuteAsync(name, toJsonSafe(args), async () => {
732+
+ executed = true
733+
+ originalResult = await fn(args)
734+
+ return toJsonSafe(originalResult)
718735
+ }, null, null, null, null)
719736
+ return originalResult!
720737
+ } catch (e) {
721738
+ log.error("wrapToolExecute", { error: e })
739+
+ if (executed) return originalResult!
722740
+ return fn(args)
723741
+ }
724742
+ }
@@ -757,6 +775,7 @@ index 000000000..2cfe4d267
757775
+ yield* streamFn()
758776
+ return
759777
+ }
778+
+ let streamStarted = false
760779
+ try {
761780
+ const request = {
762781
+ headers: {},
@@ -803,6 +822,7 @@ index 000000000..2cfe4d267
803822
+ streamInput.model.providerID,
804823
+ request,
805824
+ (interceptedReq: any) => {
825+
+ streamStarted = true
806826
+ // Apply intercepted request changes back to streamInput before
807827
+ // streamFn() reads it — streamFn captures streamInput by reference.
808828
+ applyIntercepted(streamInput, interceptedReq?.content)
@@ -845,6 +865,7 @@ index 000000000..2cfe4d267
845865
+ }
846866
+ } catch (e) {
847867
+ log.error("wrapLlmStream", { error: e })
868+
+ if (streamStarted) throw e
848869
+ yield* streamFn()
849870
+ }
850871
+ }
@@ -891,13 +912,30 @@ index 000000000..2cfe4d267
891912
+ log.error("clearExporter", { error: e })
892913
+ }
893914
+ }
915+
+
916+
+ export function createAtOfJsonlExporter(filePath: string): boolean {
917+
+ if (!enabled || !lib) return false
918+
+ if (atofJsonlSubscriberName) return true
919+
+ try {
920+
+ const subscriberName = `atof-jsonl-${process.pid}`
921+
+ lib.registerSubscriber(subscriberName, (event: any) => {
922+
+ fsSync.appendFileSync(filePath, JSON.stringify(event) + "\n")
923+
+ })
924+
+ atofJsonlSubscriberName = subscriberName
925+
+ log.info("registered ATOF JSONL exporter", { path: filePath })
926+
+ return true
927+
+ } catch (e) {
928+
+ log.error("createAtOfJsonlExporter", { error: e })
929+
+ return false
930+
+ }
931+
+ }
894932
+}
895933
diff --git a/packages/opencode/src/plugin/nemo_flow.ts b/packages/opencode/src/plugin/nemo_flow.ts
896934
new file mode 100644
897-
index 000000000..c885bfae4
935+
index 000000000..5659fdd37
898936
--- /dev/null
899937
+++ b/packages/opencode/src/plugin/nemo_flow.ts
900-
@@ -0,0 +1,50 @@
938+
@@ -0,0 +1,54 @@
901939
+import type { Hooks, Plugin as PluginInstance } from "@opencode-ai/plugin"
902940
+import { Config } from "../config/config"
903941
+import { NemoFlow } from "../nemo_flow"
@@ -913,7 +951,11 @@ index 000000000..c885bfae4
913951
+ const enabled = await NemoFlow.init({ nemo_flow: config.experimental?.nemo_flow })
914952
+ if (!enabled) return {}
915953
+
916-
+ const atifDir = path.join(Global.Path.data, "atif")
954+
+ const atofDir = process.env.NEMO_FLOW_ATOF_DIR ?? path.join(Global.Path.data, "atof")
955+
+ await fs.mkdir(atofDir, { recursive: true })
956+
+ NemoFlow.createAtOfJsonlExporter(path.join(atofDir, "events.jsonl"))
957+
+
958+
+ const atifDir = process.env.NEMO_FLOW_ATIF_DIR ?? path.join(Global.Path.data, "atif")
917959
+ await fs.mkdir(atifDir, { recursive: true })
918960
+
919961
+ return {

third_party/README-opencode.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ This directory contains the NeMo Flow integration patch for
99
`third_party/opencode`.
1010

1111
The patch adds optional NeMo Flow tracing, LLM stream wrapping, tool execution
12-
wrapping, and ATIF export support to the opencode package. It depends on the
13-
local NeMo Flow Node binding through a `file:` dependency that resolves from
14-
`third_party/opencode/packages/opencode` back to `crates/node`.
12+
wrapping, raw ATOF JSONL export, and optional direct ATIF export support to the
13+
opencode package. The patch also wires opencode to the local NeMo Flow Node
14+
package with an optional `file:` dependency so the patched workspace can load
15+
`nemo-flow-node` when NeMo Flow tracing is enabled.
1516

1617
## Setup
1718

@@ -63,8 +64,18 @@ Alternatively, enable the patched experimental config flag:
6364
```
6465

6566
When enabled, opencode creates NeMo Flow scopes for agents and batched tool
66-
execution, wraps LLM streams and tool calls, and exports ATIF trajectories under
67-
the opencode data directory's `atif` subdirectory when a session becomes idle.
67+
execution, wraps LLM streams and tool calls, and registers a raw ATOF JSONL
68+
subscriber. Set `NEMO_FLOW_ATOF_DIR` to control where `events.jsonl` is written;
69+
otherwise it defaults to the opencode data directory's `atof` subdirectory.
70+
71+
Direct ATIF export is optional comparison output. Set `NEMO_FLOW_ATIF_DIR` to
72+
control where exported ATIF JSON files are written when a session becomes idle;
73+
otherwise it defaults to the opencode data directory's `atif` subdirectory.
74+
75+
The tool wrapper keeps opencode's execution on original JavaScript values while
76+
passing JSON-safe snapshots to the NeMo Flow native observer. This avoids
77+
`structuredClone()` failures in opencode while still preserving NeMo Flow tool
78+
events.
6879

6980
## Validation
7081

@@ -80,3 +91,7 @@ Also rerun the patch applicability check from the NeMo Flow repository root:
8091
```bash
8192
./scripts/apply-patches.sh --check
8293
```
94+
95+
For an end-to-end smoke, run an opencode task with `NEMO_FLOW_ENABLED=1` and
96+
verify that the configured `NEMO_FLOW_ATOF_DIR` contains an `events.jsonl` file
97+
with scope and tool/LLM events.

0 commit comments

Comments
 (0)