@turingpulse/sdk 1.0.1

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 (160) hide show
  1. package/.github/dependabot.yml +38 -0
  2. package/.github/workflows/ci.yml +246 -0
  3. package/.github/workflows/framework-compat.yml +169 -0
  4. package/.github/workflows/security.yml +336 -0
  5. package/CHANGELOG.md +29 -0
  6. package/LICENSE +13 -0
  7. package/MIGRATION.md +30 -0
  8. package/README.md +221 -0
  9. package/dist/attachments.d.ts +28 -0
  10. package/dist/attachments.d.ts.map +1 -0
  11. package/dist/attachments.js +59 -0
  12. package/dist/attachments.js.map +1 -0
  13. package/dist/config.d.ts +72 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +78 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/context.d.ts +126 -0
  18. package/dist/context.d.ts.map +1 -0
  19. package/dist/context.js +163 -0
  20. package/dist/context.js.map +1 -0
  21. package/dist/decorators.d.ts +6 -0
  22. package/dist/decorators.d.ts.map +1 -0
  23. package/dist/decorators.js +52 -0
  24. package/dist/decorators.js.map +1 -0
  25. package/dist/deploy.d.ts +89 -0
  26. package/dist/deploy.d.ts.map +1 -0
  27. package/dist/deploy.js +203 -0
  28. package/dist/deploy.js.map +1 -0
  29. package/dist/errors.d.ts +18 -0
  30. package/dist/errors.d.ts.map +1 -0
  31. package/dist/errors.js +34 -0
  32. package/dist/errors.js.map +1 -0
  33. package/dist/eventBuilder.d.ts +21 -0
  34. package/dist/eventBuilder.d.ts.map +1 -0
  35. package/dist/eventBuilder.js +127 -0
  36. package/dist/eventBuilder.js.map +1 -0
  37. package/dist/fingerprint.d.ts +158 -0
  38. package/dist/fingerprint.d.ts.map +1 -0
  39. package/dist/fingerprint.js +339 -0
  40. package/dist/fingerprint.js.map +1 -0
  41. package/dist/governance.d.ts +47 -0
  42. package/dist/governance.d.ts.map +1 -0
  43. package/dist/governance.js +104 -0
  44. package/dist/governance.js.map +1 -0
  45. package/dist/http.d.ts +62 -0
  46. package/dist/http.d.ts.map +1 -0
  47. package/dist/http.js +181 -0
  48. package/dist/http.js.map +1 -0
  49. package/dist/index.d.ts +15 -0
  50. package/dist/index.d.ts.map +1 -0
  51. package/dist/index.js +23 -0
  52. package/dist/index.js.map +1 -0
  53. package/dist/instrumentation.d.ts +40 -0
  54. package/dist/instrumentation.d.ts.map +1 -0
  55. package/dist/instrumentation.js +31 -0
  56. package/dist/instrumentation.js.map +1 -0
  57. package/dist/integrations/mastra.d.ts +64 -0
  58. package/dist/integrations/mastra.d.ts.map +1 -0
  59. package/dist/integrations/mastra.js +256 -0
  60. package/dist/integrations/mastra.js.map +1 -0
  61. package/dist/kpi.d.ts +21 -0
  62. package/dist/kpi.d.ts.map +1 -0
  63. package/dist/kpi.js +83 -0
  64. package/dist/kpi.js.map +1 -0
  65. package/dist/llmDetector.d.ts +22 -0
  66. package/dist/llmDetector.d.ts.map +1 -0
  67. package/dist/llmDetector.js +269 -0
  68. package/dist/llmDetector.js.map +1 -0
  69. package/dist/plugin.d.ts +33 -0
  70. package/dist/plugin.d.ts.map +1 -0
  71. package/dist/plugin.js +312 -0
  72. package/dist/plugin.js.map +1 -0
  73. package/dist/registry.d.ts +13 -0
  74. package/dist/registry.d.ts.map +1 -0
  75. package/dist/registry.js +18 -0
  76. package/dist/registry.js.map +1 -0
  77. package/dist/tracing.d.ts +10 -0
  78. package/dist/tracing.d.ts.map +1 -0
  79. package/dist/tracing.js +30 -0
  80. package/dist/tracing.js.map +1 -0
  81. package/dist/triggerState.d.ts +5 -0
  82. package/dist/triggerState.d.ts.map +1 -0
  83. package/dist/triggerState.js +19 -0
  84. package/dist/triggerState.js.map +1 -0
  85. package/dist/utils.d.ts +27 -0
  86. package/dist/utils.d.ts.map +1 -0
  87. package/dist/utils.js +72 -0
  88. package/dist/utils.js.map +1 -0
  89. package/package.json +37 -0
  90. package/packages/anthropic/package.json +16 -0
  91. package/packages/anthropic/src/index.ts +5 -0
  92. package/packages/anthropic/src/wrapper.ts +102 -0
  93. package/packages/anthropic/tsconfig.build.json +20 -0
  94. package/packages/langchain/package.json +16 -0
  95. package/packages/langchain/src/index.ts +7 -0
  96. package/packages/langchain/src/wrapper.ts +51 -0
  97. package/packages/mastra/package.json +17 -0
  98. package/packages/mastra/src/index.ts +8 -0
  99. package/packages/mastra/src/wrapper.ts +301 -0
  100. package/packages/openai/package.json +16 -0
  101. package/packages/openai/src/index.ts +8 -0
  102. package/packages/openai/src/wrapper.ts +103 -0
  103. package/packages/openai/tsconfig.build.json +20 -0
  104. package/packages/openclaw/openclaw.plugin.json +100 -0
  105. package/packages/openclaw/package.json +41 -0
  106. package/packages/openclaw/src/buffer.ts +99 -0
  107. package/packages/openclaw/src/config.ts +139 -0
  108. package/packages/openclaw/src/hooks/governance.ts +267 -0
  109. package/packages/openclaw/src/hooks/lifecycle.ts +75 -0
  110. package/packages/openclaw/src/hooks/telemetry.ts +207 -0
  111. package/packages/openclaw/src/index.ts +91 -0
  112. package/packages/openclaw/src/mapper.ts +233 -0
  113. package/packages/openclaw/src/session-tracker.ts +181 -0
  114. package/packages/openclaw/src/types.ts +220 -0
  115. package/packages/openclaw/tests/buffer.test.ts +148 -0
  116. package/packages/openclaw/tests/config.test.ts +122 -0
  117. package/packages/openclaw/tests/governance.test.ts +232 -0
  118. package/packages/openclaw/tests/mapper.test.ts +242 -0
  119. package/packages/openclaw/tests/session-tracker.test.ts +124 -0
  120. package/packages/openclaw/tsconfig.json +18 -0
  121. package/packages/openclaw/vitest.config.ts +8 -0
  122. package/packages/vercel-ai/package.json +16 -0
  123. package/packages/vercel-ai/src/index.ts +5 -0
  124. package/packages/vercel-ai/src/wrapper.ts +49 -0
  125. package/scripts/bump-version.sh +58 -0
  126. package/scripts/update-readme-compat.mjs +151 -0
  127. package/src/__tests__/fingerprint.test.ts +328 -0
  128. package/src/attachments.ts +88 -0
  129. package/src/config.ts +164 -0
  130. package/src/context.ts +258 -0
  131. package/src/decorators.ts +61 -0
  132. package/src/deploy.ts +260 -0
  133. package/src/errors.ts +44 -0
  134. package/src/eventBuilder.ts +153 -0
  135. package/src/fingerprint.ts +421 -0
  136. package/src/governance.ts +156 -0
  137. package/src/http.ts +241 -0
  138. package/src/index.ts +57 -0
  139. package/src/instrumentation.ts +68 -0
  140. package/src/integrations/mastra.ts +335 -0
  141. package/src/kpi.ts +112 -0
  142. package/src/llmDetector.ts +330 -0
  143. package/src/plugin.ts +384 -0
  144. package/src/registry.ts +27 -0
  145. package/src/tracing.ts +39 -0
  146. package/src/triggerState.ts +27 -0
  147. package/src/utils.ts +78 -0
  148. package/tests/compat/anthropic.test.ts +61 -0
  149. package/tests/compat/cohere.test.ts +57 -0
  150. package/tests/compat/google-genai.test.ts +61 -0
  151. package/tests/compat/langchain-openai.test.ts +41 -0
  152. package/tests/compat/langchain.test.ts +64 -0
  153. package/tests/compat/mistral.test.ts +58 -0
  154. package/tests/compat/openai.test.ts +71 -0
  155. package/tests/compat/vercel-ai.test.ts +56 -0
  156. package/tests/plugins/anthropic-wrapper.test.ts +120 -0
  157. package/tests/plugins/langchain-wrapper.test.ts +128 -0
  158. package/tests/plugins/openai-wrapper.test.ts +165 -0
  159. package/tsconfig.json +21 -0
  160. package/vitest.config.ts +9 -0
package/src/http.ts ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * HTTP client for TuringPulse SDK.
3
+ *
4
+ * All outbound HTTP flows through this class. Authentication is done
5
+ * exclusively via the `X-API-Key` header — the backend resolves
6
+ * tenant_id and project_id from the key.
7
+ */
8
+
9
+ import type { AgentEvent } from '@turingpulse/interfaces';
10
+ import { safeErrorMessage } from './utils';
11
+
12
+ import { TuringPulseConfig } from './config';
13
+ import { toSnakeCaseDeep } from './utils';
14
+
15
+ const _fetch: typeof globalThis.fetch = globalThis.fetch;
16
+ const _Headers: typeof globalThis.Headers = globalThis.Headers;
17
+
18
+ export const SDK_VERSION = '0.1.0';
19
+ const SDK_LANGUAGE = 'typescript';
20
+ const MAX_PAYLOAD_BYTES = 5 * 1024 * 1024; // 5 MB
21
+ const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9_-]+$/;
22
+ const TERMINAL_STATUS_CODES = new Set([401, 402, 403, 404]);
23
+
24
+ interface JsonValue {
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ /** Parameters for SDK policy_check() */
29
+ export interface PolicyCheckParams {
30
+ agentId?: string;
31
+ workflowId?: string;
32
+ workflowName?: string;
33
+ runId?: string;
34
+ nodeName?: string;
35
+ modelName?: string;
36
+ confidenceScore?: number;
37
+ costUsd?: number;
38
+ latencyMs?: number;
39
+ outputText?: string;
40
+ inputText?: string;
41
+ outputLength?: number;
42
+ errorOccurred?: boolean;
43
+ category?: string;
44
+ metadata?: Record<string, unknown>;
45
+ spanId?: string;
46
+ nodeType?: string;
47
+ }
48
+
49
+ /** Result of a policy check */
50
+ export interface PolicyCheckResult {
51
+ /** "allow", "block", or "flag" */
52
+ action: string;
53
+ /** Whether any policy was triggered */
54
+ triggered: boolean;
55
+ /** Human-readable reason */
56
+ reason?: string;
57
+ /** IDs of triggered policies */
58
+ policyIds: string[];
59
+ }
60
+
61
+ export class TuringPulseHttpClient {
62
+ private readonly baseUrl: string;
63
+
64
+ constructor(private readonly config: TuringPulseConfig) {
65
+ this.baseUrl = config.endpoint;
66
+ }
67
+
68
+ get maxSerializedFieldLength(): number {
69
+ return this.config.maxSerializedFieldLength;
70
+ }
71
+
72
+ private get fetchImpl() {
73
+ return this.config.fetchImpl ?? _fetch;
74
+ }
75
+
76
+ // ------------------------------------------------------------------
77
+ // Event emission
78
+ // ------------------------------------------------------------------
79
+
80
+ async emitEvents(events: AgentEvent[]): Promise<void> {
81
+ if (!events.length) return;
82
+ const snakeEvents = toSnakeCaseDeep(events);
83
+ const url = `${this.baseUrl}/api/v1/sdk/events`;
84
+ await this.post(url, { events: snakeEvents } as unknown as JsonValue);
85
+ }
86
+
87
+ // ------------------------------------------------------------------
88
+ // Task / HITL helpers
89
+ // ------------------------------------------------------------------
90
+
91
+ async createTask(payload: JsonValue): Promise<string | undefined> {
92
+ const url = `${this.baseUrl}/api/v1/tasks`;
93
+ const response = await this.post(url, payload);
94
+ if (response && typeof response === 'object' && 'taskId' in response && typeof response.taskId === 'string') {
95
+ return response.taskId;
96
+ }
97
+ if (typeof payload.taskId === 'string') {
98
+ return payload.taskId;
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ async addHitlAction(taskId: string, payload: JsonValue): Promise<void> {
104
+ if (!SAFE_PATH_SEGMENT.test(taskId)) {
105
+ // eslint-disable-next-line no-console
106
+ console.warn('TuringPulse: invalid taskId rejected (possible path traversal)');
107
+ return;
108
+ }
109
+ const url = `${this.baseUrl}/api/v1/tasks/${taskId}/hitl`;
110
+ await this.post(url, payload);
111
+ }
112
+
113
+ // ------------------------------------------------------------------
114
+ // Fingerprint / deploy / custom-change helpers
115
+ // ------------------------------------------------------------------
116
+
117
+ async postFingerprint(payload: JsonValue): Promise<unknown> {
118
+ const url = `${this.baseUrl}/api/v1/fingerprints`;
119
+ return this.post(url, payload);
120
+ }
121
+
122
+ async postDeploy(payload: JsonValue): Promise<unknown> {
123
+ const url = `${this.baseUrl}/api/v1/deploys`;
124
+ return this.post(url, payload);
125
+ }
126
+
127
+ async postCustomChange(payload: JsonValue): Promise<unknown> {
128
+ const url = `${this.baseUrl}/api/v1/changes`;
129
+ return this.post(url, payload);
130
+ }
131
+
132
+ // ------------------------------------------------------------------
133
+ // Core POST with retries + timeout + payload limit
134
+ // ------------------------------------------------------------------
135
+
136
+ private async post(url: string, body: JsonValue): Promise<unknown> {
137
+ let serialized = JSON.stringify(body);
138
+ if (serialized.length > MAX_PAYLOAD_BYTES) {
139
+ // eslint-disable-next-line no-console
140
+ console.warn(`TuringPulse payload exceeds ${MAX_PAYLOAD_BYTES} bytes, truncating`);
141
+ const events = (body as Record<string, unknown>).events;
142
+ if (Array.isArray(events) && events.length > 1) {
143
+ const overhead = serialized.length - JSON.stringify(events).length;
144
+ const avgPerEvent = Math.ceil((serialized.length - overhead) / events.length);
145
+ const targetCount = Math.max(1, Math.floor((MAX_PAYLOAD_BYTES - overhead) / avgPerEvent));
146
+ if (targetCount < events.length) {
147
+ body = { ...body, events: events.slice(0, targetCount) } as unknown as JsonValue;
148
+ }
149
+ }
150
+ serialized = JSON.stringify(body);
151
+ }
152
+
153
+ const headers = this.buildHeaders();
154
+ let lastError: unknown;
155
+ for (let attempt = 0; attempt <= this.config.maxRetries; attempt += 1) {
156
+ const controller = new AbortController();
157
+ const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
158
+ try {
159
+ const response = await this.fetchImpl(url, {
160
+ method: 'POST',
161
+ headers,
162
+ body: serialized,
163
+ signal: controller.signal as AbortSignal,
164
+ });
165
+ clearTimeout(timeout);
166
+ if (!response.ok) {
167
+ const err = new Error(`TuringPulse request failed with ${response.status}`);
168
+ const retryAfterHeader = response.headers.get('retry-after');
169
+ if (retryAfterHeader) {
170
+ (err as Error & { retryAfter?: number }).retryAfter = parseFloat(retryAfterHeader) || undefined;
171
+ }
172
+ throw err;
173
+ }
174
+ if (response.headers.get('content-type')?.includes('application/json')) {
175
+ return await response.json();
176
+ }
177
+ return undefined;
178
+ } catch (error) {
179
+ clearTimeout(timeout);
180
+ lastError = error;
181
+ const statusMatch = error instanceof Error && error.message.match(/failed with (\d+)/);
182
+ if (statusMatch && TERMINAL_STATUS_CODES.has(Number(statusMatch[1]))) {
183
+ break;
184
+ }
185
+ if (attempt === this.config.maxRetries) break;
186
+ const retryAfter = (error as { retryAfter?: number })?.retryAfter;
187
+ const delay = retryAfter && retryAfter > 0
188
+ ? Math.min(retryAfter * 1000, 2000)
189
+ : Math.min(500 * (attempt + 1), 2000);
190
+ await sleep(delay);
191
+ }
192
+ }
193
+ // eslint-disable-next-line no-console
194
+ console.warn(`TuringPulse request to ${url} failed: ${safeErrorMessage(lastError)}`);
195
+ return undefined;
196
+ }
197
+
198
+ // ------------------------------------------------------------------
199
+ // Policy check — uses the same centralized post() with retries
200
+ // ------------------------------------------------------------------
201
+
202
+ async policyCheck(params: PolicyCheckParams): Promise<PolicyCheckResult> {
203
+ const url = `${this.baseUrl}/api/v1/hitl/policy/check`;
204
+ const body = toSnakeCaseDeep(params) as unknown as JsonValue;
205
+
206
+ try {
207
+ const data = (await this.post(url, body)) as Record<string, unknown> | undefined;
208
+ if (!data) {
209
+ return { action: 'flag', triggered: true, reason: 'check_error: no response', policyIds: [] };
210
+ }
211
+ return {
212
+ action: (data.action as string) || 'allow',
213
+ triggered: Boolean(data.triggered),
214
+ reason: (data.reason as string) || undefined,
215
+ policyIds: (data.policy_ids as string[]) || [],
216
+ };
217
+ } catch (err) {
218
+ const safeMessage = err instanceof Error ? err.message : 'unknown error';
219
+ // eslint-disable-next-line no-console
220
+ console.warn('TuringPulse policy check failed (fail-open):', safeMessage);
221
+ return { action: 'flag', triggered: true, reason: `check_error: ${safeMessage}`, policyIds: [] };
222
+ }
223
+ }
224
+
225
+ // ------------------------------------------------------------------
226
+ // Headers — only X-API-Key, never tenant/project/roles
227
+ // ------------------------------------------------------------------
228
+
229
+ private buildHeaders(): Headers {
230
+ return new _Headers({
231
+ 'Content-Type': 'application/json',
232
+ 'X-API-Key': this.config.apiKey,
233
+ 'X-SDK-Version': SDK_VERSION,
234
+ 'X-SDK-Language': SDK_LANGUAGE,
235
+ });
236
+ }
237
+ }
238
+
239
+ async function sleep(ms: number): Promise<void> {
240
+ await new Promise((resolve) => setTimeout(resolve, ms));
241
+ }
package/src/index.ts ADDED
@@ -0,0 +1,57 @@
1
+ // Config
2
+ export { TuringPulseConfig, type TuringPulseConfigOptions, type GovernanceDefaults, DEFAULT_ENDPOINT } from './config';
3
+
4
+ // Plugin
5
+ export { TuringPulsePlugin, init, getInstance } from './plugin';
6
+
7
+ // Decorators
8
+ export { instrument, withInstrumentation } from './decorators';
9
+
10
+ // Governance
11
+ export { GovernanceDirective, type GovernanceDirectiveOptions, type GovernanceTaskResult } from './governance';
12
+
13
+ // Instrumentation
14
+ export { type InstrumentationOptions } from './instrumentation';
15
+
16
+ // KPIs
17
+ export { type KPIConfig, type KPIResult, type KPIComparator } from './kpi';
18
+
19
+ // Context
20
+ export { currentContext, ExecutionContext, type ToolCallRecord, generateUUID } from './context';
21
+
22
+ // Errors
23
+ export {
24
+ TriggerNotFoundError,
25
+ PluginNotInitializedError,
26
+ GovernanceBlockedError,
27
+ ConfigurationError,
28
+ } from './errors';
29
+
30
+ // Fingerprinting
31
+ export {
32
+ type FingerprintConfig,
33
+ type FingerprintData,
34
+ } from './fingerprint';
35
+
36
+ // Deploy Tracking
37
+ export {
38
+ registerDeploy,
39
+ type DeployInfo,
40
+ type RegisterDeployOptions,
41
+ } from './deploy';
42
+
43
+ // Policy Check & HTTP Client
44
+ export { type PolicyCheckParams, type PolicyCheckResult, TuringPulseHttpClient, SDK_VERSION } from './http';
45
+
46
+ // Attachments
47
+ export { AttachmentManager, type AttachmentResult } from './attachments';
48
+
49
+ // Utils
50
+ export { safeEnv, safeErrorMessage, toSnakeCaseDeep } from './utils';
51
+
52
+ // Framework Integrations
53
+ export {
54
+ instrumentMastra,
55
+ type MastraInstrumentOptions,
56
+ type MastraToolCallRecord,
57
+ } from './integrations/mastra';
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Instrumentation options for @instrument decorator.
3
+ *
4
+ * REFACTORED:
5
+ * - Added `name` parameter for workflow display name
6
+ * - `agentId` is DEPRECATED - use `name` instead
7
+ * - Backend auto-registers workflows based on `name` and assigns UUID
8
+ */
9
+
10
+ import { GovernanceDirective } from './governance';
11
+ import { KPIConfig } from './kpi';
12
+
13
+ export interface InstrumentationOptions {
14
+ /**
15
+ * Display name for the workflow (e.g., "Order Processing Agent").
16
+ * This is the preferred parameter. The backend will auto-register
17
+ * workflows based on this name and assign a UUID.
18
+ */
19
+ name?: string;
20
+
21
+ /**
22
+ * @deprecated Use `name` instead.
23
+ * Kept for backward compatibility.
24
+ */
25
+ agentId?: string;
26
+
27
+ operation?: string;
28
+ labels?: Record<string, string>;
29
+ trace?: boolean;
30
+ governance?: GovernanceDirective;
31
+ kpis?: KPIConfig[];
32
+ triggerKey?: string;
33
+ triggerDescription?: string;
34
+ hiddenEntrypoint?: boolean;
35
+ metadata?: Record<string, string>;
36
+ }
37
+
38
+ export function resolveOperation(options: InstrumentationOptions, fallback: string): string {
39
+ return options.operation ?? fallback;
40
+ }
41
+
42
+ export function tracingEnabled(options: InstrumentationOptions, defaultEnabled: boolean): boolean {
43
+ return options.trace ?? defaultEnabled;
44
+ }
45
+
46
+ /**
47
+ * Validate instrumentation options.
48
+ * @throws Error if neither name nor agentId is provided.
49
+ */
50
+ export function validateOptions(options: InstrumentationOptions): void {
51
+ if (!options.name && !options.agentId) {
52
+ throw new Error(
53
+ "Either 'name' or 'agentId' must be provided to @instrument. " +
54
+ "Example: @instrument({ name: 'My Workflow' })"
55
+ );
56
+ }
57
+
58
+ // Emit console warning for deprecated agentId usage
59
+ if (options.agentId && !options.name) {
60
+ // eslint-disable-next-line no-console
61
+ console.warn(
62
+ "The 'agentId' parameter is deprecated. Use 'name' instead. " +
63
+ "Example: @instrument({ name: 'Order Processing Agent' })"
64
+ );
65
+ }
66
+ }
67
+
68
+
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Mastra 1.2.x instrumentation for TuringPulse SDK (TypeScript).
3
+ *
4
+ * Wraps `agent.generate()` to capture:
5
+ * - Token counts from `response.usage` (ai-sdk format: promptTokens / completionTokens).
6
+ * - Per-tool-call child spans.
7
+ * - Agent instructions as system prompt.
8
+ *
9
+ * This integration uses the centralized TuringPulseHttpClient when a plugin
10
+ * instance is available (preferred), or falls back to standalone HTTP when
11
+ * used without the full SDK.
12
+ */
13
+
14
+ import type {
15
+ AgentEvent,
16
+ } from '@turingpulse/interfaces';
17
+
18
+ import type { ToolCallRecord } from '../context';
19
+ import { generateUUID } from '../context';
20
+ import { DEFAULT_ENDPOINT } from '../config';
21
+ import type { TuringPulseHttpClient } from '../http';
22
+ import { SDK_VERSION } from '../http';
23
+ import { safeEnv, safeErrorMessage, toSnakeCaseDeep } from '../utils';
24
+
25
+ const FRAMEWORK_NAME = 'mastra';
26
+ const FRAMEWORK_VERSION = '1.2.0';
27
+
28
+ /** Mastra tool call with required result/id fields. */
29
+ interface MastraToolCallRecord extends ToolCallRecord {
30
+ toolResult: string;
31
+ toolId: string;
32
+ }
33
+
34
+ interface MastraInstrumentOptions {
35
+ /** Workflow display name for TuringPulse. */
36
+ name: string;
37
+ /** LLM model name (default: gpt-4o-mini). */
38
+ model?: string;
39
+ /** LLM provider (default: openai). */
40
+ provider?: string;
41
+ /**
42
+ * Preferred: pass the plugin's HTTP client for centralized retries, timeouts, headers.
43
+ * If not provided, falls back to standalone fetch using apiKey + endpoint.
44
+ */
45
+ httpClient?: TuringPulseHttpClient;
46
+ /** TuringPulse API endpoint — only needed if httpClient is not provided. */
47
+ endpoint?: string;
48
+ /** TuringPulse API key — only needed if httpClient is not provided. */
49
+ apiKey?: string;
50
+ /** Tool names available to the agent. */
51
+ toolNames?: string[];
52
+ /**
53
+ * A reference to the mutable tool-call log array. Tool execute()
54
+ * callbacks push records here during agent.generate(). The integration
55
+ * reads from this array after execution completes.
56
+ */
57
+ toolCallLog?: MastraToolCallRecord[];
58
+ kpis?: Array<{ kpiId: string; value: number | (() => number); alertThreshold?: number; comparator?: string; description?: string }>;
59
+ metadata?: Record<string, string>;
60
+ }
61
+
62
+ /**
63
+ * Instrument a Mastra Agent for TuringPulse observability.
64
+ *
65
+ * Returns an async function that runs `agent.generate()` and emits
66
+ * a root workflow span plus per-tool/LLM child spans.
67
+ */
68
+ export function instrumentMastra(
69
+ agent: { generate: (prompt: string) => Promise<unknown>; [key: string]: unknown },
70
+ options: MastraInstrumentOptions,
71
+ ): (prompt: string) => Promise<unknown> {
72
+ const {
73
+ name,
74
+ model = 'gpt-4o-mini',
75
+ provider = 'openai',
76
+ httpClient,
77
+ endpoint = safeEnv('TP_ENDPOINT') || DEFAULT_ENDPOINT,
78
+ apiKey = (safeEnv('TP_API_KEY') || '').trim(),
79
+ toolNames = [],
80
+ toolCallLog: externalLog,
81
+ } = options;
82
+
83
+ if (!httpClient && !apiKey) {
84
+ // eslint-disable-next-line no-console
85
+ console.warn(
86
+ 'TuringPulse: No httpClient or apiKey provided. Telemetry will be skipped. ' +
87
+ 'Pass the plugin\'s client via httpClient for best results.',
88
+ );
89
+ }
90
+
91
+ const systemPrompt: string =
92
+ typeof (agent as Record<string, unknown>).instructions === 'string'
93
+ ? ((agent as Record<string, unknown>).instructions as string).slice(0, 500)
94
+ : '';
95
+
96
+ return async function runInstrumented(prompt: string): Promise<unknown> {
97
+ const traceId = generateUUID();
98
+ const rootSpanId = generateUUID();
99
+ const t0 = Date.now();
100
+
101
+ if (externalLog) externalLog.length = 0;
102
+
103
+ const maxFieldLen = httpClient?.maxSerializedFieldLength ?? 50_000;
104
+
105
+ let response: unknown;
106
+ let generationError: Error | null = null;
107
+ try {
108
+ response = await agent.generate(prompt);
109
+ } catch (err) {
110
+ generationError = err instanceof Error ? err : new Error(String(err));
111
+ response = null;
112
+ }
113
+ const totalDurationMs = Date.now() - t0;
114
+
115
+ const resp = (response as Record<string, unknown>) || {};
116
+ const finalOutput = (resp.text as string) || '';
117
+ const usage = (resp.usage as { promptTokens?: number; completionTokens?: number }) || {};
118
+ const promptTokens = usage.promptTokens || 0;
119
+ const completionTokens = usage.completionTokens || 0;
120
+
121
+ const estPrompt = promptTokens || Math.max(Math.round(prompt.length / 4), 400);
122
+ const estCompletion = completionTokens || Math.max(Math.round(finalOutput.length / 4), 200);
123
+
124
+ const tcLog = externalLog ? [...externalLog] : [];
125
+
126
+ // ── Build child spans ──
127
+ const childEvents: AgentEvent[] = [];
128
+ const now = new Date();
129
+ let offset = 100;
130
+
131
+ for (const tc of tcLog) {
132
+ const spanId = generateUUID();
133
+ const ts = new Date(now.getTime() + offset).toISOString();
134
+ childEvents.push({
135
+ runId: traceId,
136
+ agentId: `tool_${tc.toolName}`,
137
+ timestamp: ts,
138
+ type: 'span',
139
+ payload: {
140
+ name: `${name}.tool_${tc.toolName}`,
141
+ durationMs: Math.floor(totalDurationMs / (tcLog.length + 1)),
142
+ status: tc.success ? 'success' : 'error',
143
+ metadata: {
144
+ span_id: spanId,
145
+ parent_span_id: rootSpanId,
146
+ trace_id: traceId,
147
+ workflow_name: name,
148
+ framework: FRAMEWORK_NAME,
149
+ node_type: 'tool',
150
+ depth: '1',
151
+ input: JSON.stringify(tc.toolArgs).slice(0, maxFieldLen),
152
+ output: JSON.stringify({ result: tc.toolResult.slice(0, maxFieldLen) }),
153
+ available_tools: JSON.stringify(toolNames),
154
+ tool_name: tc.toolName,
155
+ ...(tc.errorMessage ? { error_message: tc.errorMessage } : {}),
156
+ mastra_tool_id: tc.toolName,
157
+ mastra_version: FRAMEWORK_VERSION,
158
+ },
159
+ toolCalls: [{
160
+ toolName: tc.toolName,
161
+ toolArgs: tc.toolArgs,
162
+ toolResult: tc.toolResult.slice(0, maxFieldLen),
163
+ toolId: tc.toolId,
164
+ success: tc.success,
165
+ errorMessage: tc.errorMessage,
166
+ }],
167
+ },
168
+ });
169
+ offset += Math.floor(totalDurationMs / (tcLog.length + 1)) + 50;
170
+ }
171
+
172
+ // LLM reasoning span
173
+ const llmSpanId = generateUUID();
174
+ childEvents.push({
175
+ runId: traceId,
176
+ agentId: 'agent_reasoning',
177
+ timestamp: new Date(now.getTime() + offset).toISOString(),
178
+ type: 'span',
179
+ payload: {
180
+ name: `${name}.agent_reasoning`,
181
+ durationMs: Math.floor(totalDurationMs / (tcLog.length + 1)),
182
+ status: 'success',
183
+ tokens: { prompt: Math.round(estPrompt), completion: Math.round(estCompletion) },
184
+ costUsd: 0,
185
+ metadata: {
186
+ span_id: llmSpanId,
187
+ parent_span_id: rootSpanId,
188
+ trace_id: traceId,
189
+ workflow_name: name,
190
+ framework: FRAMEWORK_NAME,
191
+ node_type: 'llm',
192
+ depth: '1',
193
+ prompt: prompt.slice(0, maxFieldLen),
194
+ user_query: prompt.slice(0, maxFieldLen),
195
+ system_prompt: systemPrompt,
196
+ model,
197
+ provider,
198
+ available_tools: JSON.stringify(toolNames),
199
+ mastra_agent_name: (agent as Record<string, unknown>).name as string || name,
200
+ mastra_model: model,
201
+ mastra_tool_count: String(tcLog.length),
202
+ mastra_version: FRAMEWORK_VERSION,
203
+ },
204
+ },
205
+ });
206
+
207
+ // ── Root span ──
208
+ const totalTokensIn = childEvents.reduce((s, e) => s + (e.payload.tokens?.prompt || 0), 0);
209
+ const totalTokensOut = childEvents.reduce((s, e) => s + (e.payload.tokens?.completion || 0), 0);
210
+
211
+ const rootEvent: AgentEvent = {
212
+ runId: traceId,
213
+ agentId: name,
214
+ timestamp: now.toISOString(),
215
+ type: 'span',
216
+ payload: {
217
+ name: `${name}.execute`,
218
+ durationMs: totalDurationMs,
219
+ status: generationError ? 'error' : 'success',
220
+ tokens: {
221
+ prompt: totalTokensIn || Math.round(estPrompt),
222
+ completion: totalTokensOut || Math.round(estCompletion),
223
+ },
224
+ costUsd: 0,
225
+ metadata: {
226
+ workflow_name: name,
227
+ framework: FRAMEWORK_NAME,
228
+ environment: safeEnv('NODE_ENV') || 'unknown',
229
+ input: prompt.slice(0, maxFieldLen),
230
+ output: finalOutput.slice(0, maxFieldLen),
231
+ span_id: rootSpanId,
232
+ trace_id: traceId,
233
+ node_type: 'workflow',
234
+ mastra_version: FRAMEWORK_VERSION,
235
+ ...(generationError ? { error_message: generationError.message.slice(0, 500) } : {}),
236
+ ...(options.metadata ?? {}),
237
+ ...(_evalKpis(options.kpis, totalDurationMs)),
238
+ },
239
+ },
240
+ };
241
+
242
+ // ── Send all events via centralized client or standalone ──
243
+ const allEvents = [rootEvent, ...childEvents];
244
+
245
+ if (httpClient) {
246
+ await httpClient.emitEvents(allEvents);
247
+ } else if (apiKey && endpoint) {
248
+ await sendEventsStandalone(allEvents, endpoint, apiKey);
249
+ }
250
+
251
+ if (generationError) throw generationError;
252
+ return response;
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Standalone fallback when no httpClient is available.
258
+ * Prefer passing httpClient for retries, timeouts, and consistent headers.
259
+ */
260
+ const STANDALONE_TIMEOUT_MS = 10_000;
261
+ const STANDALONE_MAX_PAYLOAD_BYTES = 5 * 1024 * 1024;
262
+
263
+ async function sendEventsStandalone(
264
+ events: AgentEvent[],
265
+ baseUrl: string,
266
+ apiKey: string,
267
+ ): Promise<void> {
268
+ const url = `${baseUrl.replace(/\/$/, '')}/api/v1/sdk/events`;
269
+ const snakeEvents = toSnakeCaseDeep(events);
270
+ let body = JSON.stringify({ events: snakeEvents });
271
+ if (body.length > STANDALONE_MAX_PAYLOAD_BYTES && Array.isArray(snakeEvents) && (snakeEvents as unknown[]).length > 1) {
272
+ const arr = snakeEvents as unknown[];
273
+ const overhead = body.length - JSON.stringify(arr).length;
274
+ const avg = Math.ceil((body.length - overhead) / arr.length);
275
+ const target = Math.max(1, Math.floor((STANDALONE_MAX_PAYLOAD_BYTES - overhead) / avg));
276
+ body = JSON.stringify({ events: arr.slice(0, target) });
277
+ }
278
+ const controller = new AbortController();
279
+ const timeout = setTimeout(() => controller.abort(), STANDALONE_TIMEOUT_MS);
280
+ try {
281
+ const resp = await fetch(url, {
282
+ method: 'POST',
283
+ headers: {
284
+ 'Content-Type': 'application/json',
285
+ 'X-API-Key': apiKey,
286
+ 'X-SDK-Version': SDK_VERSION,
287
+ 'X-SDK-Language': 'typescript',
288
+ },
289
+ body,
290
+ signal: controller.signal as AbortSignal,
291
+ });
292
+ if (!resp.ok) {
293
+ // eslint-disable-next-line no-console
294
+ console.warn(`TuringPulse telemetry FAILED: ${resp.status}`);
295
+ }
296
+ } catch (e) {
297
+ // eslint-disable-next-line no-console
298
+ console.warn(`TuringPulse telemetry ERROR: ${safeErrorMessage(e)}`);
299
+ } finally {
300
+ clearTimeout(timeout);
301
+ }
302
+ }
303
+
304
+ export type { MastraInstrumentOptions, MastraToolCallRecord };
305
+
306
+ type KpiEntry = MastraInstrumentOptions['kpis'] extends (infer U)[] | undefined ? U : never;
307
+
308
+ function _evalKpis(
309
+ kpis: MastraInstrumentOptions['kpis'],
310
+ durationMs: number,
311
+ ): Record<string, string> {
312
+ if (!kpis || kpis.length === 0) return {};
313
+ const out: Record<string, string> = {};
314
+ for (const kpi of kpis) {
315
+ try {
316
+ const raw = typeof kpi.value === 'function' ? kpi.value() : kpi.value;
317
+ if (raw == null || !isFinite(raw)) continue;
318
+ out[`metric.${kpi.kpiId}`] = String(raw);
319
+ if (kpi.alertThreshold != null) {
320
+ const cmp = kpi.comparator ?? 'gt';
321
+ const t = kpi.alertThreshold;
322
+ const triggered =
323
+ cmp === 'gt' ? raw > t :
324
+ cmp === 'gte' ? raw >= t :
325
+ cmp === 'lt' ? raw < t :
326
+ cmp === 'lte' ? raw <= t :
327
+ raw === t;
328
+ if (triggered) out[`metric.alert.${kpi.kpiId}`] = 'true';
329
+ }
330
+ } catch {
331
+ /* user function threw — silently skip */
332
+ }
333
+ }
334
+ return out;
335
+ }