Skip to content

feat: enforce and optimise ToolMetadata sync flag#102

Merged
aarne merged 9 commits intomainfrom
copilot/enforce-sync-tool-handling
Mar 6, 2026
Merged

feat: enforce and optimise ToolMetadata sync flag#102
aarne merged 9 commits intomainfrom
copilot/enforce-sync-tool-handling

Conversation

Copy link
Contributor

Copilot AI commented Mar 6, 2026

  • Review new PR comment and confirm scope
  • Investigate recent CI workflow runs/logs context
  • Move sync metadata extraction into resolveToolMeta
  • Update ExecutionTree.callTool to consume sync from helper
  • Run targeted tests for sync tool behavior
  • Run repo validation (build, lint, test)
  • Run automated code review and CodeQL checks
  • Reply to the PR comment with commit hash

✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

@changeset-bot
Copy link

changeset-bot bot commented Mar 6, 2026

🦋 Changeset detected

Latest commit: 8421658

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@stackables/bridge-core Minor
@stackables/bridge-compiler Minor
@stackables/bridge-stdlib Patch
@stackables/bridge-graphql Patch
@stackables/bridge-parser Patch
@stackables/bridge Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copilot AI and others added 2 commits March 6, 2026 08:48
…d compiler

- Core: callTool() checks fn.bridge.sync, enforces no-promise return, skips
  timeout race and OTel span wrapper for sync tools
- Compiler: add __callSync() helper, use sync-aware branching at all call sites
- Compiler: dual-path array map optimization (sync .map() vs async for...of)
  with runtime check on tool sync flags

Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Mar 6, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
bridge 8421658 Commit Preview URL

Branch Preview URL
Mar 06 2026, 02:32 PM

…st name

Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
Copilot AI changed the title [WIP] Add enforcement of sync tool constraints and optimizations feat: enforce and optimise ToolMetadata sync flag Mar 6, 2026
Copilot finished work on behalf of aarne March 6, 2026 09:02
@aarne aarne marked this pull request as ready for review March 6, 2026 09:08
@github-actions
Copy link
Contributor

github-actions bot commented Mar 6, 2026

🐰 Bencher Report

