@portel/photon 1.21.0 → 1.22.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.
Files changed (62) hide show
  1. package/dist/ag-ui/adapter.d.ts +4 -1
  2. package/dist/ag-ui/adapter.d.ts.map +1 -1
  3. package/dist/ag-ui/adapter.js +33 -3
  4. package/dist/ag-ui/adapter.js.map +1 -1
  5. package/dist/ag-ui/types.d.ts +12 -0
  6. package/dist/ag-ui/types.d.ts.map +1 -1
  7. package/dist/auto-ui/beam/routes/api-config.d.ts +1 -1
  8. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
  9. package/dist/auto-ui/beam/routes/api-config.js +79 -1
  10. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  11. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  12. package/dist/auto-ui/streamable-http-transport.js +24 -2
  13. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  14. package/dist/cli/commands/init.d.ts.map +1 -1
  15. package/dist/cli/commands/init.js +3 -3
  16. package/dist/cli/commands/init.js.map +1 -1
  17. package/dist/cli.d.ts +4 -0
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +11 -1
  20. package/dist/cli.js.map +1 -1
  21. package/dist/daemon/protocol.d.ts +1 -1
  22. package/dist/daemon/protocol.d.ts.map +1 -1
  23. package/dist/daemon/protocol.js +1 -0
  24. package/dist/daemon/protocol.js.map +1 -1
  25. package/dist/daemon/server.js +8 -0
  26. package/dist/daemon/server.js.map +1 -1
  27. package/dist/loader.d.ts +14 -0
  28. package/dist/loader.d.ts.map +1 -1
  29. package/dist/loader.js +247 -28
  30. package/dist/loader.js.map +1 -1
  31. package/dist/server.d.ts +7 -0
  32. package/dist/server.d.ts.map +1 -1
  33. package/dist/server.js +67 -37
  34. package/dist/server.js.map +1 -1
  35. package/dist/shared/error-handler.d.ts +1 -0
  36. package/dist/shared/error-handler.d.ts.map +1 -1
  37. package/dist/shared/error-handler.js +68 -10
  38. package/dist/shared/error-handler.js.map +1 -1
  39. package/dist/shared/logger.d.ts.map +1 -1
  40. package/dist/shared/logger.js +34 -0
  41. package/dist/shared/logger.js.map +1 -1
  42. package/dist/telemetry/context.d.ts +24 -0
  43. package/dist/telemetry/context.d.ts.map +1 -0
  44. package/dist/telemetry/context.js +17 -0
  45. package/dist/telemetry/context.js.map +1 -0
  46. package/dist/telemetry/logs.d.ts +38 -0
  47. package/dist/telemetry/logs.d.ts.map +1 -0
  48. package/dist/telemetry/logs.js +108 -0
  49. package/dist/telemetry/logs.js.map +1 -0
  50. package/dist/telemetry/metrics.d.ts +71 -0
  51. package/dist/telemetry/metrics.d.ts.map +1 -0
  52. package/dist/telemetry/metrics.js +184 -0
  53. package/dist/telemetry/metrics.js.map +1 -0
  54. package/dist/telemetry/otel.d.ts +20 -1
  55. package/dist/telemetry/otel.d.ts.map +1 -1
  56. package/dist/telemetry/otel.js +79 -2
  57. package/dist/telemetry/otel.js.map +1 -1
  58. package/dist/telemetry/sdk.d.ts +49 -0
  59. package/dist/telemetry/sdk.d.ts.map +1 -0
  60. package/dist/telemetry/sdk.js +110 -0
  61. package/dist/telemetry/sdk.js.map +1 -0
  62. package/package.json +2 -2
package/dist/loader.js CHANGED
@@ -11,6 +11,8 @@ import * as path from 'path';
11
11
  import { fileURLToPath, pathToFileURL } from 'url';
12
12
  import * as crypto from 'crypto';
13
13
  import { startToolSpan } from './telemetry/otel.js';
