@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.
- package/dist/ag-ui/adapter.d.ts +4 -1
- package/dist/ag-ui/adapter.d.ts.map +1 -1
- package/dist/ag-ui/adapter.js +33 -3
- package/dist/ag-ui/adapter.js.map +1 -1
- package/dist/ag-ui/types.d.ts +12 -0
- package/dist/ag-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-config.d.ts +1 -1
- package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-config.js +79 -1
- package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +24 -2
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +3 -3
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +11 -1
- package/dist/cli.js.map +1 -1
- package/dist/daemon/protocol.d.ts +1 -1
- package/dist/daemon/protocol.d.ts.map +1 -1
- package/dist/daemon/protocol.js +1 -0
- package/dist/daemon/protocol.js.map +1 -1
- package/dist/daemon/server.js +8 -0
- package/dist/daemon/server.js.map +1 -1
- package/dist/loader.d.ts +14 -0
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +247 -28
- package/dist/loader.js.map +1 -1
- package/dist/server.d.ts +7 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +67 -37
- package/dist/server.js.map +1 -1
- package/dist/shared/error-handler.d.ts +1 -0
- package/dist/shared/error-handler.d.ts.map +1 -1
- package/dist/shared/error-handler.js +68 -10
- package/dist/shared/error-handler.js.map +1 -1
- package/dist/shared/logger.d.ts.map +1 -1
- package/dist/shared/logger.js +34 -0
- package/dist/shared/logger.js.map +1 -1
- package/dist/telemetry/context.d.ts +24 -0
- package/dist/telemetry/context.d.ts.map +1 -0
- package/dist/telemetry/context.js +17 -0
- package/dist/telemetry/context.js.map +1 -0
- package/dist/telemetry/logs.d.ts +38 -0
- package/dist/telemetry/logs.d.ts.map +1 -0
- package/dist/telemetry/logs.js +108 -0
- package/dist/telemetry/logs.js.map +1 -0
- package/dist/telemetry/metrics.d.ts +71 -0
- package/dist/telemetry/metrics.d.ts.map +1 -0
- package/dist/telemetry/metrics.js +184 -0
- package/dist/telemetry/metrics.js.map +1 -0
- package/dist/telemetry/otel.d.ts +20 -1
- package/dist/telemetry/otel.d.ts.map +1 -1
- package/dist/telemetry/otel.js +79 -2
- package/dist/telemetry/otel.js.map +1 -1
- package/dist/telemetry/sdk.d.ts +49 -0
- package/dist/telemetry/sdk.d.ts.map +1 -0
- package/dist/telemetry/sdk.js +110 -0
- package/dist/telemetry/sdk.js.map +1 -0
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2819
|
-
//
|
|
2820
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3041
|
+
this.logger.debug('[emit] event transmitted to outputHandler');
|
|
2861
3042
|
}
|
|
2862
3043
|
}
|
|
2863
3044
|
catch (e) {
|
|
2864
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2884
|
-
|
|
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) => {
|