Branchcopilot/enforce-sync-tool-handling
Testbedubuntu-latest
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
nanoseconds (ns)
(Result Δ%)
Upper Boundary
nanoseconds (ns)
(Limit %)
compiled: absolute baseline (passthrough, no tools)📈 view plot
🚷 view threshold
0.00 ns
(-0.76%)Baseline: 0.00 ns
0.00 ns
(91.49%)
compiled: array + SYNC tool-per-element 10📈 view plot
🚷 view threshold
0.01 ns
compiled: array + SYNC tool-per-element 100📈 view plot
🚷 view threshold
0.02 ns
compiled: array + tool-per-element 10📈 view plot
🚷 view threshold
0.01 ns
(-0.78%)Baseline: 0.01 ns
0.01 ns
(92.96%)
compiled: array + tool-per-element 100📈 view plot
🚷 view threshold
0.03 ns
(-2.99%)Baseline: 0.03 ns
0.04 ns
(91.10%)
compiled: chained 3-tool fan-out📈 view plot
🚷 view threshold
0.00 ns
(-0.31%)Baseline: 0.00 ns
0.00 ns
(92.89%)
compiled: flat array 10 items📈 view plot
🚷 view threshold
0.01 ns
(-0.07%)Baseline: 0.01 ns
0.01 ns
(93.44%)
compiled: flat array 100 items📈 view plot
🚷 view threshold
0.01 ns
(+0.90%)Baseline: 0.01 ns
0.01 ns
(95.68%)
compiled: flat array 1000 items📈 view plot
🚷 view threshold
0.07 ns
(-0.42%)Baseline: 0.07 ns
0.07 ns
(94.92%)
compiled: nested array 10x10📈 view plot
🚷 view threshold
0.02 ns
(-0.22%)Baseline: 0.02 ns
0.02 ns
(93.40%)
compiled: nested array 20x10📈 view plot
🚷 view threshold
0.03 ns
(+0.02%)Baseline: 0.03 ns
0.04 ns
(94.01%)
compiled: nested array 5x5📈 view plot
🚷 view threshold
0.01 ns
(-0.42%)Baseline: 0.01 ns
0.01 ns
(94.04%)
compiled: short-circuit (overdefinition bypass)📈 view plot
🚷 view threshold
0.00 ns
(-1.92%)Baseline: 0.00 ns
0.00 ns
(90.60%)
compiled: simple chain (1 tool)📈 view plot
🚷 view threshold
0.00 ns
(-1.07%)Baseline: 0.00 ns
0.00 ns
(91.68%)
exec: absolute baseline (passthrough, no tools)📈 view plot
🚷 view threshold
0.00 ns
(+4.62%)Baseline: 0.00 ns
0.00 ns
(85.52%)
exec: array + SYNC tool-per-element 10📈 view plot
🚷 view threshold
0.06 ns
exec: array + SYNC tool-per-element 100📈 view plot
🚷 view threshold
0.56 ns
exec: array + tool-per-element 10📈 view plot
🚷 view threshold
0.08 ns
(+2.63%)Baseline: 0.08 ns
0.10 ns
(78.64%)
exec: array + tool-per-element 100📈 view plot
🚷 view threshold
0.69 ns
(+1.81%)Baseline: 0.68 ns
0.91 ns
(76.00%)
exec: chained 3-tool fan-out📈 view plot
🚷 view threshold
0.02 ns
(+11.00%)Baseline: 0.01 ns
0.02 ns
(82.86%)
exec: flat array 10 items📈 view plot
🚷 view threshold
0.01 ns
(-7.79%)Baseline: 0.02 ns
0.03 ns
(42.37%)
exec: flat array 100 items📈 view plot
🚷 view threshold
0.07 ns
(-21.12%)Baseline: 0.09 ns
0.30 ns
(24.94%)
exec: flat array 1000 items📈 view plot
🚷 view threshold
0.66 ns
(-29.23%)Baseline: 0.94 ns
3.58 ns
(18.50%)
exec: nested array 10x10📈 view plot
🚷 view threshold
0.12 ns
(-13.69%)Baseline: 0.14 ns
0.36 ns
(33.73%)
exec: nested array 20x10📈 view plot
🚷 view threshold
0.24 ns
(-15.09%)Baseline: 0.28 ns
0.74 ns
(31.98%)
exec: nested array 5x5📈 view plot
🚷 view threshold
0.05 ns
(-6.51%)Baseline: 0.05 ns
0.10 ns
(46.84%)
exec: short-circuit (overdefinition bypass)📈 view plot
🚷 view threshold
0.00 ns
(+5.23%)Baseline: 0.00 ns
0.00 ns
(91.82%)
exec: simple chain (1 tool)📈 view plot
🚷 view threshold
0.01 ns
(+13.68%)Baseline: 0.01 ns
0.01 ns
(88.36%)
parse: large bridge (20 handles x 5 wires)📈 view plot
🚷 view threshold
1.10 ns
(+9.34%)Baseline: 1.00 ns
1.37 ns
(79.71%)
parse: simple bridge📈 view plot
🚷 view threshold
0.04 ns
(+15.47%)Baseline: 0.03 ns
0.05 ns
(81.40%)
🐰 View full continuous benchmarking report in Bencher

@aarne
Copy link
Contributor

aarne commented Mar 6, 2026

@copilot

1. The Bug: Missing AbortError Normalization

In your fast path (line 174) and your async instrumented path (line 262), you correctly catch DOMException and normalize it:

// Normalize platform AbortError to BridgeAbortError
if (this.signal?.aborted && err instanceof DOMException && err.name === "AbortError") {
  throw new BridgeAbortError();
}

However, you missed this check in the instrumented sync path (around line 222).

      } catch (err) {
        const durationMs = roundMs(performance.now() - wallStart);
        // ... telemetry recording ...
        logToolError(logger, log.errors, toolName, fnName, err as Error);
        
        // BUG: You throw the raw error here!
        throw err; 
      }

While it is rare for a purely synchronous tool to throw an AbortError, if a user writes a sync tool that reads the toolContext.signal.aborted state and throws a standard DOMException("AbortError"), it will leak out as a raw error instead of being caught by your framework's graceful abort handlers.

The Fix: Copy the exact same normalization if block into the catch block of the isSyncTool instrumented path.

2. The Architectural Catch: sync: true destroys OpenTelemetry

Look closely at your instrumented sync path:

    // ── Sync-optimised instrumented path ─────────────────────────
    // When the tool declares {sync: true}, avoid the async closure and
    // the OTel span overhead (sync tools opt out of tracing by convention).

You are intentionally skipping withSpan for sync tools. I know exactly why you did this: if you wrap a synchronous function inside your standard withSpan utility, withSpan will return a Promise. That would instantly destroy the MaybePromise optimization, forcing the engine to await the sync tool!