14
+ import { recordToolCall, recordCircuitStateChange, recordRateLimitRejection, recordBulkheadRejection, } from './telemetry/metrics.js';
15
+ import { runWithRequestContext } from './telemetry/context.js';
14
16
  import { spawn } from 'child_process';
15
17
  import { SchemaExtractor, DependencyManager,
16
18
  // Generator utilities (ask/emit pattern from 1.2.0)
@@ -235,6 +237,19 @@ export class PhotonLoader {
235
237
  middlewareStates = new Map();
236
238
  /** Per-photon custom middleware definitions discovered from module exports */
237
239
  photonMiddleware = new Map();
240
+ /** Shadow registry of circuit breaker states, keyed by `${photon}:${instance}:${tool}` */
241
+ circuitHealthTracker = new Map();
242
+ /**
243
+ * Returns all tracked circuit breaker states across all photons.
244
+ * Each entry uses the key format `${photon}:${instance}:${tool}`.
245
+ */
246
+ getCircuitHealth() {
247
+ const result = {};
248
+ for (const [key, entry] of this.circuitHealthTracker) {
249
+ result[key] = { ...entry };
250
+ }
251
+ return result;
252
+ }
238
253
  /** Base directory for state/config/cache (defaults to ~/.photon) */
239
254
  baseDir;
240
255
  /**
@@ -698,9 +713,31 @@ export class PhotonLoader {
698
713
  if (typeof instance._callHandler === 'undefined' || instance._callHandler === undefined) {
699
714
  const callBaseDir = this.baseDir;
700
715
  instance._callHandler = async (photonName, method, params, targetInstance) => {
716
+ // Propagate trace context to nested photon-to-photon calls so the
717
+ // downstream span chains as a child of the current span. Uses
718
+ // in-band `_meta.traceparent` which executeTool peeks at before
719
+ // schema validation — works through worker threads transparently.
720
+ const ctxMod = await import('./telemetry/context.js');
721
+ const ctx = ctxMod.getRequestContext();
722
+ let forwardedParams = params;
723
+ if (ctx) {
724
+ const traceparent = ctx.parentTraceparent ||
725
+ (ctx.traceId
726
+ ? `00-${ctx.traceId}-${crypto.randomBytes(8).toString('hex')}-01`
727
+ : undefined);
728
+ if (traceparent) {
729
+ const existingMeta = params?._meta;
730
+ forwardedParams = {
731
+ ...(params || {}),
732
+ _meta: existingMeta && typeof existingMeta === 'object'
733
+ ? { traceparent, ...existingMeta }
734
+ : { traceparent },
735
+ };
736
+ }
737
+ }
701
738
  // Dynamic import to avoid circular dependency
702
739
  const { sendCommand } = await import('./daemon/client.js');
703
- return sendCommand(photonName, method, params, {
740
+ return sendCommand(photonName, method, forwardedParams, {
704
741
  workingDir: callBaseDir,
705
742
  targetInstance,
706
743
  });
@@ -1494,7 +1531,7 @@ export class PhotonLoader {
1494
1531
  if (!description)
1495
1532
  return '';
1496
1533
  if (description.includes('@emits') && process.env.PHOTON_DEBUG_EXTRACT) {
1497
- console.log(`[stripJSDocTags] Input: "${description}"`);
1534
+ this.logger.debug(`[stripJSDocTags] Input: "${description}"`);
1498
1535
  }
1499
1536
  // Remove lines that start with @ (full line removal)
1500
1537
  let cleaned = description
@@ -1505,7 +1542,7 @@ export class PhotonLoader {
1505
1542
  // Also remove inline @ tags (e.g., "text @emits ... " at end of line)
1506
1543
  cleaned = cleaned.replace(/\s*@\w+.*$/gm, '').trim();
1507
1544
  if (description.includes('@emits') && process.env.PHOTON_DEBUG_EXTRACT) {
1508
- console.log(`[stripJSDocTags] Output: "${cleaned}"`);
1545
+ this.logger.debug(`[stripJSDocTags] Output: "${cleaned}"`);
1509
1546
  }
1510
1547
  return cleaned;
1511
1548
  }
@@ -1588,7 +1625,7 @@ export class PhotonLoader {
1588
1625
  if (process.env.PHOTON_DEBUG_EXTRACT) {
1589
1626
  tools.forEach((t) => {
1590
1627
  if (t.description?.includes('@')) {
1591
- console.log(`[EXTRACTOR] Before clean: ${t.name}: "${t.description}"`);
1628
+ this.logger.debug(`[EXTRACTOR] Before clean: ${t.name}: "${t.description}"`);
1592
1629
  }
1593
1630
  });
1594
1631
  }
@@ -1601,7 +1638,7 @@ export class PhotonLoader {
1601
1638
  if (process.env.PHOTON_DEBUG_EXTRACT) {
1602
1639
  tools.forEach((t) => {
1603
1640
  if (t.name === 'clear') {
1604
- console.log(`[EXTRACTOR] After clean: ${t.name}: "${t.description}"`);
1641
+ this.logger.debug(`[EXTRACTOR] After clean: ${t.name}: "${t.description}"`);
1605
1642
  }
1606
1643
  });
1607
1644
  }
@@ -2593,14 +2630,17 @@ Run: photon mcp ${mcpName} --config
2593
2630
  * Tags become composable wrappers applied in the correct order via phase-sorted middleware.
2594
2631
  * All middleware (built-in and custom) follows the same code path — no if-chain.
2595
2632
  */
2596
- applyMiddleware(execute, toolMeta, photonName, toolName, parameters, instanceName) {
2633
+ applyMiddleware(execute, toolMeta, photonName, toolName, parameters, instanceName, outputHandler) {
2597
2634
  // Build middleware context
2635
+ const store = executionContext.getStore();
2598
2636
  const ctx = {
2599
2637
  photon: photonName,
2600
2638
  tool: toolName,
2601
2639
  instance: instanceName || 'default',
2602
2640
  params: parameters,
2603
- };
2641
+ caller: store?.caller,
2642
+ outputHandler,
2643
+ }; // MiddlewareContext type is in photon-core (read-only); caller/outputHandler added via runtime extension
2604
2644
  // Get declarations from the new middleware[] field
2605
2645
  const declarations = toolMeta.middleware || [];
2606
2646
  if (declarations.length === 0) {
@@ -2652,6 +2692,94 @@ Run: photon mcp ${mcpName} --config
2652
2692
  return withLockHelper(lockName, next);
2653
2693
  };
2654
2694
  });
2695
+ // Handler override for circuitBreaker — mirror state into circuitHealthTracker for introspection
2696
+ handlerOverrides.set('circuitBreaker', (config, _state) => {
2697
+ const tracker = this.circuitHealthTracker;
2698
+ return async (ctx, next) => {
2699
+ const key = `${ctx.photon}:${ctx.instance}:${ctx.tool}`;
2700
+ let circuit = tracker.get(key);
2701
+ if (!circuit) {
2702
+ circuit = { failures: 0, state: 'closed', openedAt: 0 };
2703
+ tracker.set(key, circuit);
2704
+ }
2705
+ const openError = () => {
2706
+ const error = new Error(`Circuit open: ${ctx.photon}.${ctx.tool} has failed ${config.threshold} consecutive times. Resets in ${Math.ceil((config.resetAfterMs - (Date.now() - circuit.openedAt)) / 1000)}s`);
2707
+ error.name = 'PhotonCircuitOpenError';
2708
+ return error;
2709
+ };
2710
+ const emitTransition = (from, to) => {
2711
+ if (from === to)
2712
+ return;
2713
+ recordCircuitStateChange({
2714
+ photon: ctx.photon,
2715
+ tool: ctx.tool,
2716
+ instance: ctx.instance,
2717
+ from,
2718
+ to,
2719
+ });
2720
+ // Broadcast state-change as a domain event so AG-UI clients
2721
+ // (via createAGUIOutputHandler) receive a STATE_DELTA in real
2722
+ // time instead of polling /api/health/circuits.
2723
+ const handler = ctx.outputHandler;
2724
+ if (handler) {
2725
+ try {
2726
+ handler({
2727
+ channel: `${ctx.photon}:circuits`,
2728
+ event: 'state-change',
2729
+ data: {
2730
+ key: `${ctx.photon}:${ctx.instance}:${ctx.tool}`,
2731
+ from,
2732
+ to,
2733
+ timestamp: Date.now(),
2734
+ },
2735
+ });
2736
+ }
2737
+ catch {
2738
+ /* best-effort — don't fail the middleware chain */
2739
+ }
2740
+ }
2741
+ };
2742
+ if (circuit.state === 'open') {
2743
+ if (Date.now() - circuit.openedAt >= config.resetAfterMs) {
2744
+ // Transition to half-open and allow exactly one probe through.
2745
+ emitTransition('open', 'half-open');
2746
+ circuit.state = 'half-open';
2747
+ circuit.probeInFlight = true;
2748
+ }
2749
+ else {
2750
+ throw openError();
2751
+ }
2752
+ }
2753
+ else if (circuit.state === 'half-open') {
2754
+ // Probe already in flight — reject concurrent requests to avoid stampede.
2755
+ if (circuit.probeInFlight) {
2756
+ throw openError();
2757
+ }
2758
+ circuit.probeInFlight = true;
2759
+ }
2760
+ const stateBefore = circuit.state;
2761
+ try {
2762
+ const result = await next();
2763
+ circuit.failures = 0;
2764
+ if (stateBefore !== 'closed')
2765
+ emitTransition(stateBefore, 'closed');
2766
+ circuit.state = 'closed';
2767
+ circuit.probeInFlight = false;
2768
+ return result;
2769
+ }
2770
+ catch (error) {
2771
+ circuit.failures++;
2772
+ circuit.probeInFlight = false;
2773
+ // A failed probe in half-open immediately reopens the circuit.
2774
+ if (stateBefore === 'half-open' || circuit.failures >= config.threshold) {
2775
+ emitTransition(circuit.state, 'open');
2776
+ circuit.state = 'open';
2777
+ circuit.openedAt = Date.now();
2778
+ }
2779
+ throw error;
2780
+ }
2781
+ };
2782
+ });
2655
2783
  return buildMiddlewareChain(execute, declarations, combinedRegistry, this.middlewareStates, ctx, handlerOverrides);
2656
2784
  }
2657
2785
  /**
@@ -2668,11 +2796,54 @@ Run: photon mcp ${mcpName} --config
2668
2796
  * @returns Tool result, or wrapped result with runId for stateful workflows
2669
2797
  */
2670
2798
  async executeTool(mcp, toolName, parameters, options) {
2799
+ // Resolve parentTraceparent from options or in-band `_meta.traceparent` so
2800
+ // the ALS context reflects the true parent for any nested `this.call()`.
2801
+ let resolvedParentTraceparent = options?.parentTraceparent;
2802
+ if (!resolvedParentTraceparent &&
2803
+ parameters &&
2804
+ typeof parameters === 'object' &&
2805
+ '_meta' in parameters) {
2806
+ const metaPeek = parameters._meta;
2807
+ if (metaPeek && typeof metaPeek.traceparent === 'string') {
2808
+ resolvedParentTraceparent = metaPeek.traceparent;
2809
+ }
2810
+ }
2811
+ return runWithRequestContext({
2812
+ photon: mcp.name,
2813
+ tool: toolName,
2814
+ traceId: options?.traceId,
2815
+ parentTraceparent: resolvedParentTraceparent,
2816
+ caller: options?.caller,
2817
+ startedAt: Date.now(),
2818
+ }, () => this._executeToolInner(mcp, toolName, parameters, {
2819
+ ...options,
2820
+ parentTraceparent: resolvedParentTraceparent,
2821
+ }));
2822
+ }
2823
+ async _executeToolInner(mcp, toolName, parameters, options) {
2671
2824
  // Start audit trail recording
2672
2825
  const audit = getAuditTrail();
2673
2826
  const { finish: auditFinish } = audit.start(mcp.name, toolName, parameters || {});
2827
+ // Start wall-clock timer for tool-call duration histogram.
2828
+ const toolStartedAt = Date.now();
2829
+ let metricsStatus = 'ok';
2830
+ let metricsErrorType;
2831
+ // Peek at _meta.traceparent before span creation so distributed traces chain.
2832
+ // Explicit option wins over in-band _meta (server may have extracted from headers).
2833
+ let parentTraceparent = options?.parentTraceparent;
2834
+ if (!parentTraceparent &&
2835
+ parameters &&
2836
+ typeof parameters === 'object' &&
2837
+ '_meta' in parameters) {
2838
+ const metaPeek = parameters._meta;
2839
+ if (metaPeek && typeof metaPeek === 'object' && typeof metaPeek.traceparent === 'string') {
2840
+ parentTraceparent = metaPeek.traceparent;
2841
+ }
2842
+ }
2674
2843
  // Start OTel span for tool execution (no-op if SDK not installed)
2675
- const span = startToolSpan(mcp.name, toolName, parameters);
2844
+ const toolMetaForSpan = mcp?.meta?.tools?.[toolName];
2845
+ const isStateful = Boolean(toolMetaForSpan?.stateful ?? mcp?.meta?.stateful);
2846
+ const span = startToolSpan(mcp.name, toolName, parameters, options?.traceId, isStateful, parentTraceparent);
2676
2847
  if (mcp.instance?.instanceName) {
2677
2848
  span.setAttribute('photon.instance', mcp.instance.instanceName);
2678
2849
  }
@@ -2787,7 +2958,7 @@ Run: photon mcp ${mcpName} --config
2787
2958
  };
2788
2959
  // Apply functional tag middleware if any tags present
2789
2960
  if (hasFunctionalTags) {
2790
- executeBase = this.applyMiddleware(executeBase, toolMeta, mcp.name, toolName, parameters, mcp.instance.instanceName);
2961
+ executeBase = this.applyMiddleware(executeBase, toolMeta, mcp.name, toolName, parameters, mcp.instance.instanceName, options?.outputHandler);
2791
2962
  }
2792
2963
  const result = await executeBase();
2793
2964
  this.progressRenderer.done();
@@ -2815,30 +2986,36 @@ Run: photon mcp ${mcpName} --config
2815
2986
  configurable: true,
2816
2987
  });
2817
2988
  }
2818
- // Construct event data with full context for transmission
2819
- // CRITICAL: emit() expects { channel, event, data } structure for daemon pub/sub routing
2820
- const eventPayload = {
2989
+ // Construct CloudEvents 1.0 envelope for @stateful event emissions.
2990
+ // The `data` field carries the photon-specific payload; `channel` and `event`
2991
+ // are transport metadata used by the daemon pub/sub router.
2992
+ const cloudEventData = {
2821
2993
  method: toolName,
2822
2994
  params: parameters,
2823
2995
  result,
2824
- timestamp: new Date().toISOString(),
2996
+ instance: mcp.instance.instanceName ?? null,
2825
2997
  };
2826
- // Add instance name if available
2827
- if (mcp.instance.instanceName) {
2828
- eventPayload.instance = mcp.instance.instanceName;
2829
- }
2830
2998
  // Add index/pagination info if result is from items array
2831
2999
  if (result && typeof result === 'object' && Array.isArray(mcp.instance.items)) {
2832
3000
  const index = mcp.instance.items.findIndex((item) => item === result);
2833
3001
  if (index !== -1) {
2834
- eventPayload.index = index;
2835
- eventPayload.totalCount = mcp.instance.items.length;
2836
- eventPayload.affectedRange = {
3002
+ cloudEventData.index = index;
3003
+ cloudEventData.totalCount = mcp.instance.items.length;
3004
+ cloudEventData.affectedRange = {
2837
3005
  start: index,
2838
3006
  end: index + 1,
2839
3007
  };
2840
3008
  }
2841
3009
  }
3010
+ // CloudEvents 1.0 envelope (https://cloudevents.io)
3011
+ const eventPayload = {
3012
+ specversion: '1.0',
3013
+ id: crypto.randomUUID(),
3014
+ source: `photon/${photonName}`,
3015
+ type: `photon.${photonName}.${toolName}.executed`,
3016
+ time: new Date().toISOString(),
3017
+ data: cloudEventData,
3018
+ };
2842
3019
  // Wrap in daemon pub/sub format: { channel, event, data }
2843
3020
  const eventData = {
2844
3021
  channel: `${photonName}:${toolName}`, // For daemon pub/sub routing
@@ -2850,18 +3027,24 @@ Run: photon mcp ${mcpName} --config
2850
3027
  if (options?.outputHandler) {
2851
3028
  try {
2852
3029
  if (process.env.PHOTON_DEBUG_EMIT === '1') {
2853
- console.error(`[EMIT-DEBUG] Sending event: method=${eventPayload.method}, channel=${eventData.channel}, hasMeta=${!!result?.__meta}`);
3030
+ this.logger.debug('[emit] sending event', {
3031
+ method: eventPayload.data.method,
3032
+ channel: eventData.channel,
3033
+ hasMeta: !!result?.__meta,
3034
+ });
2854
3035
  }
2855
3036
  // Cast to DaemonEventEnvelope - outputHandler is flexible and routes any object with channel property
2856
3037
  void Promise.resolve(options.outputHandler(eventData)).catch((e) => {
2857
3038
  this.logger.debug('Output handler failed for event', { error: e?.message || e });
2858
3039
  });
2859
3040
  if (process.env.PHOTON_DEBUG_EMIT === '1') {
2860
- console.error(`[EMIT-DEBUG] Event transmitted to outputHandler`);
3041
+ this.logger.debug('[emit] event transmitted to outputHandler');
2861
3042
  }
2862
3043
  }
2863
3044
  catch (e) {
2864
- console.error(`[EMIT-ERROR] Failed to send event through outputHandler: ${e instanceof Error ? e.message : String(e)}`);
3045
+ this.logger.error('Failed to send event through outputHandler', {
3046
+ error: e instanceof Error ? e.message : String(e),
3047
+ });
2865
3048
  }
2866
3049
  }
2867
3050
  else if (mcp.instance && typeof mcp.instance.emit === 'function') {
@@ -2869,19 +3052,22 @@ Run: photon mcp ${mcpName} --config
2869
3052
  // (this path won't route to daemon pub/sub, but at least calls emit)
2870
3053
  try {
2871
3054
  if (process.env.PHOTON_DEBUG_EMIT === '1') {
2872
- console.error(`[EMIT-DEBUG] No outputHandler, falling back to instance.emit`);
3055
+ this.logger.debug('[emit] no outputHandler, falling back to instance.emit');
2873
3056
  }
2874
3057
  mcp.instance.emit(eventData);
2875
3058
  }
2876
3059
  catch (e) {
2877
- console.error(`[EMIT-ERROR] Failed to emit: ${e instanceof Error ? e.message : String(e)}`);
3060
+ this.logger.error('Failed to emit event via instance.emit', {
3061
+ error: e instanceof Error ? e.message : String(e),
3062
+ });
2878
3063
  }
2879
3064
  }
2880
3065
  }
2881
3066
  catch (e) {
2882
3067
  // Log emit errors but don't break tool execution
2883
- console.error(`[EMIT-ERROR] Failed to emit @stateful event: ${e instanceof Error ? e.message : String(e)}`);
2884
- this.logger.debug(`Failed to emit @stateful event: ${e instanceof Error ? e.message : String(e)}`);
3068
+ this.logger.error('Failed to emit @stateful event', {
3069
+ error: e instanceof Error ? e.message : String(e),
3070
+ });
2885
3071
  }
2886
3072
  }
2887
3073
  // Apply _meta post-processing (format transformation, field selection)
@@ -2938,7 +3124,7 @@ Run: photon mcp ${mcpName} --config
2938
3124
  });
2939
3125
  // Apply functional tag middleware if any tags present
2940
3126
  if (hasFunctionalTags) {
2941
- generatorFn = this.applyMiddleware(generatorFn, toolMeta, mcp.name, toolName, parameters, mcp.instance.instanceName);
3127
+ generatorFn = this.applyMiddleware(generatorFn, toolMeta, mcp.name, toolName, parameters, mcp.instance.instanceName, options?.outputHandler);
2942
3128
  }
2943
3129
  // Use maybeStatefulExecute for all executions
2944
3130
  // It handles both regular async and generators, detecting checkpoint yields
@@ -2961,6 +3147,7 @@ Run: photon mcp ${mcpName} --config
2961
3147
  if (execResult.runId) {
2962
3148
  error.runId = execResult.runId;
2963
3149
  }
3150
+ span.recordException(error);
2964
3151
  span.setStatus('ERROR', error.message);
2965
3152
  auditFinish(null, error);
2966
3153
  throw error;
@@ -2993,13 +3180,41 @@ Run: photon mcp ${mcpName} --config
2993
3180
  catch (error) {
2994
3181
  // Clear progress on error too
2995
3182
  this.progressRenderer.done();
3183
+ span.recordException(error);
2996
3184
  span.setStatus('ERROR', error instanceof Error ? error.message : String(error));
2997
3185
  auditFinish(null, error);
3186
+ metricsStatus = 'error';
3187
+ metricsErrorType = error instanceof Error ? error.name || 'Error' : 'unknown';
3188
+ // Emit a dedicated counter for rate-limit rejections so dashboards can
3189
+ // alert on sustained throttling without scanning the generic error
3190
+ // stream.
3191
+ if (metricsErrorType === 'PhotonRateLimitError') {
3192
+ recordRateLimitRejection({
3193
+ photon: mcp.name,
3194
+ tool: toolName,
3195
+ instance: mcp.instance?.instanceName,
3196
+ });
3197
+ }
3198
+ else if (metricsErrorType === 'PhotonBulkheadFullError') {
3199
+ recordBulkheadRejection({
3200
+ photon: mcp.name,
3201
+ tool: toolName,
3202
+ instance: mcp.instance?.instanceName,
3203
+ });
3204
+ }
2998
3205
  this.logger.error(`Tool execution failed: ${toolName} - ${getErrorMessage(error)}`);
2999
3206
  throw error;
3000
3207
  }
3001
3208
  finally {
3002
3209
  span.end();
3210
+ recordToolCall({
3211
+ photon: mcp.name,
3212
+ tool: toolName,
3213
+ durationMs: Date.now() - toolStartedAt,
3214
+ status: metricsStatus,
3215
+ errorType: metricsErrorType,
3216
+ stateful: isStateful,
3217
+ });
3003
3218
  }
3004
3219
  }
3005
3220
  normalizeNestedParamsTool(toolMeta, parameters) {
@@ -3320,6 +3535,10 @@ Run: photon mcp ${mcpName} --config
3320
3535
  });
3321
3536
  }
3322
3537
  };
3538
+ // Generators must pass through unwrapped - .then() would kill the iterator protocol
3539
+ if (result && typeof result[Symbol.asyncIterator] === 'function') {
3540
+ return result;
3541
+ }
3323
3542
  // For async methods, attach __meta to the resolved value, not the Promise
3324
3543
  if (result && typeof result.then === 'function') {
3325
3544
  return result.then((resolved) => {