@mcpmesh/sdk 2.3.0 → 2.4.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 (77) hide show
  1. package/dist/__tests__/llm-agent-model-params.test.js +83 -0
  2. package/dist/__tests__/llm-agent-model-params.test.js.map +1 -1
  3. package/dist/__tests__/llm-max-iterations.test.d.ts +20 -0
  4. package/dist/__tests__/llm-max-iterations.test.d.ts.map +1 -0
  5. package/dist/__tests__/llm-max-iterations.test.js +250 -0
  6. package/dist/__tests__/llm-max-iterations.test.js.map +1 -0
  7. package/dist/__tests__/llm-mesh-error-mapping.test.d.ts +16 -0
  8. package/dist/__tests__/llm-mesh-error-mapping.test.d.ts.map +1 -0
  9. package/dist/__tests__/llm-mesh-error-mapping.test.js +135 -0
  10. package/dist/__tests__/llm-mesh-error-mapping.test.js.map +1 -0
  11. package/dist/__tests__/llm-provider-output-mode.test.d.ts +21 -0
  12. package/dist/__tests__/llm-provider-output-mode.test.d.ts.map +1 -0
  13. package/dist/__tests__/llm-provider-output-mode.test.js +115 -0
  14. package/dist/__tests__/llm-provider-output-mode.test.js.map +1 -0
  15. package/dist/__tests__/llm-provider-system-synthesis.test.d.ts +20 -0
  16. package/dist/__tests__/llm-provider-system-synthesis.test.d.ts.map +1 -0
  17. package/dist/__tests__/llm-provider-system-synthesis.test.js +167 -0
  18. package/dist/__tests__/llm-provider-system-synthesis.test.js.map +1 -0
  19. package/dist/__tests__/llm-response-model.test.d.ts +10 -0
  20. package/dist/__tests__/llm-response-model.test.d.ts.map +1 -0
  21. package/dist/__tests__/llm-response-model.test.js +92 -0
  22. package/dist/__tests__/llm-response-model.test.js.map +1 -0
  23. package/dist/__tests__/proxy-timeout-guard.test.d.ts +12 -0
  24. package/dist/__tests__/proxy-timeout-guard.test.d.ts.map +1 -0
  25. package/dist/__tests__/proxy-timeout-guard.test.js +85 -0
  26. package/dist/__tests__/proxy-timeout-guard.test.js.map +1 -0
  27. package/dist/__tests__/registry-disconnect-retains-deps.spec.d.ts +2 -0
  28. package/dist/__tests__/registry-disconnect-retains-deps.spec.d.ts.map +1 -0
  29. package/dist/__tests__/registry-disconnect-retains-deps.spec.js +101 -0
  30. package/dist/__tests__/registry-disconnect-retains-deps.spec.js.map +1 -0
  31. package/dist/__tests__/response-parser.test.js +29 -0
  32. package/dist/__tests__/response-parser.test.js.map +1 -1
  33. package/dist/agent.d.ts.map +1 -1
  34. package/dist/agent.js +4 -0
  35. package/dist/agent.js.map +1 -1
  36. package/dist/api-runtime.d.ts.map +1 -1
  37. package/dist/api-runtime.js +8 -1
  38. package/dist/api-runtime.js.map +1 -1
  39. package/dist/express.d.ts.map +1 -1
  40. package/dist/express.js +8 -1
  41. package/dist/express.js.map +1 -1
  42. package/dist/llm-agent.d.ts +34 -0
  43. package/dist/llm-agent.d.ts.map +1 -1
  44. package/dist/llm-agent.js +239 -434
  45. package/dist/llm-agent.js.map +1 -1
  46. package/dist/llm-provider.d.ts +33 -4
  47. package/dist/llm-provider.d.ts.map +1 -1
  48. package/dist/llm-provider.js +91 -4
  49. package/dist/llm-provider.js.map +1 -1
  50. package/dist/llm.d.ts +1 -1
  51. package/dist/llm.d.ts.map +1 -1
  52. package/dist/llm.js +8 -5
  53. package/dist/llm.js.map +1 -1
  54. package/dist/provider-handlers/gemini-handler.d.ts.map +1 -1
  55. package/dist/provider-handlers/gemini-handler.js +2 -14
  56. package/dist/provider-handlers/gemini-handler.js.map +1 -1
  57. package/dist/provider-handlers/openai-handler.d.ts.map +1 -1
  58. package/dist/provider-handlers/openai-handler.js +2 -15
  59. package/dist/provider-handlers/openai-handler.js.map +1 -1
  60. package/dist/provider-handlers/provider-handler.d.ts +12 -0
  61. package/dist/provider-handlers/provider-handler.d.ts.map +1 -1
  62. package/dist/provider-handlers/provider-handler.js +24 -0
  63. package/dist/provider-handlers/provider-handler.js.map +1 -1
  64. package/dist/proxy.d.ts.map +1 -1
  65. package/dist/proxy.js +189 -254
  66. package/dist/proxy.js.map +1 -1
  67. package/dist/response-parser.d.ts +10 -0
  68. package/dist/response-parser.d.ts.map +1 -1
  69. package/dist/response-parser.js +55 -0
  70. package/dist/response-parser.js.map +1 -1
  71. package/dist/tracing.d.ts +12 -0
  72. package/dist/tracing.d.ts.map +1 -1
  73. package/dist/tracing.js +37 -0
  74. package/dist/tracing.js.map +1 -1
  75. package/dist/types.d.ts +10 -2
  76. package/dist/types.d.ts.map +1 -1
  77. package/package.json +2 -2
package/dist/llm-agent.js CHANGED
@@ -33,12 +33,10 @@ import { zodToJsonSchema } from "zod-to-json-schema";
33
33
  import { renderTemplate } from "./template.js";
34
34
  import { ResponseParser } from "./response-parser.js";
35
35
  import { MaxIterationsError, LLMAPIError, ToolExecutionError, } from "./errors.js";
36
- import { parseSSEResponse } from "./sse.js";
37
36
  import { resolveMediaInputs } from "./media/index.js";
38
- import { getCurrentTraceContext, getCurrentPropagatedHeaders, streamMcpTool, DEFAULT_CALL_OPTIONS, } from "./proxy.js";
39
- import { generateSpanId, publishTraceSpan, createTraceHeaders, injectTraceContext, } from "./tracing.js";
40
- import { fetchWithTimeout, isTimeoutError } from "./timeout-utils.js";
41
- import { getDispatcher } from "./http-pool.js";
37
+ import { callMcpTool, streamMcpTool, DEFAULT_CALL_OPTIONS, } from "./proxy.js";
38
+ import { isTimeoutError } from "./timeout-utils.js";
39
+ import { envMaxIterations, sanitizeMaxIterations } from "./llm-provider.js";
42
40
  /**
43
41
  * Mesh provider that delegates to an LLM provider discovered via mesh.
44
42
  */
@@ -51,8 +49,16 @@ export class MeshDelegatedProvider {
51
49
  this.functionName = functionName;
52
50
  this.parallelToolCalls = parallelToolCalls;
53
51
  }
54
- async complete(model, messages, tools, options) {
55
- // Build MeshLlmRequest structure (matches Python claude_provider schema)
52
+ /**
53
+ * Build the MeshLlmRequest body shared by complete() and streamComplete().
54
+ *
55
+ * Assembles model_params (with the escape-hatch merge + typed overrides),
56
+ * wraps messages/tools into the MeshLlmRequest, and returns it pre-wrapped
57
+ * in the ``{ request }`` arguments object. Callers inject trace context /
58
+ * propagated headers into ``args`` afterward (per-caller — complete() uses
59
+ * injectTraceAndHeaders, streamComplete() lets streamMcpTool() handle it).
60
+ */
61
+ buildMeshLlmRequest(model, messages, tools, options) {
56
62
  const modelParams = {};
57
63
  // Escape-hatch merge: callers can pass vendor-specific kwargs
58
64
  // (e.g., thinking_config, output_config) via options.modelParams.
@@ -84,6 +90,18 @@ export class MeshDelegatedProvider {
84
90
  if (this.parallelToolCalls) {
85
91
  modelParams.parallel_tool_calls = true;
86
92
  }
93
+ // Issue #1116: forward the provider-managed loop cap. Typed field, so it
94
+ // takes precedence over any escape-hatch modelParams.max_iterations above.
95
+ if (options?.maxIterations !== undefined) {
96
+ modelParams.max_iterations = options.maxIterations;
97
+ }
98
+ // Issue #1112: forward the consumer-supplied output_mode override ONLY when
99
+ // the user explicitly set it (undefined = auto = provider's per-vendor
100
+ // selection; omitting keeps the provider byte-identical to today). Typed
101
+ // field, so it takes precedence over any escape-hatch modelParams.output_mode.
102
+ if (options?.outputMode !== undefined) {
103
+ modelParams.output_mode = options.outputMode;
104
+ }
87
105
  const request = {
88
106
  messages,
89
107
  };
@@ -96,152 +114,82 @@ export class MeshDelegatedProvider {
96
114
  }
97
115
  // Wrap in "request" parameter as expected by Python claude_provider
98
116
  const args = { request };
117
+ return { request, args };
118
+ }
119
+ async complete(model, messages, tools, options) {
120
+ // Build MeshLlmRequest structure (matches Python claude_provider schema).
121
+ // The {request} wrapper stays; callMcpTool injects trace context internally.
122
+ const { args } = this.buildMeshLlmRequest(model, messages, tools, options);
99
123
  // Set up timeout (default 300s to match Python SDK's stream_timeout)
100
124
  const timeoutMs = parseInt(process.env.MESH_PROVIDER_TIMEOUT_MS || "300000", 10);
101
- // Tracing: propagate context to downstream provider
102
- const traceCtx = getCurrentTraceContext();
103
- const traceSpanId = traceCtx ? generateSpanId() : null;
104
- const traceStartTime = Date.now() / 1000;
105
- // Inject trace context and propagated headers into args via Rust core
106
- const delegatedPropHeaders = getCurrentPropagatedHeaders();
107
- if (traceCtx && traceSpanId) {
108
- try {
109
- const argsJson = JSON.stringify(args);
110
- const headersJson = Object.keys(delegatedPropHeaders).length > 0 ? JSON.stringify(delegatedPropHeaders) : undefined;
111
- const injectedJson = injectTraceContext(argsJson, traceCtx.traceId, traceSpanId, headersJson);
112
- const injected = JSON.parse(injectedJson);
113
- Object.assign(args, injected);
114
- }
115
- catch {
116
- // Fallback to manual injection
117
- args._trace_id = traceCtx.traceId;
118
- args._parent_span = traceSpanId;
119
- if (Object.keys(delegatedPropHeaders).length > 0) {
120
- args._mesh_headers = { ...delegatedPropHeaders };
121
- }
122
- }
123
- }
124
- else if (Object.keys(delegatedPropHeaders).length > 0) {
125
- args._mesh_headers = { ...delegatedPropHeaders };
126
- }
127
- let traceSuccess = true;
128
- let traceError = null;
125
+ // Route through the shared callMcpTool (trace injection, span publish,
126
+ // dispatcher pooling, job-cancel wiring all handled internally). LLM calls
127
+ // are expensive / non-idempotent so disable retries; LLM responses can
128
+ // exceed the default 10 MiB cap so raise it to effectively unbounded.
129
+ const callOptions = {
130
+ ...DEFAULT_CALL_OPTIONS,
131
+ timeout: timeoutMs,
132
+ maxAttempts: 1,
133
+ maxResponseSize: Number.MAX_SAFE_INTEGER,
134
+ };
135
+ // Call the mesh provider via MCP. callMcpTool throws a plain Error on
136
+ // failure re-wrap into LLMAPIError to preserve the public error type.
137
+ let content;
129
138
  try {
130
- // Call the mesh provider via MCP
131
- let response;
132
- try {
133
- response = await fetchWithTimeout(`${this.endpoint}/mcp`, {
134
- method: "POST",
135
- headers: {
136
- "Content-Type": "application/json",
137
- "Accept": "application/json, text/event-stream",
138
- ...(traceCtx && traceSpanId ? createTraceHeaders(traceCtx.traceId, traceSpanId) : {}),
139
- ...getCurrentPropagatedHeaders(),
140
- },
141
- body: JSON.stringify({
142
- jsonrpc: "2.0",
143
- id: Date.now(),
144
- method: "tools/call",
145
- params: {
146
- name: this.functionName,
147
- arguments: args,
148
- },
149
- }),
150
- timeout: timeoutMs,
151
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
152
- dispatcher: getDispatcher(`${this.endpoint}/mcp`),
153
- });
154
- }
155
- catch (error) {
156
- if (isTimeoutError(error)) {
157
- throw new LLMAPIError(408, `Request timed out after ${timeoutMs}ms`, `mesh:${this.endpoint}`);
158
- }
159
- throw new LLMAPIError(0, `Fetch failed: ${error instanceof Error ? error.message : String(error)}`, `mesh:${this.endpoint}`);
160
- }
161
- if (!response.ok) {
162
- const error = await response.text();
163
- throw new LLMAPIError(response.status, error, `mesh:${this.endpoint}`);
164
- }
165
- // Handle SSE response from FastMCP stateless HTTP stream
166
- const responseText = await response.text();
167
- const result = parseSSEResponse(responseText);
168
- if (result.error) {
169
- throw new Error(`Mesh provider RPC error: ${result.error.message}`);
170
- }
171
- // Parse the MCP result content
172
- const content = result.result?.content?.[0];
173
- if (!content || content.type !== "text") {
174
- throw new Error("Invalid response from mesh provider");
175
- }
176
- // Check for MCP tool execution error (isError flag in result)
177
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
178
- if (result.result?.isError) {
179
- throw new Error(`Mesh provider tool error: ${content.text}`);
180
- }
181
- // Parse the LLM provider response
182
- // Format: { role, content, tool_calls?, _mesh_usage? }
183
- const meshResponse = JSON.parse(content.text);
184
- // Validate role - LLM responses should always be "assistant"
185
- let validatedRole = "assistant";
186
- if (meshResponse.role !== "assistant") {
187
- console.warn(`[mesh.llm] Unexpected role "${meshResponse.role}" from mesh provider, defaulting to "assistant"`);
188
- }
189
- // Convert to OpenAI format expected by MeshLlmAgent
190
- const openAiResponse = {
191
- id: `mesh-${Date.now()}`,
192
- object: "chat.completion",
193
- created: Math.floor(Date.now() / 1000),
194
- model: "mesh-delegated",
195
- choices: [
196
- {
197
- index: 0,
198
- message: {
199
- role: validatedRole,
200
- content: meshResponse.content,
201
- tool_calls: meshResponse.tool_calls,
202
- },
203
- finish_reason: meshResponse.tool_calls ? "tool_calls" : "stop",
204
- },
205
- ],
206
- usage: meshResponse._mesh_usage
207
- ? {
208
- prompt_tokens: meshResponse._mesh_usage.prompt_tokens,
209
- completion_tokens: meshResponse._mesh_usage.completion_tokens,
210
- total_tokens: meshResponse._mesh_usage.prompt_tokens +
211
- meshResponse._mesh_usage.completion_tokens,
212
- }
213
- : undefined,
214
- };
215
- return openAiResponse;
139
+ content = await callMcpTool(this.endpoint, this.functionName, args, callOptions, "mesh-llm");
216
140
  }
217
141
  catch (err) {
218
- traceSuccess = false;
219
- traceError = err instanceof Error ? err.message : String(err);
220
- throw err;
221
- }
222
- finally {
223
- if (traceCtx && traceSpanId) {
224
- const traceEndTime = Date.now() / 1000;
225
- const traceDurationMs = (traceEndTime - traceStartTime) * 1000;
226
- publishTraceSpan({
227
- traceId: traceCtx.traceId,
228
- spanId: traceSpanId,
229
- parentSpan: traceCtx.parentSpanId,
230
- functionName: "proxy_call_wrapper",
231
- startTime: traceStartTime,
232
- endTime: traceEndTime,
233
- durationMs: traceDurationMs,
234
- success: traceSuccess,
235
- error: traceError,
236
- resultType: traceSuccess ? "object" : "error",
237
- argsCount: 0,
238
- kwargsCount: 0,
239
- dependencies: [this.endpoint],
240
- injectedDependencies: 0,
241
- meshPositions: [],
242
- }).catch(() => { });
243
- }
244
- }
142
+ const message = err instanceof Error ? err.message : String(err);
143
+ // callMcpTool catches the AbortError and re-throws a plain Error whose
144
+ // message is "MCP call timed out after <N>ms", so isTimeoutError (name ===
145
+ // "AbortError") no longer matches here — also detect the message.
146
+ const isTimeout = isTimeoutError(err) ||
147
+ (err instanceof Error && /timed out/i.test(err.message));
148
+ if (isTimeout) {
149
+ throw new LLMAPIError(408, message, `mesh:${this.endpoint}`);
150
+ }
151
+ throw new LLMAPIError(0, message, `mesh:${this.endpoint}`);
152
+ }
153
+ // The mesh provider returns a single text content item whose text is the
154
+ // LLM provider response JSON. callMcpTool joins/extracts it to a string.
155
+ if (typeof content !== "string") {
156
+ throw new Error("Invalid response from mesh provider");
157
+ }
158
+ // Parse the LLM provider response
159
+ // Format: { role, content, tool_calls?, _mesh_usage? }
160
+ const meshResponse = JSON.parse(content);
161
+ // Validate role - LLM responses should always be "assistant"
162
+ let validatedRole = "assistant";
163
+ if (meshResponse.role !== "assistant") {
164
+ console.warn(`[mesh.llm] Unexpected role "${meshResponse.role}" from mesh provider, defaulting to "assistant"`);
165
+ }
166
+ // Convert to OpenAI format expected by MeshLlmAgent
167
+ const openAiResponse = {
168
+ id: `mesh-${Date.now()}`,
169
+ object: "chat.completion",
170
+ created: Math.floor(Date.now() / 1000),
171
+ model: "mesh-delegated",
172
+ choices: [
173
+ {
174
+ index: 0,
175
+ message: {
176
+ role: validatedRole,
177
+ content: meshResponse.content,
178
+ tool_calls: meshResponse.tool_calls,
179
+ },
180
+ finish_reason: meshResponse.tool_calls ? "tool_calls" : "stop",
181
+ },
182
+ ],
183
+ usage: meshResponse._mesh_usage
184
+ ? {
185
+ prompt_tokens: meshResponse._mesh_usage.prompt_tokens,
186
+ completion_tokens: meshResponse._mesh_usage.completion_tokens,
187
+ total_tokens: meshResponse._mesh_usage.prompt_tokens +
188
+ meshResponse._mesh_usage.completion_tokens,
189
+ }
190
+ : undefined,
191
+ };
192
+ return openAiResponse;
245
193
  }
246
194
  /**
247
195
  * Stream chunks from the mesh-delegated provider's streaming variant.
@@ -257,40 +205,8 @@ export class MeshDelegatedProvider {
257
205
  * ``ai.mcpmesh.stream`` tag opt-in (see ``MeshLlmAgent.stream()``).
258
206
  */
259
207
  async *streamComplete(model, messages, tools, options) {
260
- // Build MeshLlmRequest body — same shape as complete()
261
- const modelParams = {};
262
- // Escape-hatch merge: callers can pass vendor-specific kwargs
263
- // (e.g., thinking_config, output_config) via options.modelParams.
264
- // Merged FIRST so typed fields below take precedence on collision.
265
- if (options?.modelParams) {
266
- Object.assign(modelParams, options.modelParams);
267
- }
268
- if (model && model !== "default") {
269
- modelParams.model = model;
270
- }
271
- if (options?.maxOutputTokens)
272
- modelParams.max_tokens = options.maxOutputTokens;
273
- if (options?.temperature !== undefined)
274
- modelParams.temperature = options.temperature;
275
- if (options?.topP !== undefined)
276
- modelParams.top_p = options.topP;
277
- if (options?.stop)
278
- modelParams.stop = options.stop;
279
- if (options?.outputSchema) {
280
- modelParams.output_schema = options.outputSchema.schema;
281
- modelParams.output_type_name = options.outputSchema.name;
282
- }
283
- if (this.parallelToolCalls) {
284
- modelParams.parallel_tool_calls = true;
285
- }
286
- const request = { messages };
287
- if (Object.keys(modelParams).length > 0) {
288
- request.model_params = modelParams;
289
- }
290
- if (tools && tools.length > 0) {
291
- request.tools = tools;
292
- }
293
- const args = { request };
208
+ // Build MeshLlmRequest body — same shape as complete().
209
+ const { args } = this.buildMeshLlmRequest(model, messages, tools, options);
294
210
  // streamMcpTool() handles trace context injection / propagated headers /
295
211
  // dispatcher pooling internally — same path as createProxy().stream().
296
212
  // Match complete()'s env-backed timeout (MESH_PROVIDER_TIMEOUT_MS) so
@@ -314,10 +230,36 @@ export class MeshLlmAgent {
314
230
  _meta = null;
315
231
  _systemPromptOverride = null;
316
232
  _parallelLogEmitted = false;
233
+ // Cached output schema derived from the immutable returnSchema (Issue #459).
234
+ // Computed once: `null` means "not yet computed", an object holds the result
235
+ // (which may itself be `undefined` when conversion failed).
236
+ _outputSchema = null;
237
+ _outputSchemaSection = null;
317
238
  constructor(config) {
318
239
  this.config = config;
319
240
  this.responseParser = new ResponseParser(config.returnSchema);
320
241
  }
242
+ /**
243
+ * Build (once) the provider output schema from the immutable returnSchema.
244
+ * Returns `undefined` when there is no schema or conversion failed.
245
+ */
246
+ getOutputSchema() {
247
+ if (this._outputSchema !== null)
248
+ return this._outputSchema;
249
+ let result;
250
+ if (this.config.returnSchema) {
251
+ try {
252
+ const jsonSchema = zodToJsonSchema(this.config.returnSchema);
253
+ const schemaName = jsonSchema.title ?? "Response";
254
+ result = { schema: jsonSchema, name: schemaName };
255
+ }
256
+ catch {
257
+ // If schema conversion fails, skip
258
+ }
259
+ }
260
+ this._outputSchema = result;
261
+ return result;
262
+ }
321
263
  /**
322
264
  * Get metadata from the last run.
323
265
  */
@@ -337,28 +279,20 @@ export class MeshLlmAgent {
337
279
  return this._systemPromptOverride ?? this.config.systemPrompt;
338
280
  }
339
281
  /**
340
- * Run the agentic loop.
282
+ * Build the initial LlmMessage[] shared by run() and stream():
283
+ * render the system prompt (+ tool schema injection), optionally append the
284
+ * output-schema hint, resolve media inputs, and unwind multi-turn history
285
+ * (attaching resolved media to the last user message).
341
286
  *
342
- * @param messageInput - User message string or multi-turn message array
343
- * @param context - Runtime context with tools and options
344
- * @returns Parsed response (validated if schema provided)
287
+ * The ONLY behavioral knob is opts.includeOutputSchemaHint:
288
+ * - run() passes `!meshDelegated` (consumer-side schema hint when not delegated).
289
+ * - stream() passes `false` (always mesh-delegated; provider applies formatting).
345
290
  */
346
- async run(messageInput, context) {
347
- if (this.config.parallelToolCalls && !this._parallelLogEmitted) {
348
- console.log("[mesh.llm] parallel tool calls enabled — tools will execute concurrently via Promise.all()");
349
- this._parallelLogEmitted = true;
350
- }
351
- const startTime = Date.now();
352
- const toolCalls = [];
353
- let totalInputTokens = 0;
354
- let totalOutputTokens = 0;
355
- // Resolve provider
356
- const provider = this.resolveProvider(context);
357
- // Build initial messages
291
+ async buildAgentMessages(messageInput, context, opts) {
358
292
  const messages = [];
359
- // Build tool definitions first (needed for schema injection)
293
+ // Build tool definitions first (needed for schema injection).
360
294
  // When using mesh delegation, enrich tools with endpoint URLs
361
- // so the provider can execute tools directly via MCP proxies
295
+ // so the provider can execute tools directly via MCP proxies.
362
296
  const isMeshDelegated = !!context.meshProvider;
363
297
  const toolDefs = this.buildToolDefinitions(context.tools, isMeshDelegated);
364
298
  // Add system prompt if configured
@@ -375,7 +309,7 @@ export class MeshLlmAgent {
375
309
  // formatting via output_schema in model_params. Consumer doesn't know
376
310
  // the provider's vendor, so it must not add vendor-agnostic schema instructions.
377
311
  const outputMode = this.config.outputMode ?? "hint";
378
- if (!context.meshProvider && outputMode !== "text" && this.config.returnSchema) {
312
+ if (opts.includeOutputSchemaHint && outputMode !== "text" && this.config.returnSchema) {
379
313
  const outputSchemaSection = this.buildOutputSchemaSection();
380
314
  systemContent += outputSchemaSection;
381
315
  }
@@ -426,11 +360,41 @@ export class MeshLlmAgent {
426
360
  }
427
361
  }
428
362
  }
363
+ return messages;
364
+ }
365
+ /**
366
+ * Run the agentic loop.
367
+ *
368
+ * @param messageInput - User message string or multi-turn message array
369
+ * @param context - Runtime context with tools and options
370
+ * @returns Parsed response (validated if schema provided)
371
+ */
372
+ async run(messageInput, context) {
373
+ if (this.config.parallelToolCalls && !this._parallelLogEmitted) {
374
+ console.log("[mesh.llm] parallel tool calls enabled — tools will execute concurrently via Promise.all()");
375
+ this._parallelLogEmitted = true;
376
+ }
377
+ const startTime = Date.now();
378
+ const toolCalls = [];
379
+ let totalInputTokens = 0;
380
+ let totalOutputTokens = 0;
381
+ // Resolve provider
382
+ const provider = this.resolveProvider(context);
383
+ // Build tool definitions (needed for schema injection + the agentic loop).
384
+ // When using mesh delegation, enrich tools with endpoint URLs
385
+ // so the provider can execute tools directly via MCP proxies.
386
+ const isMeshDelegated = !!context.meshProvider;
387
+ const toolDefs = this.buildToolDefinitions(context.tools, isMeshDelegated);
388
+ // Build initial messages (system prompt + tool schema + output-schema hint
389
+ // + resolved media + multi-turn unwinding). run() includes the output-schema
390
+ // hint only when NOT mesh-delegated.
391
+ const messages = await this.buildAgentMessages(messageInput, context, {
392
+ includeOutputSchemaHint: !isMeshDelegated,
393
+ });
429
394
  // Get effective options (runtime options > MESH_LLM_* env > config)
430
- const maxIterations = context.options?.maxIterations ??
431
- (process.env.MESH_LLM_MAX_ITERATIONS
432
- ? parseInt(process.env.MESH_LLM_MAX_ITERATIONS, 10)
433
- : this.config.maxIterations);
395
+ const maxIterations = sanitizeMaxIterations(context.options?.maxIterations) ??
396
+ envMaxIterations() ??
397
+ this.config.maxIterations;
434
398
  const maxTokens = context.options?.maxOutputTokens ?? this.config.maxOutputTokens;
435
399
  const temperature = context.options?.temperature ?? this.config.temperature;
436
400
  // Determine model (mesh provider > MESH_LLM_MODEL env > config > default)
@@ -438,19 +402,8 @@ export class MeshLlmAgent {
438
402
  process.env.MESH_LLM_MODEL ??
439
403
  this.config.model ??
440
404
  this.getDefaultModel();
441
- // Build output schema for provider (Issue #459) - computed once before loop
442
- let outputSchema;
443
- if (this.config.returnSchema) {
444
- try {
445
- const jsonSchema = zodToJsonSchema(this.config.returnSchema);
446
- // Extract schema name from title or use generic name
447
- const schemaName = jsonSchema.title ?? "Response";
448
- outputSchema = { schema: jsonSchema, name: schemaName };
449
- }
450
- catch {
451
- // If schema conversion fails, skip
452
- }
453
- }
405
+ // Build output schema for provider (Issue #459) - computed once, cached
406
+ const outputSchema = this.getOutputSchema();
454
407
  // Agentic loop
455
408
  let iteration = 0;
456
409
  let finalContent = "";
@@ -465,6 +418,11 @@ export class MeshLlmAgent {
465
418
  outputSchema,
466
419
  // Issue #1019: forward caller-supplied escape-hatch kwargs
467
420
  modelParams: context.options?.modelParams,
421
+ // Issue #1116: forward the resolved provider-managed loop cap.
422
+ maxIterations,
423
+ // Issue #1112: forward the RAW (possibly-undefined) output_mode so the
424
+ // provider honors an explicit override; unset stays auto.
425
+ outputMode: this.config.outputMode,
468
426
  });
469
427
  // Track tokens
470
428
  if (response.usage) {
@@ -597,79 +555,25 @@ export class MeshLlmAgent {
597
555
  // loop. The mesh-delegated streaming provider runs its own loop on the
598
556
  // server side and emits text chunks via notifications/progress; the
599
557
  // consumer just yields each one.
600
- const messages = [];
601
- const isMeshDelegated = true; // by definition: we required meshProvider above
602
- const toolDefs = this.buildToolDefinitions(context.tools, isMeshDelegated);
603
- // System prompt with template rendering + tool schema injection.
604
- // Mirrors run(): mesh-delegated path skips the output-schema hint
605
- // because the provider applies vendor-specific output formatting.
606
- const systemPromptTemplate = this.getSystemPrompt();
607
- if (systemPromptTemplate) {
608
- let systemContent = await renderTemplate(systemPromptTemplate, context.templateContext ?? {});
609
- if (toolDefs.length > 0) {
610
- systemContent += this.buildToolSchemaSection(toolDefs);
611
- }
612
- messages.push({ role: "system", content: systemContent });
613
- }
614
- // Resolve media items to OpenAI-compatible image_url parts
615
- const mediaItems = context.options?.media;
616
- let mediaParts = null;
617
- if (mediaItems && mediaItems.length > 0) {
618
- mediaParts = await resolveMediaInputs(mediaItems);
619
- }
620
- if (typeof messageInput === "string") {
621
- if (mediaParts && mediaParts.length > 0) {
622
- messages.push({
623
- role: "user",
624
- content: [
625
- { type: "text", text: messageInput },
626
- ...mediaParts,
627
- ],
628
- });
629
- }
630
- else {
631
- messages.push({ role: "user", content: messageInput });
632
- }
633
- }
634
- else {
635
- for (let i = 0; i < messageInput.length; i++) {
636
- const msg = messageInput[i];
637
- const isLastUser = mediaParts &&
638
- mediaParts.length > 0 &&
639
- msg.role === "user" &&
640
- i === messageInput.length - 1;
641
- if (isLastUser) {
642
- messages.push({
643
- role: "user",
644
- content: [
645
- { type: "text", text: msg.content },
646
- ...mediaParts,
647
- ],
648
- });
649
- }
650
- else {
651
- messages.push({ role: msg.role, content: msg.content });
652
- }
653
- }
654
- }
558
+ // Mesh-delegated by definition (we required meshProvider above).
559
+ const toolDefs = this.buildToolDefinitions(context.tools, true);
560
+ // Build initial messages (system prompt + tool schema + resolved media +
561
+ // multi-turn unwinding). stream() NEVER includes the output-schema hint —
562
+ // the provider applies vendor-specific output formatting.
563
+ const messages = await this.buildAgentMessages(messageInput, context, {
564
+ includeOutputSchemaHint: false,
565
+ });
655
566
  // Effective options (runtime > env > config)
567
+ const maxIterations = sanitizeMaxIterations(context.options?.maxIterations) ??
568
+ envMaxIterations() ??
569
+ this.config.maxIterations;
656
570
  const maxTokens = context.options?.maxOutputTokens ?? this.config.maxOutputTokens;
657
571
  const temperature = context.options?.temperature ?? this.config.temperature;
658
572
  const model = context.meshProvider?.model ??
659
573
  process.env.MESH_LLM_MODEL ??
660
574
  this.config.model ??
661
575
  this.getDefaultModel();
662
- let outputSchema;
663
- if (this.config.returnSchema) {
664
- try {
665
- const jsonSchema = zodToJsonSchema(this.config.returnSchema);
666
- const schemaName = jsonSchema.title ?? "Response";
667
- outputSchema = { schema: jsonSchema, name: schemaName };
668
- }
669
- catch {
670
- // skip
671
- }
672
- }
576
+ const outputSchema = this.getOutputSchema();
673
577
  const provider = new MeshDelegatedProvider(context.meshProvider.endpoint, context.meshProvider.functionName, this.config.parallelToolCalls ?? false);
674
578
  yield* provider.streamComplete(model, messages, toolDefs.length > 0 ? toolDefs : undefined, {
675
579
  maxOutputTokens: maxTokens,
@@ -679,6 +583,11 @@ export class MeshLlmAgent {
679
583
  outputSchema,
680
584
  // Issue #1019: forward caller-supplied escape-hatch kwargs
681
585
  modelParams: context.options?.modelParams,
586
+ // Issue #1116: forward the resolved provider-managed loop cap.
587
+ maxIterations,
588
+ // Issue #1112: forward the RAW (possibly-undefined) output_mode so the
589
+ // provider honors an explicit override; unset stays auto.
590
+ outputMode: this.config.outputMode,
682
591
  });
683
592
  }
684
593
  /**
@@ -686,8 +595,9 @@ export class MeshLlmAgent {
686
595
  */
687
596
  createCallable(context) {
688
597
  const agent = this;
689
- const callable = async (message, options) => {
690
- // Handle context mode
598
+ // Shared "context merge vs replace" semantics used by both the buffered
599
+ // callable and the stream method below.
600
+ const mergeRunContext = (options) => {
691
601
  const contextMode = options?.contextMode ?? "merge";
692
602
  let mergedTemplateContext;
693
603
  if (contextMode === "replace" && options?.context) {
@@ -702,13 +612,14 @@ export class MeshLlmAgent {
702
612
  // No runtime context - use base context
703
613
  mergedTemplateContext = context.templateContext ?? {};
704
614
  }
705
- // Merge options
706
- const mergedContext = {
615
+ return {
707
616
  ...context,
708
617
  options: options ? { ...context.options, ...options } : context.options,
709
618
  templateContext: mergedTemplateContext,
710
619
  };
711
- return agent.run(message, mergedContext);
620
+ };
621
+ const callable = async (message, options) => {
622
+ return agent.run(message, mergeRunContext(options));
712
623
  };
713
624
  // Attach meta property
714
625
  Object.defineProperty(callable, "meta", {
@@ -727,23 +638,7 @@ export class MeshLlmAgent {
727
638
  // "context merge vs replace" behavior as the buffered call.
728
639
  Object.defineProperty(callable, "stream", {
729
640
  value: (message, options) => {
730
- const contextMode = options?.contextMode ?? "merge";
731
- let mergedTemplateContext;
732
- if (contextMode === "replace" && options?.context) {
733
- mergedTemplateContext = options.context;
734
- }
735
- else if (options?.context) {
736
- mergedTemplateContext = { ...context.templateContext, ...options.context };
737
- }
738
- else {
739
- mergedTemplateContext = context.templateContext ?? {};
740
- }
741
- const mergedContext = {
742
- ...context,
743
- options: options ? { ...context.options, ...options } : context.options,
744
- templateContext: mergedTemplateContext,
745
- };
746
- return agent.stream(message, mergedContext);
641
+ return agent.stream(message, mergeRunContext(options));
747
642
  },
748
643
  });
749
644
  return callable;
@@ -819,17 +714,16 @@ export class MeshLlmAgent {
819
714
  * Guides the LLM to produce structured output matching the schema.
820
715
  */
821
716
  buildOutputSchemaSection() {
822
- if (!this.config.returnSchema)
823
- return "";
824
- try {
825
- const jsonSchema = zodToJsonSchema(this.config.returnSchema);
826
- const schemaStr = JSON.stringify(jsonSchema, null, 2);
827
- return `\n\n## Output Format\n\nYour response MUST be valid JSON matching this schema:\n\n\`\`\`json\n${schemaStr}\n\`\`\`\n\nRespond ONLY with the JSON object, no additional text.`;
828
- }
829
- catch {
830
- // If schema conversion fails, skip injection
717
+ if (this._outputSchemaSection !== null)
718
+ return this._outputSchemaSection;
719
+ const cached = this.getOutputSchema();
720
+ if (!cached) {
721
+ this._outputSchemaSection = "";
831
722
  return "";
832
723
  }
724
+ const schemaStr = JSON.stringify(cached.schema, null, 2);
725
+ this._outputSchemaSection = `\n\n## Output Format\n\nYour response MUST be valid JSON matching this schema:\n\n\`\`\`json\n${schemaStr}\n\`\`\`\n\nRespond ONLY with the JSON object, no additional text.`;
726
+ return this._outputSchemaSection;
833
727
  }
834
728
  /**
835
729
  * Execute a tool call and record metadata.
@@ -876,129 +770,40 @@ export function createLlmToolProxy(toolInfo, description) {
876
770
  const proxy = async (args) => {
877
771
  // Set up timeout (default 30s for tool calls)
878
772
  const timeoutMs = parseInt(process.env.MESH_TOOL_TIMEOUT_MS || "30000", 10);
879
- // Tracing: propagate context to downstream tool
880
- const traceCtx = getCurrentTraceContext();
881
- const traceSpanId = traceCtx ? generateSpanId() : null;
882
- const traceStartTime = Date.now() / 1000;
883
- let traceSuccess = true;
884
- let traceError = null;
885
- let resultType = "unknown";
886
- // Build arguments with trace context injection via Rust core
887
- const toolPropHeaders = getCurrentPropagatedHeaders();
888
- let toolArgsWithTrace;
889
- if (traceCtx && traceSpanId) {
890
- try {
891
- const argsJson = JSON.stringify(args);
892
- const headersJson = Object.keys(toolPropHeaders).length > 0 ? JSON.stringify(toolPropHeaders) : undefined;
893
- const injectedJson = injectTraceContext(argsJson, traceCtx.traceId, traceSpanId, headersJson);
894
- toolArgsWithTrace = JSON.parse(injectedJson);
895
- }
896
- catch {
897
- // Fallback to manual injection
898
- toolArgsWithTrace = {
899
- ...args,
900
- _trace_id: traceCtx.traceId,
901
- _parent_span: traceSpanId,
902
- ...(Object.keys(toolPropHeaders).length > 0 ? { _mesh_headers: toolPropHeaders } : {}),
903
- };
904
- }
773
+ // Route through the shared callMcpTool (trace injection, span publish,
774
+ // dispatcher pooling, job-cancel wiring all handled internally). Tools may
775
+ // be non-idempotent so disable retries; LLM tool results can exceed the
776
+ // default 10 MiB cap so raise it to effectively unbounded.
777
+ const callOptions = {
778
+ ...DEFAULT_CALL_OPTIONS,
779
+ timeout: timeoutMs,
780
+ maxAttempts: 1,
781
+ maxResponseSize: Number.MAX_SAFE_INTEGER,
782
+ };
783
+ let result;
784
+ try {
785
+ result = await callMcpTool(toolInfo.endpoint, toolInfo.functionName, args, callOptions, "mesh-tool");
905
786
  }
906
- else {
907
- toolArgsWithTrace = {
908
- ...args,
909
- ...(Object.keys(toolPropHeaders).length > 0 ? { _mesh_headers: toolPropHeaders } : {}),
910
- };
787
+ catch (error) {
788
+ // callMcpTool throws a plain Error on failure — re-wrap into the
789
+ // ToolExecutionError shape the agentic loop expects.
790
+ throw new ToolExecutionError(toolInfo.functionName, error instanceof Error ? error : new Error(String(error)));
911
791
  }
912
- try {
913
- // Make MCP call to the tool
914
- let response;
915
- try {
916
- response = await fetchWithTimeout(`${toolInfo.endpoint}/mcp`, {
917
- method: "POST",
918
- headers: {
919
- "Content-Type": "application/json",
920
- "Accept": "application/json, text/event-stream",
921
- ...(traceCtx && traceSpanId ? createTraceHeaders(traceCtx.traceId, traceSpanId) : {}),
922
- ...toolPropHeaders,
923
- },
924
- body: JSON.stringify({
925
- jsonrpc: "2.0",
926
- id: Date.now(),
927
- method: "tools/call",
928
- params: {
929
- name: toolInfo.functionName,
930
- arguments: toolArgsWithTrace,
931
- },
932
- }),
933
- timeout: timeoutMs,
934
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
935
- dispatcher: getDispatcher(`${toolInfo.endpoint}/mcp`),
936
- });
937
- }
938
- catch (error) {
939
- if (isTimeoutError(error)) {
940
- throw new ToolExecutionError(toolInfo.functionName, new Error(`Tool call timed out after ${timeoutMs}ms (endpoint: ${toolInfo.endpoint})`));
941
- }
942
- throw new ToolExecutionError(toolInfo.functionName, error instanceof Error ? error : new Error(String(error)));
943
- }
944
- if (!response.ok) {
945
- const errorBody = await response.text();
946
- throw new ToolExecutionError(toolInfo.functionName, new Error(`Tool call failed: ${response.status} ${errorBody}`));
947
- }
948
- // Handle SSE response from FastMCP stateless HTTP stream
949
- const responseText = await response.text();
950
- const result = parseSSEResponse(responseText);
951
- if (result.error) {
952
- throw new Error(`Tool error: ${result.error.message}`);
953
- }
954
- // Parse result content
955
- const content = result.result?.content?.[0];
956
- if (!content) {
957
- resultType = "null";
958
- return null;
959
- }
960
- if (content.type === "text" && content.text) {
961
- // Try to parse as JSON
962
- try {
963
- const parsed = JSON.parse(content.text);
964
- resultType = typeof parsed;
965
- return parsed;
966
- }
967
- catch {
968
- resultType = "string";
969
- return content.text;
970
- }
971
- }
972
- resultType = typeof content;
973
- return content;
792
+ // Multi-content results are returned as structured objects as-is.
793
+ if (typeof result === "object") {
794
+ return result;
974
795
  }
975
- catch (err) {
976
- traceSuccess = false;
977
- traceError = err instanceof Error ? err.message : String(err);
978
- throw err;
796
+ // Empty content signals "tool returned nothing" — preserve the null result
797
+ // the hand-rolled path produced rather than an empty string.
798
+ if (result === "") {
799
+ return null;
979
800
  }
980
- finally {
981
- if (traceCtx && traceSpanId) {
982
- const traceEndTime = Date.now() / 1000;
983
- const traceDurationMs = (traceEndTime - traceStartTime) * 1000;
984
- publishTraceSpan({
985
- traceId: traceCtx.traceId,
986
- spanId: traceSpanId,
987
- parentSpan: traceCtx.parentSpanId,
988
- functionName: "proxy_call_wrapper",
989
- startTime: traceStartTime,
990
- endTime: traceEndTime,
991
- durationMs: traceDurationMs,
992
- success: traceSuccess,
993
- error: traceError,
994
- resultType: traceSuccess ? resultType : "error",
995
- argsCount: 0,
996
- kwargsCount: 0,
997
- dependencies: [toolInfo.endpoint],
998
- injectedDependencies: 0,
999
- meshPositions: [],
1000
- }).catch(() => { });
1001
- }
801
+ // Parse JSON if possible, otherwise return the raw string.
802
+ try {
803
+ return JSON.parse(result);
804
+ }
805
+ catch {
806
+ return result;
1002
807
  }
1003
808
  };
1004
809
  // Safely parse inputSchema - don't let malformed JSON break proxy creation