However, by hard-coding this bypass, you violate the trace: true metadata contract. If an enterprise user writes a heavy, synchronous vector-math tool that takes 40ms to run, and they explicitly configure trace: true in their metadata so it shows up in Datadog/Honeycomb, your engine will silently ignore them and drop the span.

Option B: Implement withSyncSpan
Update your tracing.ts file to export a secondary span wrapper that does not use async/await.

export function withSyncSpan<T>(
  active: boolean,
  name: string,
  attrs: Record<string, any>,
  fn: (span: Span | undefined) => T
): T {
  if (!active || !tracer) return fn(undefined);
  
  return tracer.startActiveSpan(name, (span) => {
    span.setAttributes(attrs);
    try {
      const result = fn(span);
      return result;
    } catch (err) {
      recordSpanError(span, err as Error);
      throw err;
    } finally {
      span.end();
    }
  });
}

If you implement withSyncSpan, you can wrap your sync tools in OTel spans without wrapping them in Promises, giving you the best of both worlds: perfect observability and maximum V8 execution speed.

…ion in sync tool path

1. Add withSyncSpan() to tracing.ts — synchronous span wrapper that
   honours trace metadata without returning a Promise
2. Sync instrumented path now uses withSyncSpan + recordSpanError +
   span?.end() — sync tools with {trace:true} produce OTel spans
3. Add missing AbortError → BridgeAbortError normalization in the
   sync instrumented catch block

Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 6, 2026

@copilot

1. The Bug: Missing AbortError Normalization

...

Both issues fixed in 1383d20:

  1. AbortError normalization: Added the DOMExceptionBridgeAbortError normalization to the sync instrumented catch block, matching the fast path and async path.

  2. OTel span support: Added withSyncSpan() to tracing.ts — a synchronous variant of withSpan that calls otelTracer.startActiveSpan without returning a Promise. The sync instrumented path now uses it, so {sync: true, trace: true} tools produce proper OTel spans. Also added recordSpanError and span?.end() to the sync path's catch/finally blocks.

Copilot finished work on behalf of aarne March 6, 2026 09:25
@aarne
Copy link
Contributor

aarne commented Mar 6, 2026

@copilot

One Final (Extremely Minor) Performance Tweak

Take a look at how you are reading the sync flag off the function:

const isSyncTool = (fnImpl as any).bridge?.sync === true;

This works perfectly. However, remember that callTool is the absolute hottest path in your entire interpreter. It is called for every single tool, pipe, and mathematical operator.

Currently, you are doing a dynamic property lookup on the function object (.bridge), and then another lookup on that object (.sync) on every single invocation.

Because you already built the resolveToolMeta(fnImpl) helper right below it to extract doTrace and log, you should extract the sync flag inside that helper too! That way, if you decide to cache tool metadata in the future (e.g., in a WeakMap inside tracing.ts), you only have to do the caching in one place.

Current:

    const timeoutMs = this.toolTimeoutMs;
    const isSyncTool = (fnImpl as any).bridge?.sync === true;

    // ── Fast path: no instrumentation configured ──────────────────
    if (!tracer && !logger && !isOtelActive()) { ... }

    // ── Instrumented path ─────────────────────────────────────────
    const { doTrace, log } = resolveToolMeta(fnImpl);

Suggested Optimization:
Update resolveToolMeta to return { sync, doTrace, log }.

    const timeoutMs = this.toolTimeoutMs;
    const { sync: isSyncTool, doTrace, log } = resolveToolMeta(fnImpl);

    // ── Fast path: no instrumentation configured ──────────────────
    if (!tracer && !logger && !isOtelActive()) { ... }

Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 6, 2026

@copilot

One Final (Extremely Minor) Performance Tweak

...

Done in c5a1e52. resolveToolMeta() now returns { sync, doTrace, log }, and ExecutionTree.callTool() uses const { sync: isSyncTool, doTrace, log } = resolveToolMeta(fnImpl); so the hot path no longer does a separate direct .bridge?.sync lookup. Validation passed (sync-tools targeted tests + full build/lint/test).

Copilot finished work on behalf of aarne March 6, 2026 09:34
@aarne aarne merged commit 2243c7e into main Mar 6, 2026
8 of 9 checks passed
@aarne aarne deleted the copilot/enforce-sync-tool-handling branch March 6, 2026 14:16
@github-actions github-actions bot mentioned this pull request Mar 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants