@vedmalex/ai-connect 0.9.0 → 0.10.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.
@@ -2622,6 +2622,73 @@ function normalizeToolResult(result) {
2622
2622
  ...result.isError !== void 0 ? { isError: result.isError } : {}
2623
2623
  };
2624
2624
  }
2625
+ var MAX_CLIENT_TOOL_LOOP_DEPTH = 8;
2626
+ async function* executeToolCallsRound(params) {
2627
+ const { toolCalls, registry, route, request, runtime, messages, abort } = params;
2628
+ const assistantMessage = {
2629
+ role: "assistant",
2630
+ content: params.assistantText,
2631
+ toolCalls
2632
+ };
2633
+ const toolMessages = [];
2634
+ for (const toolCall of toolCalls) {
2635
+ const tool = registry.get(toolCall.name);
2636
+ if (!tool) {
2637
+ throw new ClientToolExecutionError(
2638
+ new AiConnectError(
2639
+ "validation_error",
2640
+ `No client tool handler is registered for "${toolCall.name}".`
2641
+ )
2642
+ );
2643
+ }
2644
+ if (abort.signal.aborted) {
2645
+ throw mapAbortError(abort.reason);
2646
+ }
2647
+ const startEvent = params.onToolStart?.(toolCall);
2648
+ if (startEvent) {
2649
+ yield startEvent;
2650
+ }
2651
+ let execution;
2652
+ try {
2653
+ execution = normalizeToolResult(
2654
+ await tool.execute(toolCall.arguments, {
2655
+ route,
2656
+ request,
2657
+ runtime,
2658
+ toolCall,
2659
+ messages,
2660
+ ...request.workingDirectory ? { workingDirectory: request.workingDirectory } : {}
2661
+ })
2662
+ );
2663
+ } catch (error) {
2664
+ if (abort.signal.aborted) {
2665
+ throw mapAbortError(abort.reason);
2666
+ }
2667
+ throw new ClientToolExecutionError(
2668
+ new AiConnectError(
2669
+ "validation_error",
2670
+ `Client tool "${tool.function.name}" threw during execution: ${error instanceof Error ? error.message : String(error)}`,
2671
+ { cause: error }
2672
+ )
2673
+ );
2674
+ }
2675
+ toolMessages.push({
2676
+ role: "tool",
2677
+ content: execution.content,
2678
+ toolName: tool.function.name,
2679
+ toolCallId: toolCall.id,
2680
+ ...execution.data !== void 0 ? { data: execution.data } : {},
2681
+ ...execution.isError !== void 0 ? { isError: execution.isError } : {}
2682
+ });
2683
+ const finishEvent = params.onToolFinish?.(toolCall, {
2684
+ ...execution.isError !== void 0 ? { isError: execution.isError } : {}
2685
+ });
2686
+ if (finishEvent) {
2687
+ yield finishEvent;
2688
+ }
2689
+ }
2690
+ return { assistantMessage, toolMessages };
2691
+ }
2625
2692
  function sumOptionalNumbers(left, right) {
2626
2693
  if (left === void 0) {
2627
2694
  return right;
@@ -3225,7 +3292,7 @@ function createClient(configOrInput, options = {}) {
3225
3292
  let output = initialOutput;
3226
3293
  let messages = [...request.messages];
3227
3294
  let transportSummary2;
3228
- for (let round = 0; round < 8; round += 1) {
3295
+ for (let round = 0; ; round += 1) {
3229
3296
  const toolCalls = output.toolCalls ?? [];
3230
3297
  if (toolCalls.length === 0) {
3231
3298
  return {
@@ -3237,62 +3304,36 @@ function createClient(configOrInput, options = {}) {
3237
3304
  ...transportSummary2 ? { transportSummary: transportSummary2 } : {}
3238
3305
  };
3239
3306
  }
3240
- const assistantMessage = {
3241
- role: "assistant",
3242
- content: output.text ?? "",
3243
- toolCalls
3244
- };
3245
- const toolMessages = [];
3246
- for (const toolCall of toolCalls) {
3247
- const tool = registry.get(toolCall.name);
3248
- if (!tool) {
3249
- throw new ClientToolExecutionError(
3250
- new AiConnectError(
3251
- "validation_error",
3252
- `No client tool handler is registered for "${toolCall.name}".`
3253
- )
3254
- );
3255
- }
3256
- if (abort.signal.aborted) {
3257
- throw mapAbortError(abort.reason);
3258
- }
3259
- let execution;
3260
- try {
3261
- execution = normalizeToolResult(
3262
- await tool.execute(toolCall.arguments, {
3263
- route,
3264
- request,
3265
- runtime,
3266
- toolCall,
3267
- messages,
3268
- ...request.workingDirectory ? { workingDirectory: request.workingDirectory } : {}
3269
- })
3270
- );
3271
- } catch (error) {
3272
- if (abort.signal.aborted) {
3273
- throw mapAbortError(abort.reason);
3274
- }
3275
- throw new ClientToolExecutionError(
3276
- new AiConnectError(
3277
- "validation_error",
3278
- `Client tool "${tool.function.name}" threw during execution: ${error instanceof Error ? error.message : String(error)}`,
3279
- { cause: error }
3280
- )
3281
- );
3282
- }
3283
- toolMessages.push({
3284
- role: "tool",
3285
- content: execution.content,
3286
- toolName: tool.function.name,
3287
- toolCallId: toolCall.id,
3288
- ...execution.data !== void 0 ? { data: execution.data } : {},
3289
- ...execution.isError !== void 0 ? { isError: execution.isError } : {}
3290
- });
3307
+ const roundIterator = executeToolCallsRound({
3308
+ toolCalls,
3309
+ assistantText: output.text ?? "",
3310
+ registry,
3311
+ route,
3312
+ request,
3313
+ runtime,
3314
+ messages,
3315
+ abort
3316
+ });
3317
+ let roundStep = await roundIterator.next();
3318
+ while (!roundStep.done) {
3319
+ roundStep = await roundIterator.next();
3291
3320
  }
3292
- messages = [...messages, assistantMessage, ...toolMessages];
3321
+ messages = [
3322
+ ...messages,
3323
+ roundStep.value.assistantMessage,
3324
+ ...roundStep.value.toolMessages
3325
+ ];
3293
3326
  if (abort.signal.aborted) {
3294
3327
  throw mapAbortError(abort.reason);
3295
3328
  }
3329
+ if (round + 1 >= MAX_CLIENT_TOOL_LOOP_DEPTH) {
3330
+ throw new ClientToolExecutionError(
3331
+ new AiConnectError(
3332
+ "validation_error",
3333
+ `clientTools exceeded the maximum tool-call loop depth of ${MAX_CLIENT_TOOL_LOOP_DEPTH}.`
3334
+ )
3335
+ );
3336
+ }
3296
3337
  output = await runWithAbortRace(
3297
3338
  handler({
3298
3339
  route,
@@ -3315,12 +3356,6 @@ function createClient(configOrInput, options = {}) {
3315
3356
  warnings.push(...output.warnings ?? []);
3316
3357
  usage = mergeUsage(usage, output.usage);
3317
3358
  }
3318
- throw new ClientToolExecutionError(
3319
- new AiConnectError(
3320
- "validation_error",
3321
- "clientTools exceeded the maximum tool-call loop depth of 8."
3322
- )
3323
- );
3324
3359
  }
3325
3360
  async function emitWideEvent(event) {
3326
3361
  if (!logging?.logger) {
@@ -4380,12 +4415,6 @@ function createClient(configOrInput, options = {}) {
4380
4415
  },
4381
4416
  async *stream(request, opts) {
4382
4417
  const preparedRequest = prepareRequest(request);
4383
- if (preparedRequest.clientTools?.length) {
4384
- throw new AiConnectError(
4385
- "not_supported",
4386
- "clientTools are currently supported only by generate(), not stream()."
4387
- );
4388
- }
4389
4418
  const { abort, dispose: disposeAbort } = deriveAbort(
4390
4419
  opts,
4391
4420
  resolveOpTimeout(opts, "generateMs", options.timeouts),
@@ -4405,7 +4434,12 @@ function createClient(configOrInput, options = {}) {
4405
4434
  release = await limiter.acquire(abort.signal);
4406
4435
  const selectedModel = await resolveSelectedModel(preparedRequest);
4407
4436
  const normalizedRequest = withSelectedModel(preparedRequest, selectedModel);
4408
- const { requiresImage, requiresFile, request: capabilityScopedRequest } = resolveRequiredCapabilities(normalizedRequest);
4437
+ const {
4438
+ requiresImage,
4439
+ requiresFile,
4440
+ requiresClientTools,
4441
+ request: capabilityScopedRequest
4442
+ } = resolveRequiredCapabilities(normalizedRequest);
4409
4443
  const candidates = router.resolveCandidates(capabilityScopedRequest);
4410
4444
  const startedAt = nowMs(runtime);
4411
4445
  const requestId = resolveEventRequestId(normalizedRequest.logContext);
@@ -4413,10 +4447,12 @@ function createClient(configOrInput, options = {}) {
4413
4447
  const requestSummary = summarizeRequest(normalizedRequest, candidateSummaries);
4414
4448
  const attempts = [];
4415
4449
  const wideAttempts = [];
4450
+ let hasYieldedOutward = false;
4451
+ let sawStreamHandler = false;
4416
4452
  if (candidates.length === 0) {
4417
- const error = requiresImage || requiresFile ? new AiConnectError(
4453
+ const error = requiresImage || requiresFile || requiresClientTools ? new AiConnectError(
4418
4454
  "unsupported_capability",
4419
- `No eligible route supports the required ${requiresFile ? "document/file" : "image"} input for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".`
4455
+ requiresImage || requiresFile ? `No eligible route supports the required ${requiresFile ? "document/file" : "image"} input for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".` : `No eligible route supports client-side tool execution for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".`
4420
4456
  ) : new AiConnectError("not_supported", `No eligible routes are available for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".`);
4421
4457
  await emitWideEvent({
4422
4458
  timestamp: isoTimestamp(startedAt),
@@ -4444,84 +4480,202 @@ function createClient(configOrInput, options = {}) {
4444
4480
  continue;
4445
4481
  }
4446
4482
  if (handler.stream) {
4483
+ sawStreamHandler = true;
4447
4484
  const routeSummary = summarizeRoute(route);
4448
4485
  const attemptStartedAt = nowMs(runtime);
4449
4486
  let lastResult;
4450
4487
  let accumulatedText = "";
4451
4488
  const routeRequest = applyRouteDefaults(route, normalizedRequest);
4452
- const iterable = handler.stream({
4453
- route,
4454
- request: routeRequest,
4455
- runtime,
4456
- attempt: 1,
4457
- abort
4458
- });
4459
- const iterator = iterable[Symbol.asyncIterator]();
4460
- let iteratorReturned = false;
4461
- const returnIterator = async () => {
4462
- if (iteratorReturned) {
4463
- return;
4464
- }
4465
- iteratorReturned = true;
4466
- try {
4467
- await iterator.return?.();
4468
- } catch {
4469
- }
4470
- };
4489
+ const roundTools = routeRequest.clientTools ?? [];
4490
+ const registry = new Map(
4491
+ roundTools.map((tool) => [tool.function.name, tool])
4492
+ );
4493
+ let loopMessages = routeRequest.messages;
4494
+ let aggregatedUsage;
4495
+ const aggregatedWarnings = [];
4496
+ let toolRoundsExecuted = 0;
4471
4497
  try {
4472
- while (true) {
4498
+ roundsLoop: for (let round = 0; ; round += 1) {
4499
+ if (round >= MAX_CLIENT_TOOL_LOOP_DEPTH) {
4500
+ throw new ClientToolExecutionError(
4501
+ new AiConnectError(
4502
+ "validation_error",
4503
+ `clientTools exceeded the maximum tool-call loop depth of ${MAX_CLIENT_TOOL_LOOP_DEPTH}.`
4504
+ )
4505
+ );
4506
+ }
4473
4507
  if (abort.signal.aborted) {
4474
- await returnIterator();
4475
4508
  throw mapAbortError(abort.reason);
4476
4509
  }
4477
- if (pauseSignal?.aborted) {
4510
+ const roundRequest = round === 0 ? routeRequest : { ...routeRequest, messages: loopMessages, attachments: [] };
4511
+ const iterable = handler.stream({
4512
+ route,
4513
+ request: roundRequest,
4514
+ runtime,
4515
+ attempt: 1,
4516
+ abort
4517
+ });
4518
+ const iterator = iterable[Symbol.asyncIterator]();
4519
+ let iteratorReturned = false;
4520
+ const returnIterator = async () => {
4521
+ if (iteratorReturned) {
4522
+ return;
4523
+ }
4524
+ iteratorReturned = true;
4525
+ try {
4526
+ await iterator.return?.();
4527
+ } catch {
4528
+ }
4529
+ };
4530
+ let roundResult;
4531
+ try {
4532
+ while (true) {
4533
+ if (abort.signal.aborted) {
4534
+ await returnIterator();
4535
+ throw mapAbortError(abort.reason);
4536
+ }
4537
+ if (pauseSignal?.aborted) {
4538
+ await returnIterator();
4539
+ const pausedResult = lastResult ?? {
4540
+ route,
4541
+ text: accumulatedText,
4542
+ attachments: [],
4543
+ warnings: [],
4544
+ attempts
4545
+ };
4546
+ wideAttempts.push({
4547
+ index: wideAttempts.length + 1,
4548
+ route: routeSummary,
4549
+ durationMs: nowMs(runtime) - attemptStartedAt,
4550
+ outcome: "success",
4551
+ retryCount: 0
4552
+ });
4553
+ await emitWideEvent({
4554
+ timestamp: isoTimestamp(startedAt),
4555
+ event: "ai_connect.operation",
4556
+ requestId,
4557
+ operationName: "stream",
4558
+ operation: normalizedRequest.operation,
4559
+ runtime: runtime.kind,
4560
+ outcome: "success",
4561
+ durationMs: nowMs(runtime) - startedAt,
4562
+ candidates: candidateSummaries,
4563
+ attempts: wideAttempts,
4564
+ selectedRoute: routeSummary,
4565
+ request: requestSummary,
4566
+ result: summarizeResult(pausedResult),
4567
+ ...normalizedRequest.logContext ? { context: normalizedRequest.logContext } : {}
4568
+ });
4569
+ yield { type: "paused", result: pausedResult };
4570
+ return;
4571
+ }
4572
+ const next = await iterator.next();
4573
+ if (next.done) {
4574
+ break;
4575
+ }
4576
+ const event = next.value;
4577
+ if (event.type === "delta") {
4578
+ accumulatedText += event.text;
4579
+ hasYieldedOutward = true;
4580
+ yield event;
4581
+ continue;
4582
+ }
4583
+ if (event.type === "paused") {
4584
+ lastResult = event.result;
4585
+ wideAttempts.push({
4586
+ index: wideAttempts.length + 1,
4587
+ route: routeSummary,
4588
+ durationMs: nowMs(runtime) - attemptStartedAt,
4589
+ outcome: "success",
4590
+ retryCount: 0
4591
+ });
4592
+ router.recordSuccess(route);
4593
+ await emitWideEvent({
4594
+ timestamp: isoTimestamp(startedAt),
4595
+ event: "ai_connect.operation",
4596
+ requestId,
4597
+ operationName: "stream",
4598
+ operation: normalizedRequest.operation,
4599
+ runtime: runtime.kind,
4600
+ outcome: "success",
4601
+ durationMs: nowMs(runtime) - startedAt,
4602
+ candidates: candidateSummaries,
4603
+ attempts: wideAttempts,
4604
+ selectedRoute: routeSummary,
4605
+ request: requestSummary,
4606
+ result: summarizeResult(event.result),
4607
+ ...normalizedRequest.logContext ? { context: normalizedRequest.logContext } : {}
4608
+ });
4609
+ yield event;
4610
+ return;
4611
+ }
4612
+ if (event.type === "result") {
4613
+ roundResult = event.result;
4614
+ break;
4615
+ }
4616
+ }
4617
+ } finally {
4478
4618
  await returnIterator();
4479
- const pausedResult = lastResult ?? {
4480
- route,
4481
- text: accumulatedText,
4482
- attachments: [],
4483
- warnings: [],
4484
- attempts
4485
- };
4486
- wideAttempts.push({
4487
- index: wideAttempts.length + 1,
4488
- route: routeSummary,
4489
- durationMs: nowMs(runtime) - attemptStartedAt,
4490
- outcome: "success",
4491
- retryCount: 0
4492
- });
4493
- await emitWideEvent({
4494
- timestamp: isoTimestamp(startedAt),
4495
- event: "ai_connect.operation",
4496
- requestId,
4497
- operationName: "stream",
4498
- operation: normalizedRequest.operation,
4499
- runtime: runtime.kind,
4500
- outcome: "success",
4501
- durationMs: nowMs(runtime) - startedAt,
4502
- candidates: candidateSummaries,
4503
- attempts: wideAttempts,
4504
- selectedRoute: routeSummary,
4505
- request: requestSummary,
4506
- result: summarizeResult(pausedResult),
4507
- ...normalizedRequest.logContext ? { context: normalizedRequest.logContext } : {}
4508
- });
4509
- yield { type: "paused", result: pausedResult };
4510
- return;
4511
4619
  }
4512
- const next = await iterator.next();
4513
- if (next.done) {
4514
- break;
4620
+ if (!roundResult) {
4621
+ break roundsLoop;
4515
4622
  }
4516
- const event = next.value;
4517
- if (event.type === "delta") {
4518
- accumulatedText += event.text;
4519
- yield event;
4520
- continue;
4623
+ if (registry.size === 0 || !roundResult.toolCalls?.length) {
4624
+ if (toolRoundsExecuted === 0) {
4625
+ lastResult = roundResult;
4626
+ } else {
4627
+ const mergedUsage = mergeUsage(aggregatedUsage, roundResult.usage);
4628
+ lastResult = {
4629
+ ...roundResult,
4630
+ warnings: [
4631
+ ...aggregatedWarnings,
4632
+ ...roundResult.warnings ?? []
4633
+ ],
4634
+ ...mergedUsage ? { usage: mergedUsage } : {}
4635
+ };
4636
+ }
4637
+ yield { type: "result", result: lastResult };
4638
+ break roundsLoop;
4521
4639
  }
4522
- lastResult = event.result;
4523
- yield event;
4524
- break;
4640
+ aggregatedUsage = mergeUsage(aggregatedUsage, roundResult.usage);
4641
+ aggregatedWarnings.push(...roundResult.warnings ?? []);
4642
+ const roundGen = executeToolCallsRound({
4643
+ toolCalls: roundResult.toolCalls,
4644
+ assistantText: roundResult.text ?? "",
4645
+ registry,
4646
+ route,
4647
+ request: routeRequest,
4648
+ runtime,
4649
+ messages: loopMessages,
4650
+ abort,
4651
+ // D3/UR-007: emit a `tool-call` BEFORE execute, `tool-result` AFTER.
4652
+ onToolStart: (toolCall) => ({
4653
+ type: "tool-call",
4654
+ toolCall: {
4655
+ id: toolCall.id,
4656
+ name: toolCall.name,
4657
+ arguments: toolCall.arguments
4658
+ }
4659
+ }),
4660
+ onToolFinish: (toolCall, execution) => ({
4661
+ type: "tool-result",
4662
+ toolCallId: toolCall.id,
4663
+ name: toolCall.name,
4664
+ ...execution.isError !== void 0 ? { isError: execution.isError } : {}
4665
+ })
4666
+ });
4667
+ let toolStep = await roundGen.next();
4668
+ while (!toolStep.done) {
4669
+ hasYieldedOutward = true;
4670
+ yield toolStep.value;
4671
+ toolStep = await roundGen.next();
4672
+ }
4673
+ loopMessages = [
4674
+ ...loopMessages,
4675
+ toolStep.value.assistantMessage,
4676
+ ...toolStep.value.toolMessages
4677
+ ];
4678
+ toolRoundsExecuted += 1;
4525
4679
  }
4526
4680
  wideAttempts.push({
4527
4681
  index: wideAttempts.length + 1,
@@ -4549,7 +4703,44 @@ function createClient(configOrInput, options = {}) {
4549
4703
  });
4550
4704
  return;
4551
4705
  } catch (error) {
4552
- await returnIterator();
4706
+ if (error instanceof ClientToolExecutionError && !abort.signal.aborted) {
4707
+ const toolError = error.aiError;
4708
+ attempts.push({
4709
+ routeId: route.id,
4710
+ handlerKey: route.handlerKey,
4711
+ errorCode: toolError.code,
4712
+ message: toolError.message
4713
+ });
4714
+ wideAttempts.push({
4715
+ index: wideAttempts.length + 1,
4716
+ route: routeSummary,
4717
+ durationMs: nowMs(runtime) - attemptStartedAt,
4718
+ outcome: "error",
4719
+ retryCount: 0,
4720
+ errorCode: toolError.code,
4721
+ message: toolError.message
4722
+ });
4723
+ await emitWideEvent({
4724
+ timestamp: isoTimestamp(startedAt),
4725
+ event: "ai_connect.operation",
4726
+ requestId,
4727
+ operationName: "stream",
4728
+ operation: normalizedRequest.operation,
4729
+ runtime: runtime.kind,
4730
+ outcome: "error",
4731
+ durationMs: nowMs(runtime) - startedAt,
4732
+ candidates: candidateSummaries,
4733
+ attempts: wideAttempts,
4734
+ request: requestSummary,
4735
+ error: {
4736
+ code: toolError.code,
4737
+ message: toolError.message,
4738
+ routeId: route.id
4739
+ },
4740
+ ...normalizedRequest.logContext ? { context: normalizedRequest.logContext } : {}
4741
+ });
4742
+ throw toolError;
4743
+ }
4553
4744
  const normalizedError = abort.signal.aborted ? mapAbortError(abort.reason) : toAiConnectError(error);
4554
4745
  attempts.push({
4555
4746
  routeId: route.id,
@@ -4588,6 +4779,28 @@ function createClient(configOrInput, options = {}) {
4588
4779
  });
4589
4780
  throw normalizedError;
4590
4781
  }
4782
+ if (hasYieldedOutward) {
4783
+ await emitWideEvent({
4784
+ timestamp: isoTimestamp(startedAt),
4785
+ event: "ai_connect.operation",
4786
+ requestId,
4787
+ operationName: "stream",
4788
+ operation: normalizedRequest.operation,
4789
+ runtime: runtime.kind,
4790
+ outcome: "error",
4791
+ durationMs: nowMs(runtime) - startedAt,
4792
+ candidates: candidateSummaries,
4793
+ attempts: wideAttempts,
4794
+ request: requestSummary,
4795
+ error: {
4796
+ code: normalizedError.code,
4797
+ message: normalizedError.message,
4798
+ routeId: route.id
4799
+ },
4800
+ ...normalizedRequest.logContext ? { context: normalizedRequest.logContext } : {}
4801
+ });
4802
+ throw normalizedError;
4803
+ }
4591
4804
  router.recordFailure(route, normalizedError.code);
4592
4805
  continue;
4593
4806
  }
@@ -4603,6 +4816,12 @@ function createClient(configOrInput, options = {}) {
4603
4816
  attempts,
4604
4817
  wideAttempts
4605
4818
  });
4819
+ if (requiresClientTools && !sawStreamHandler) {
4820
+ result.warnings = [
4821
+ ...result.warnings,
4822
+ "Streaming tool-loop is not supported by the available route(s); returned a non-streamed result via the generate tool-loop."
4823
+ ];
4824
+ }
4606
4825
  yield {
4607
4826
  type: "result",
4608
4827
  result