@letta-ai/letta-code 0.34.1 → 0.34.2

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 (61) hide show
  1. package/dist/gateway-core.js +621 -166
  2. package/dist/gateway-core.js.map +13 -8
  3. package/dist/mcp-client.js +2 -2
  4. package/dist/mcp-client.js.map +1 -1
  5. package/dist/mcp-oauth.js +2 -2
  6. package/dist/mcp-oauth.js.map +1 -1
  7. package/dist/types/agent/approval-execution.d.ts.map +1 -1
  8. package/dist/types/agent/message.d.ts.map +1 -1
  9. package/dist/types/agent/subagents/names.d.ts +1 -1
  10. package/dist/types/agent/subagents/names.d.ts.map +1 -1
  11. package/dist/types/channels/gateway-assistant-relay.d.ts +37 -0
  12. package/dist/types/channels/gateway-assistant-relay.d.ts.map +1 -0
  13. package/dist/types/channels/gateway-core.d.ts +8 -50
  14. package/dist/types/channels/gateway-core.d.ts.map +1 -1
  15. package/dist/types/channels/gateway-registration-policy.d.ts +14 -0
  16. package/dist/types/channels/gateway-registration-policy.d.ts.map +1 -0
  17. package/dist/types/channels/gateway-runtime-registration.d.ts +12 -0
  18. package/dist/types/channels/gateway-runtime-registration.d.ts.map +1 -0
  19. package/dist/types/channels/gateway-runtime-state.d.ts +48 -0
  20. package/dist/types/channels/gateway-runtime-state.d.ts.map +1 -0
  21. package/dist/types/channels/gateway-runtime-tool-scopes.d.ts +26 -0
  22. package/dist/types/channels/gateway-runtime-tool-scopes.d.ts.map +1 -0
  23. package/dist/types/channels/gateway-sources.d.ts +1 -0
  24. package/dist/types/channels/gateway-sources.d.ts.map +1 -1
  25. package/dist/types/channels/gateway-types.d.ts +71 -0
  26. package/dist/types/channels/gateway-types.d.ts.map +1 -0
  27. package/dist/types/channels/message-channel-executor.d.ts +1 -0
  28. package/dist/types/channels/message-channel-executor.d.ts.map +1 -1
  29. package/dist/types/channels/message-channel-idempotency.d.ts +14 -5
  30. package/dist/types/channels/message-channel-idempotency.d.ts.map +1 -1
  31. package/dist/types/channels/message-channel-tool-definition.d.ts +4 -1
  32. package/dist/types/channels/message-channel-tool-definition.d.ts.map +1 -1
  33. package/dist/types/channels/plugin-types.d.ts +3 -2
  34. package/dist/types/channels/plugin-types.d.ts.map +1 -1
  35. package/dist/types/channels/types.d.ts +3 -1
  36. package/dist/types/channels/types.d.ts.map +1 -1
  37. package/dist/types/reminders/disk-space.d.ts +34 -0
  38. package/dist/types/reminders/disk-space.d.ts.map +1 -1
  39. package/dist/types/tools/impl/github-pull-request-watch.d.ts +11 -4
  40. package/dist/types/tools/impl/github-pull-request-watch.d.ts.map +1 -1
  41. package/dist/types/tools/impl/workflow.d.ts.map +1 -1
  42. package/dist/types/tools/manager.d.ts +2 -4
  43. package/dist/types/tools/manager.d.ts.map +1 -1
  44. package/dist/types/tools/tool-name-mapping.d.ts +5 -0
  45. package/dist/types/tools/tool-name-mapping.d.ts.map +1 -0
  46. package/dist/types/tools/toolset-catalog.d.ts +1 -1
  47. package/dist/types/types/background-process-protocol.d.ts +15 -0
  48. package/dist/types/types/background-process-protocol.d.ts.map +1 -1
  49. package/dist/types/types/channel-account-protocol.d.ts +14 -0
  50. package/dist/types/types/channel-account-protocol.d.ts.map +1 -0
  51. package/dist/types/types/protocol_v2.d.ts +5 -11
  52. package/dist/types/types/protocol_v2.d.ts.map +1 -1
  53. package/dist/types/websocket/listener/background-process-snapshot.d.ts.map +1 -1
  54. package/dist/types/websocket/listener/queue.d.ts.map +1 -1
  55. package/dist/types/websocket/listener/runtime.d.ts.map +1 -1
  56. package/dist/types/websocket/listener/types.d.ts +5 -0
  57. package/dist/types/websocket/listener/types.d.ts.map +1 -1
  58. package/letta.js +28880 -27909
  59. package/package.json +1 -1
  60. package/scripts/source-file-size-baseline.json +2 -2
  61. package/skills/context-doctor/SKILL.md +1 -1
@@ -485,6 +485,117 @@ class ChannelControlRequestCoordinator {
485
485
  return true;
486
486
  }
487
487
  }
488
+ // src/channels/gateway-assistant-relay.ts
489
+ class GatewayAssistantTextAccumulator {
490
+ currentMessageId;
491
+ currentText;
492
+ deltaKeys;
493
+ finalizedMessageIds;
494
+ constructor(state) {
495
+ this.currentMessageId = state?.currentMessageId ?? null;
496
+ this.currentText = state?.currentText ?? "";
497
+ this.deltaKeys = new Set(state?.deltaKeys ?? []);
498
+ this.finalizedMessageIds = new Set(state?.finalizedMessageIds ?? []);
499
+ }
500
+ snapshot() {
501
+ return {
502
+ currentMessageId: this.currentMessageId,
503
+ currentText: this.currentText,
504
+ deltaKeys: [...this.deltaKeys],
505
+ finalizedMessageIds: [...this.finalizedMessageIds]
506
+ };
507
+ }
508
+ handleDelta(message) {
509
+ const delta = message.delta;
510
+ if (delta.message_type === "assistant_message") {
511
+ const messageId = delta.otid || delta.id;
512
+ if (!messageId || this.finalizedMessageIds.has(messageId))
513
+ return [];
514
+ const finalized = this.currentMessageId && this.currentMessageId !== messageId ? this.finalizeCurrent() : null;
515
+ if (!this.currentMessageId)
516
+ this.currentMessageId = messageId;
517
+ if (!this.deltaKeys.has(message.idempotency_key)) {
518
+ const text = typeof delta.content === "string" ? delta.content : delta.content.map((part) => part.text ?? "").join("");
519
+ this.deltaKeys.add(message.idempotency_key);
520
+ this.currentText += text;
521
+ }
522
+ return finalized ? [finalized] : [];
523
+ }
524
+ if (delta.message_type === "tool_call_message" || delta.message_type === "approval_request_message") {
525
+ const finalized = this.finalizeCurrent();
526
+ return finalized ? [finalized] : [];
527
+ }
528
+ if (delta.message_type === "reasoning_message") {
529
+ const boundaryId = delta.otid || delta.id;
530
+ if (this.currentMessageId && (!boundaryId || boundaryId !== this.currentMessageId)) {
531
+ const finalized = this.finalizeCurrent();
532
+ return finalized ? [finalized] : [];
533
+ }
534
+ }
535
+ return [];
536
+ }
537
+ finalizeCurrent() {
538
+ const messageId = this.currentMessageId;
539
+ if (!messageId)
540
+ return null;
541
+ const text = this.currentText.trim();
542
+ this.currentMessageId = null;
543
+ this.currentText = "";
544
+ this.finalizedMessageIds.add(messageId);
545
+ return text ? { messageId, text } : null;
546
+ }
547
+ }
548
+ function relayFinalizedAssistantMessage(options) {
549
+ if (options.sources.length !== 1 || !options.relay)
550
+ return;
551
+ const warn = (error) => {
552
+ const detail = error instanceof Error ? error.message : String(error);
553
+ console.warn(`[channels] Automatic relay failed: ${detail}`);
554
+ };
555
+ try {
556
+ const pending = options.relay({
557
+ text: options.message.text,
558
+ sources: options.sources,
559
+ idempotencyScope: options.idempotencyScope
560
+ });
561
+ if (pending)
562
+ return pending.catch(warn);
563
+ } catch (error) {
564
+ warn(error);
565
+ }
566
+ }
567
+ function stopReasonFromDelta(message) {
568
+ const delta = message.delta;
569
+ return delta.message_type === "stop_reason" && "stop_reason" in delta && typeof delta.stop_reason === "string" ? delta.stop_reason : null;
570
+ }
571
+ function runIdFromDelta(message) {
572
+ const runId = "run_id" in message.delta ? message.delta.run_id : undefined;
573
+ return typeof runId === "string" && runId.length > 0 ? runId : undefined;
574
+ }
575
+ function channelTurnOutcome(stopReason) {
576
+ if (stopReason === "cancelled")
577
+ return "cancelled";
578
+ return stopReason === "end_turn" || stopReason === "tool_rule" ? "completed" : "error";
579
+ }
580
+
581
+ // src/channels/gateway-registration-policy.ts
582
+ async function prepareGatewayRegistrationPolicy(options) {
583
+ const automaticRelay = options.override ?? (options.hooks.resolveAssistantRelayPolicy ? await options.hooks.resolveAssistantRelayPolicy(options.runtime, options.sources) : Boolean(options.hooks.relayAssistantText));
584
+ const tool = await options.hooks.buildExternalTool(options.runtime, options.sources, { automaticRelay });
585
+ return { automaticRelay, tool };
586
+ }
587
+ function modelStatusFromRuntimeStart(response, runtime) {
588
+ const agent = response.agent;
589
+ const conversation = response.conversation;
590
+ const agentModel = typeof agent?.model === "string" ? agent.model : response.agent?.llm_config?.model ?? null;
591
+ const conversationModel = typeof conversation?.model === "string" ? conversation.model : null;
592
+ const isAgentScope = runtime.conversation_id === "default";
593
+ return {
594
+ modelHandle: isAgentScope ? agentModel : conversationModel ?? agentModel,
595
+ scope: isAgentScope ? "agent" : "conversation"
596
+ };
597
+ }
598
+
488
599
  // src/channels/gateway-sources.ts
489
600
  function sourceRouteKey(source) {
490
601
  return [
@@ -514,6 +625,229 @@ function uniqueRoutedSources(sources) {
514
625
  function uniqueLifecycleSources(sources) {
515
626
  return uniqueSourcesBy(sources, sourceLifecycleKey);
516
627
  }
628
+ function channelTagsForSources(sources) {
629
+ return [...new Set(sources.map((source) => `channel:${source.channel}`))];
630
+ }
631
+
632
+ // src/channels/gateway-runtime-registration.ts
633
+ async function performGatewayRuntimeRegistration(options) {
634
+ const {
635
+ client,
636
+ hooks,
637
+ state,
638
+ delivery,
639
+ policyOverride,
640
+ externalToolsOverride
641
+ } = options;
642
+ const prepared = externalToolsOverride ? { automaticRelay: policyOverride ?? false, tool: null } : await prepareGatewayRegistrationPolicy({
643
+ hooks,
644
+ runtime: delivery.runtime,
645
+ sources: delivery.sources,
646
+ ...policyOverride === undefined ? {} : { override: policyOverride }
647
+ });
648
+ const { automaticRelay, tool } = prepared;
649
+ const externalTools = externalToolsOverride ?? (tool ? [{ tools: [tool] }] : []);
650
+ const conversationTags = channelTagsForSources(delivery.sources);
651
+ const signature = JSON.stringify({
652
+ mode: delivery.defaultPermissionMode ?? null,
653
+ automaticRelay,
654
+ externalTools,
655
+ conversationTags
656
+ });
657
+ if (state.registrationSignature === signature && state.registration) {
658
+ await state.registration;
659
+ return automaticRelay;
660
+ }
661
+ const registration = client.runtimeStart({
662
+ agent_id: delivery.runtime.agent_id,
663
+ conversation_id: delivery.runtime.conversation_id,
664
+ ...conversationTags.length > 0 ? { conversation_source_tags: conversationTags } : {},
665
+ ...delivery.defaultPermissionMode ? { mode: delivery.defaultPermissionMode } : {},
666
+ recover_approvals: true,
667
+ force_device_status: false,
668
+ wait_for_replay: true,
669
+ preserve_skill_sources: true,
670
+ client_info: { name: "channel-gateway", title: "Channel Gateway" },
671
+ external_tools: externalTools
672
+ }).then((response) => {
673
+ if (!response.success) {
674
+ throw new Error(response.error ?? "Failed to register channel runtime");
675
+ }
676
+ state.modelStatus = modelStatusFromRuntimeStart(response, delivery.runtime);
677
+ });
678
+ state.registrationSignature = signature;
679
+ state.registration = registration;
680
+ try {
681
+ await registration;
682
+ } catch (error) {
683
+ if (state.registration === registration) {
684
+ state.registration = null;
685
+ state.registrationSignature = null;
686
+ }
687
+ throw error;
688
+ }
689
+ return automaticRelay;
690
+ }
691
+
692
+ // src/channels/gateway-runtime-state.ts
693
+ var MAX_ACCEPTED_CLIENT_MESSAGE_IDS = 2048;
694
+ function gatewayRuntimeKey(runtime) {
695
+ return `${runtime.agent_id}:${runtime.conversation_id}`;
696
+ }
697
+ function hasAgentRuntime(value) {
698
+ return !!value.runtime?.agent_id;
699
+ }
700
+ function hasGatewayRuntimeWork(state) {
701
+ return Boolean(state && (state.active || state.pendingSourcesByClientMessageId.size > 0));
702
+ }
703
+ function rememberAcceptedClientMessageId(state, clientMessageId) {
704
+ state.acceptedClientMessageIds.delete(clientMessageId);
705
+ state.acceptedClientMessageIds.add(clientMessageId);
706
+ if (state.acceptedClientMessageIds.size <= MAX_ACCEPTED_CLIENT_MESSAGE_IDS)
707
+ return;
708
+ const oldest = state.acceptedClientMessageIds.values().next().value;
709
+ if (oldest)
710
+ state.acceptedClientMessageIds.delete(oldest);
711
+ }
712
+ function remainingGatewaySources(state) {
713
+ return uniqueLifecycleSources([
714
+ ...state.active?.lifecycleSources ?? [],
715
+ ...Array.from(state.pendingSourcesByClientMessageId.values()).flatMap((pending) => pending.sources)
716
+ ]);
717
+ }
718
+
719
+ // src/channels/gateway-runtime-tool-scopes.ts
720
+ var RETRY_DELAY_MS = 1000;
721
+ function runtimeKey(runtime) {
722
+ return `${runtime.agent_id}:${runtime.conversation_id}`;
723
+ }
724
+
725
+ class GatewayRuntimeToolScopes {
726
+ client;
727
+ states = new Map;
728
+ retryTimers = new Set;
729
+ closed = false;
730
+ constructor(client) {
731
+ this.client = client;
732
+ }
733
+ create(runtime, sources, automaticRelay, tool) {
734
+ const id = `channel-turn-${crypto.randomUUID()}`;
735
+ const scope = {
736
+ id,
737
+ runtime,
738
+ sources: structuredClone(sources),
739
+ automaticRelay,
740
+ ...tool ? { group: { scope_id: id, tools: [structuredClone(tool)] } } : {}
741
+ };
742
+ this.retain(scope);
743
+ return scope;
744
+ }
745
+ retain(scope) {
746
+ this.getState(scope.runtime).scopes.set(scope.id, scope);
747
+ }
748
+ detach(scope) {
749
+ this.states.get(runtimeKey(scope.runtime))?.scopes.delete(scope.id);
750
+ return scope;
751
+ }
752
+ release(scope) {
753
+ this.states.get(runtimeKey(scope.runtime))?.scopes.delete(scope.id);
754
+ }
755
+ resolve(runtime, scopeId) {
756
+ return this.states.get(runtimeKey(runtime))?.scopes.get(scopeId) ?? null;
757
+ }
758
+ selection(scope) {
759
+ return [scope.id];
760
+ }
761
+ hasRetainedScopes(runtime) {
762
+ return (this.states.get(runtimeKey(runtime))?.scopes.size ?? 0) > 0;
763
+ }
764
+ registrationGroups(runtime, includeUnscoped) {
765
+ const state = this.states.get(runtimeKey(runtime));
766
+ if (!state)
767
+ return [];
768
+ const scoped = [...state.scopes.values()].flatMap((scope) => scope.group ? [scope.group] : []);
769
+ return includeUnscoped ? [...scoped, ...state.desiredUnscoped] : scoped;
770
+ }
771
+ setDesiredUnscoped(runtime, groups) {
772
+ this.getState(runtime).desiredUnscoped = groups;
773
+ }
774
+ async updateDesiredUnscoped(updates, isBusy) {
775
+ const immediateByTools = new Map;
776
+ for (const update of updates) {
777
+ for (const runtime of update.runtimes) {
778
+ this.getState(runtime).desiredUnscoped = update.external_tools;
779
+ if (!isBusy(runtime)) {
780
+ const externalTools = this.registrationGroups(runtime, true);
781
+ const signature = JSON.stringify(externalTools);
782
+ const existing = immediateByTools.get(signature);
783
+ if (existing)
784
+ existing.runtimes.push(runtime);
785
+ else
786
+ immediateByTools.set(signature, {
787
+ runtimes: [runtime],
788
+ external_tools: externalTools
789
+ });
790
+ }
791
+ }
792
+ }
793
+ const immediate = [...immediateByTools.values()];
794
+ if (immediate.length === 0)
795
+ return;
796
+ const response = await this.client.runtimeExternalToolsUpdate({
797
+ updates: immediate
798
+ });
799
+ if (!response.success) {
800
+ throw new Error(response.error ?? "Failed to update routed runtime tools");
801
+ }
802
+ }
803
+ flushWhenIdle(runtime, isBusy, schedule) {
804
+ if (isBusy() || this.closed)
805
+ return;
806
+ schedule(async () => {
807
+ if (isBusy())
808
+ return;
809
+ const response = await this.client.runtimeExternalToolsUpdate({
810
+ updates: [
811
+ {
812
+ runtimes: [runtime],
813
+ external_tools: this.registrationGroups(runtime, true)
814
+ }
815
+ ]
816
+ });
817
+ if (!response.success) {
818
+ throw new Error(response.error ?? "Failed to update routed runtime tools");
819
+ }
820
+ }).catch(() => {
821
+ if (this.closed)
822
+ return;
823
+ const timer = setTimeout(() => {
824
+ this.retryTimers.delete(timer);
825
+ this.flushWhenIdle(runtime, isBusy, schedule);
826
+ }, RETRY_DELAY_MS);
827
+ timer.unref?.();
828
+ this.retryTimers.add(timer);
829
+ });
830
+ }
831
+ forgetRuntime(runtime) {
832
+ this.states.delete(runtimeKey(runtime));
833
+ }
834
+ close() {
835
+ this.closed = true;
836
+ for (const timer of this.retryTimers)
837
+ clearTimeout(timer);
838
+ this.retryTimers.clear();
839
+ this.states.clear();
840
+ }
841
+ getState(runtime) {
842
+ const key = runtimeKey(runtime);
843
+ let state = this.states.get(key);
844
+ if (!state) {
845
+ state = { runtime, scopes: new Map, desiredUnscoped: [] };
846
+ this.states.set(key, state);
847
+ }
848
+ return state;
849
+ }
850
+ }
517
851
 
518
852
  // src/channels/message-channel-idempotency.ts
519
853
  class MessageChannelDuplicateActionError extends Error {
@@ -526,38 +860,108 @@ class MessageChannelDuplicateActionError extends Error {
526
860
  function isErrorResult(result) {
527
861
  return result.startsWith("Error:");
528
862
  }
529
- function createMessageChannelIdempotencyScope() {
530
- const inFlight = new Map;
531
- let lastSuccessful = null;
863
+ function createMessageChannelIdempotencyScope(state) {
864
+ const inFlightActionKeys = new Map;
865
+ const inFlightTextDeliveryKeys = new Map;
866
+ const inFlightRelayTextDeliveryKeys = new Set;
867
+ const successfulActionKeys = new Set(state?.successfulActionKeys ?? []);
868
+ const successfulTextDeliveryKeys = new Set(state?.successfulTextDeliveryKeys ?? []);
869
+ const successfulRelayTextDeliveryKeys = new Set(state?.successfulRelayTextDeliveryKeys ?? []);
870
+ let lastSuccessfulActionKey = state?.lastSuccessfulActionKey ?? null;
532
871
  let latestInvocation = 0;
872
+ const addInFlightTextDelivery = (key) => {
873
+ if (key) {
874
+ inFlightTextDeliveryKeys.set(key, (inFlightTextDeliveryKeys.get(key) ?? 0) + 1);
875
+ }
876
+ };
877
+ const removeInFlightTextDelivery = (key) => {
878
+ if (!key)
879
+ return;
880
+ const remaining = (inFlightTextDeliveryKeys.get(key) ?? 1) - 1;
881
+ if (remaining > 0)
882
+ inFlightTextDeliveryKeys.set(key, remaining);
883
+ else
884
+ inFlightTextDeliveryKeys.delete(key);
885
+ };
533
886
  return {
534
- async execute(key, effect) {
535
- if (!key) {
887
+ async execute(actionKey, textDeliveryKey, effect) {
888
+ if (!actionKey) {
536
889
  latestInvocation += 1;
537
- lastSuccessful = null;
890
+ lastSuccessfulActionKey = null;
538
891
  return await effect();
539
892
  }
540
- const pendingDuplicate = inFlight.get(key);
541
- if (pendingDuplicate) {
893
+ if (inFlightActionKeys.has(actionKey) || textDeliveryKey && inFlightRelayTextDeliveryKeys.has(textDeliveryKey)) {
542
894
  throw new MessageChannelDuplicateActionError("in-flight");
543
895
  }
544
- if (lastSuccessful?.key === key) {
896
+ if (lastSuccessfulActionKey === actionKey || textDeliveryKey && successfulRelayTextDeliveryKeys.has(textDeliveryKey)) {
545
897
  throw new MessageChannelDuplicateActionError("completed");
546
898
  }
547
899
  const invocation = ++latestInvocation;
548
- lastSuccessful = null;
900
+ lastSuccessfulActionKey = null;
549
901
  const pending = Promise.resolve().then(effect);
550
- inFlight.set(key, pending);
902
+ inFlightActionKeys.set(actionKey, pending);
903
+ addInFlightTextDelivery(textDeliveryKey);
551
904
  try {
552
905
  const result = await pending;
553
- if (invocation === latestInvocation && !isErrorResult(result)) {
554
- lastSuccessful = { key };
906
+ if (!isErrorResult(result)) {
907
+ successfulActionKeys.add(actionKey);
908
+ if (textDeliveryKey)
909
+ successfulTextDeliveryKeys.add(textDeliveryKey);
910
+ if (invocation === latestInvocation) {
911
+ lastSuccessfulActionKey = actionKey;
912
+ }
555
913
  }
556
914
  return result;
557
915
  } finally {
558
- if (inFlight.get(key) === pending)
559
- inFlight.delete(key);
916
+ if (inFlightActionKeys.get(actionKey) === pending) {
917
+ inFlightActionKeys.delete(actionKey);
918
+ }
919
+ removeInFlightTextDelivery(textDeliveryKey);
560
920
  }
921
+ },
922
+ async executeRelay(actionKey, textDeliveryKey, effect) {
923
+ if (!actionKey)
924
+ return await effect();
925
+ if (inFlightActionKeys.has(actionKey) || textDeliveryKey && inFlightTextDeliveryKeys.has(textDeliveryKey)) {
926
+ throw new MessageChannelDuplicateActionError("in-flight");
927
+ }
928
+ if (successfulActionKeys.has(actionKey) || textDeliveryKey && successfulTextDeliveryKeys.has(textDeliveryKey)) {
929
+ throw new MessageChannelDuplicateActionError("completed");
930
+ }
931
+ const pending = Promise.resolve().then(effect);
932
+ inFlightActionKeys.set(actionKey, pending);
933
+ addInFlightTextDelivery(textDeliveryKey);
934
+ if (textDeliveryKey)
935
+ inFlightRelayTextDeliveryKeys.add(textDeliveryKey);
936
+ try {
937
+ const result = await pending;
938
+ if (!isErrorResult(result)) {
939
+ successfulActionKeys.add(actionKey);
940
+ if (textDeliveryKey) {
941
+ successfulTextDeliveryKeys.add(textDeliveryKey);
942
+ successfulRelayTextDeliveryKeys.add(textDeliveryKey);
943
+ }
944
+ }
945
+ return result;
946
+ } finally {
947
+ if (inFlightActionKeys.get(actionKey) === pending) {
948
+ inFlightActionKeys.delete(actionKey);
949
+ }
950
+ removeInFlightTextDelivery(textDeliveryKey);
951
+ if (textDeliveryKey)
952
+ inFlightRelayTextDeliveryKeys.delete(textDeliveryKey);
953
+ }
954
+ },
955
+ snapshot() {
956
+ if (inFlightActionKeys.size > 0 || inFlightTextDeliveryKeys.size > 0) {
957
+ return null;
958
+ }
959
+ return {
960
+ successfulActionKeys: [...successfulActionKeys],
961
+ successfulTextDeliveryKeys: [...successfulTextDeliveryKeys],
962
+ successfulRelayTextDeliveryKeys: [...successfulRelayTextDeliveryKeys],
963
+ lastSuccessfulActionKey
964
+ };
561
965
  }
562
966
  };
563
967
  }
@@ -1344,50 +1748,40 @@ function createChannelTurnProgressBuilder(options = {}) {
1344
1748
  }
1345
1749
 
1346
1750
  // src/channels/gateway-core.ts
1347
- var MAX_ACCEPTED_CLIENT_MESSAGE_IDS = 2048;
1348
- function runtimeKey(runtime) {
1349
- return `${runtime.agent_id}:${runtime.conversation_id}`;
1350
- }
1351
- function hasAgentRuntime(value) {
1352
- return !!value.runtime?.agent_id;
1353
- }
1354
- function channelTagsForSources(sources) {
1355
- return [...new Set(sources.map((source) => `channel:${source.channel}`))];
1356
- }
1357
- function stopReasonFromDelta(message) {
1358
- const delta = message.delta;
1359
- return delta.message_type === "stop_reason" && "stop_reason" in delta && typeof delta.stop_reason === "string" ? delta.stop_reason : null;
1360
- }
1361
- function runIdFromDelta(message) {
1362
- const runId = "run_id" in message.delta ? message.delta.run_id : undefined;
1363
- return typeof runId === "string" && runId.length > 0 ? runId : undefined;
1364
- }
1365
- function lifecycleOutcome(stopReason) {
1366
- if (stopReason === "cancelled")
1367
- return "cancelled";
1368
- if (stopReason === "end_turn" || stopReason === "tool_rule") {
1369
- return "completed";
1370
- }
1371
- return "error";
1372
- }
1373
-
1374
1751
  class ChannelGateway {
1375
1752
  client;
1376
1753
  hooks;
1377
1754
  states = new Map;
1378
1755
  disposers = [];
1756
+ runtimeToolScopes;
1379
1757
  registrationQueue = Promise.resolve();
1380
1758
  constructor(client, hooks) {
1381
1759
  this.client = client;
1382
1760
  this.hooks = hooks;
1383
- this.disposers.push(client.onMessage((message) => this.handleMessage(message)), client.onExternalToolCall((request) => {
1384
- const state = hasAgentRuntime(request) ? this.states.get(runtimeKey(request.runtime)) : undefined;
1761
+ this.runtimeToolScopes = new GatewayRuntimeToolScopes(client);
1762
+ this.disposers.push(client.onMessage((message) => this.handleMessage(message)), client.onExternalToolCall(async (request) => {
1763
+ const state = hasAgentRuntime(request) ? this.states.get(gatewayRuntimeKey(request.runtime)) : undefined;
1385
1764
  const active = state?.active;
1386
- const sources = active?.routingSources ?? state?.routedSources ?? [];
1387
- return hooks.executeExternalTool(request, sources, active?.idempotencyScope ?? null);
1765
+ const scoped = hasAgentRuntime(request) && request.scope_id ? this.runtimeToolScopes.resolve(request.runtime, request.scope_id) : null;
1766
+ if (request.scope_id && !scoped) {
1767
+ throw new Error(`Unknown or stale external tool scope: ${request.scope_id}`);
1768
+ }
1769
+ const sources = scoped?.sources ?? active?.routingSources ?? state?.routedSources ?? [];
1770
+ const activeOwnsScope = Boolean(scoped && active?.toolScopes.some((scope) => scope.id === scoped.id));
1771
+ try {
1772
+ return await hooks.executeExternalTool(request, sources, activeOwnsScope || !request.scope_id && active ? active?.idempotencyScope : null);
1773
+ } catch (error) {
1774
+ if (!(error instanceof MessageChannelDuplicateActionError))
1775
+ throw error;
1776
+ return {
1777
+ content: [{ type: "text", text: error.message }],
1778
+ is_error: true
1779
+ };
1780
+ }
1388
1781
  }));
1389
1782
  }
1390
1783
  close() {
1784
+ this.runtimeToolScopes.close();
1391
1785
  for (const dispose of this.disposers.splice(0))
1392
1786
  dispose();
1393
1787
  this.client.close();
@@ -1412,16 +1806,34 @@ class ChannelGateway {
1412
1806
  const workAtSubmit = state.active || state.pendingSourcesByClientMessageId.size > 0;
1413
1807
  state.pendingSourcesByClientMessageId.set(delivery.clientMessageId, {
1414
1808
  sources: uniqueLifecycleSources(delivery.sources),
1415
- disposition: "submitting"
1809
+ disposition: "submitting",
1810
+ automaticRelay: false
1416
1811
  });
1417
1812
  try {
1418
- await this.enqueueRegistration(async () => {
1813
+ const toolScope = await this.enqueueRegistration(async () => {
1419
1814
  state.routedSources = uniqueRoutedSources([
1420
1815
  ...state.routedSources,
1421
1816
  ...delivery.sources
1422
1817
  ]);
1423
- await this.performRuntimeRegistration(state, delivery);
1818
+ const { automaticRelay, tool } = await prepareGatewayRegistrationPolicy({
1819
+ hooks: this.hooks,
1820
+ runtime: delivery.runtime,
1821
+ sources: delivery.sources
1822
+ });
1823
+ const scope = this.runtimeToolScopes.create(delivery.runtime, delivery.sources, automaticRelay, tool);
1824
+ try {
1825
+ await this.performRuntimeRegistration(state, delivery, automaticRelay, this.runtimeToolScopes.registrationGroups(delivery.runtime, false));
1826
+ return scope;
1827
+ } catch (error) {
1828
+ this.runtimeToolScopes.release(scope);
1829
+ throw error;
1830
+ }
1424
1831
  });
1832
+ const submitted = state.pendingSourcesByClientMessageId.get(delivery.clientMessageId);
1833
+ if (submitted) {
1834
+ submitted.automaticRelay = toolScope.automaticRelay;
1835
+ submitted.toolScope = toolScope;
1836
+ }
1425
1837
  const response = await this.client.submitInput({
1426
1838
  runtime: delivery.runtime,
1427
1839
  payload: {
@@ -1434,21 +1846,24 @@ class ChannelGateway {
1434
1846
  }
1435
1847
  ],
1436
1848
  image_failure_mode: "drop",
1437
- client_preferences: {}
1849
+ client_preferences: {},
1850
+ external_tool_scope_ids: this.runtimeToolScopes.selection(toolScope)
1438
1851
  }
1439
1852
  });
1440
1853
  if (!response.accepted) {
1441
1854
  state.pendingSourcesByClientMessageId.delete(delivery.clientMessageId);
1855
+ this.runtimeToolScopes.release(toolScope);
1856
+ this.finishToolScopesIfIdle(state);
1442
1857
  if (workAtSubmit && !state.active)
1443
1858
  this.finishRejectedDelivery(state, delivery);
1444
1859
  return false;
1445
1860
  }
1446
- this.rememberAcceptedClientMessageId(state, delivery.clientMessageId);
1861
+ rememberAcceptedClientMessageId(state, delivery.clientMessageId);
1447
1862
  for (const source of delivery.sources) {
1448
1863
  this.enqueueHook(state, () => this.hooks.onLifecycle({ type: "queued", source }));
1449
1864
  }
1450
1865
  if (response.disposition === "started") {
1451
- this.activateSources(state, delivery.clientMessageId, delivery.sources);
1866
+ this.activateSources(state, delivery.clientMessageId, delivery.sources, toolScope.automaticRelay, toolScope);
1452
1867
  state.pendingSourcesByClientMessageId.delete(delivery.clientMessageId);
1453
1868
  } else if (response.disposition === "queued") {
1454
1869
  const pending = state.pendingSourcesByClientMessageId.get(delivery.clientMessageId);
@@ -1459,20 +1874,19 @@ class ChannelGateway {
1459
1874
  }
1460
1875
  return true;
1461
1876
  } catch (error) {
1877
+ const failed = state.pendingSourcesByClientMessageId.get(delivery.clientMessageId);
1462
1878
  state.pendingSourcesByClientMessageId.delete(delivery.clientMessageId);
1879
+ if (failed?.toolScope) {
1880
+ this.runtimeToolScopes.release(failed.toolScope);
1881
+ }
1882
+ this.finishToolScopesIfIdle(state);
1463
1883
  if (workAtSubmit && !state.active)
1464
1884
  this.finishRejectedDelivery(state, delivery);
1465
1885
  throw error;
1466
1886
  }
1467
1887
  }
1468
- remainingSources(state) {
1469
- return uniqueLifecycleSources([
1470
- ...state.active?.lifecycleSources ?? [],
1471
- ...Array.from(state.pendingSourcesByClientMessageId.values()).flatMap((pending) => pending.sources)
1472
- ]);
1473
- }
1474
1888
  finishRejectedDelivery(state, delivery) {
1475
- const remainingSources = this.remainingSources(state);
1889
+ const remainingSources = remainingGatewaySources(state);
1476
1890
  this.enqueueHook(state, () => this.hooks.onLifecycle({
1477
1891
  type: "finished",
1478
1892
  batchId: `channel-${delivery.clientMessageId}`,
@@ -1497,13 +1911,14 @@ class ChannelGateway {
1497
1911
  }
1498
1912
  state.pendingSourcesByClientMessageId.set(delivery.clientMessageId, {
1499
1913
  sources,
1500
- disposition: "queued"
1914
+ disposition: "queued",
1915
+ automaticRelay: false
1501
1916
  });
1502
1917
  state.routedSources = uniqueRoutedSources([
1503
1918
  ...state.routedSources,
1504
1919
  ...delivery.sources
1505
1920
  ]);
1506
- this.rememberAcceptedClientMessageId(state, delivery.clientMessageId);
1921
+ rememberAcceptedClientMessageId(state, delivery.clientMessageId);
1507
1922
  }
1508
1923
  async restoreRuntime(runtime, sources) {
1509
1924
  const state = this.getState(runtime);
@@ -1516,7 +1931,10 @@ class ChannelGateway {
1516
1931
  lifecycleSources: uniqueLifecycleSources(sources),
1517
1932
  progress: createChannelTurnProgressBuilder(),
1518
1933
  richDraft: null,
1519
- idempotencyScope: createMessageChannelIdempotencyScope()
1934
+ assistantText: new GatewayAssistantTextAccumulator,
1935
+ idempotencyScope: createMessageChannelIdempotencyScope(),
1936
+ relayEligible: false,
1937
+ toolScopes: []
1520
1938
  };
1521
1939
  state.active = recoveredTurn;
1522
1940
  }
@@ -1534,7 +1952,7 @@ class ChannelGateway {
1534
1952
  return replayedRequestIds;
1535
1953
  }
1536
1954
  async adoptActiveDelivery(delivery) {
1537
- const key = runtimeKey(delivery.runtime);
1955
+ const key = gatewayRuntimeKey(delivery.runtime);
1538
1956
  const stateExisted = this.states.has(key);
1539
1957
  const state = this.getState(delivery.runtime);
1540
1958
  const batchId = `channel-${delivery.clientMessageId}`;
@@ -1543,8 +1961,8 @@ class ChannelGateway {
1543
1961
  if (state.active.batchId !== batchId) {
1544
1962
  throw new Error(`Cannot adopt ${batchId}; ${state.active.batchId} is already active`);
1545
1963
  }
1546
- this.activateSources(state, delivery.clientMessageId, delivery.sources);
1547
- this.rememberAcceptedClientMessageId(state, delivery.clientMessageId);
1964
+ this.activateSources(state, delivery.clientMessageId, delivery.sources, state.active.relayEligible);
1965
+ rememberAcceptedClientMessageId(state, delivery.clientMessageId);
1548
1966
  return;
1549
1967
  }
1550
1968
  const previousRoutedSources = state.routedSources;
@@ -1556,19 +1974,21 @@ class ChannelGateway {
1556
1974
  lifecycleSources: uniqueLifecycleSources(delivery.sources),
1557
1975
  progress: createChannelTurnProgressBuilder(),
1558
1976
  richDraft: null,
1559
- idempotencyScope: createMessageChannelIdempotencyScope()
1977
+ assistantText: new GatewayAssistantTextAccumulator(delivery.activeTurnState?.assistantText),
1978
+ idempotencyScope: createMessageChannelIdempotencyScope(delivery.activeTurnState?.idempotency),
1979
+ relayEligible: delivery.activeTurnState?.automaticRelay ?? Boolean(delivery.activeTurnState),
1980
+ toolScopes: delivery.activeTurnState?.toolScopes ?? []
1560
1981
  };
1561
1982
  state.active = active;
1983
+ for (const scope of active.toolScopes)
1984
+ this.runtimeToolScopes.retain(scope);
1562
1985
  state.routedSources = uniqueRoutedSources([
1563
1986
  ...state.routedSources,
1564
1987
  ...delivery.sources
1565
1988
  ]);
1566
- this.rememberAcceptedClientMessageId(state, delivery.clientMessageId);
1989
+ rememberAcceptedClientMessageId(state, delivery.clientMessageId);
1567
1990
  try {
1568
- await this.performRuntimeRegistration(state, {
1569
- ...delivery,
1570
- content: ""
1571
- });
1991
+ await this.performRuntimeRegistration(state, { ...delivery, content: "" }, active.relayEligible, this.runtimeToolScopes.registrationGroups(delivery.runtime, false));
1572
1992
  if (state.active !== active)
1573
1993
  return;
1574
1994
  active.richDraft = this.hooks.createRichDraft?.({
@@ -1582,6 +2002,9 @@ class ChannelGateway {
1582
2002
  }));
1583
2003
  } catch (error) {
1584
2004
  active.richDraft?.dispose();
2005
+ for (const scope of active.toolScopes) {
2006
+ this.runtimeToolScopes.release(scope);
2007
+ }
1585
2008
  if (state.active === active)
1586
2009
  state.active = null;
1587
2010
  state.routedSources = previousRoutedSources;
@@ -1595,51 +2018,67 @@ class ChannelGateway {
1595
2018
  });
1596
2019
  }
1597
2020
  releaseActiveDelivery(runtime, clientMessageId) {
1598
- const key = runtimeKey(runtime);
2021
+ const key = gatewayRuntimeKey(runtime);
1599
2022
  const state = this.states.get(key);
1600
2023
  const active = state?.active;
1601
2024
  if (!state || active?.batchId !== `channel-${clientMessageId}`) {
1602
- return false;
2025
+ return null;
1603
2026
  }
2027
+ if (state.hookQueue)
2028
+ return null;
2029
+ const idempotency = active.idempotencyScope.snapshot();
2030
+ if (!idempotency)
2031
+ return null;
2032
+ const toolScopes = active.toolScopes.map((scope) => this.runtimeToolScopes.detach(scope));
2033
+ const handoffState = {
2034
+ assistantText: active.assistantText.snapshot(),
2035
+ idempotency,
2036
+ automaticRelay: active.relayEligible,
2037
+ ...toolScopes.length > 0 ? { toolScopes } : {}
2038
+ };
1604
2039
  active.richDraft?.dispose();
1605
2040
  this.states.delete(key);
1606
- return true;
2041
+ return handoffState;
1607
2042
  }
1608
2043
  async registerRuntime(runtime, sources = [], defaultPermissionMode) {
1609
2044
  const state = this.getState(runtime);
1610
2045
  await this.enqueueRegistration(async () => {
1611
2046
  this.setRoutedSources(runtime, sources);
2047
+ const prepared = await prepareGatewayRegistrationPolicy({
2048
+ hooks: this.hooks,
2049
+ runtime,
2050
+ sources
2051
+ });
2052
+ const externalTools = prepared.tool ? [{ tools: [prepared.tool] }] : [];
2053
+ this.runtimeToolScopes.setDesiredUnscoped(runtime, externalTools);
2054
+ if (this.runtimeToolScopes.hasRetainedScopes(runtime))
2055
+ return;
1612
2056
  await this.performRuntimeRegistration(state, {
1613
2057
  runtime,
1614
2058
  content: "",
1615
2059
  sources,
1616
2060
  clientMessageId: "recovered",
1617
2061
  ...defaultPermissionMode ? { defaultPermissionMode } : {}
1618
- });
2062
+ }, prepared.automaticRelay, externalTools);
1619
2063
  });
1620
2064
  }
1621
2065
  async publishRuntimeTools(runtime, sources = []) {
1622
2066
  return await this.enqueueRegistration(async () => {
1623
- if (this.states.has(runtimeKey(runtime)))
2067
+ if (this.states.has(gatewayRuntimeKey(runtime)))
1624
2068
  return false;
1625
2069
  const tool = await this.hooks.buildExternalTool(runtime, sources);
1626
- const response = await this.client.runtimeExternalToolsUpdate({
1627
- updates: [
1628
- {
1629
- runtimes: [runtime],
1630
- external_tools: tool ? [{ tools: [tool] }] : []
1631
- }
1632
- ]
1633
- });
1634
- if (!response.success) {
1635
- throw new Error(response.error ?? "Failed to publish channel runtime tools");
1636
- }
2070
+ await this.runtimeToolScopes.updateDesiredUnscoped([
2071
+ {
2072
+ runtimes: [runtime],
2073
+ external_tools: tool ? [{ tools: [tool] }] : []
2074
+ }
2075
+ ], () => false);
1637
2076
  return tool !== null;
1638
2077
  });
1639
2078
  }
1640
2079
  async releaseRuntimeTools(runtime, routedSources = [], options = {}) {
1641
2080
  await this.enqueueRegistration(async () => {
1642
- const key = runtimeKey(runtime);
2081
+ const key = gatewayRuntimeKey(runtime);
1643
2082
  const state = this.states.get(key);
1644
2083
  if (options.cleanupIdleRuntime) {
1645
2084
  if (routedSources.length > 0 || (state?.routedSources.length ?? 0) > 0) {
@@ -1661,6 +2100,7 @@ class ChannelGateway {
1661
2100
  if (!response.success) {
1662
2101
  throw new Error(response.error ?? "Failed to release channel runtime tools");
1663
2102
  }
2103
+ this.runtimeToolScopes.forgetRuntime(runtime);
1664
2104
  });
1665
2105
  }
1666
2106
  async submitApprovalResponse(runtime, response) {
@@ -1678,21 +2118,14 @@ class ChannelGateway {
1678
2118
  }
1679
2119
  updateRoutedRuntimeTools(updates, routedSources) {
1680
2120
  return this.enqueueRegistration(async () => {
1681
- if (updates.length > 0) {
1682
- const response = await this.client.runtimeExternalToolsUpdate({
1683
- updates
1684
- });
1685
- if (!response.success) {
1686
- throw new Error(response.error ?? "Failed to update routed runtime tools");
1687
- }
1688
- }
2121
+ await this.runtimeToolScopes.updateDesiredUnscoped(updates, (runtime) => hasGatewayRuntimeWork(this.states.get(gatewayRuntimeKey(runtime))));
1689
2122
  for (const update of routedSources) {
1690
2123
  this.setRoutedSources(update.runtime, update.sources);
1691
2124
  }
1692
2125
  });
1693
2126
  }
1694
2127
  getModelStatus(runtime) {
1695
- return this.states.get(runtimeKey(runtime))?.modelStatus ?? null;
2128
+ return this.states.get(gatewayRuntimeKey(runtime))?.modelStatus ?? null;
1696
2129
  }
1697
2130
  updateModelStatus(runtime, modelHandle) {
1698
2131
  const state = this.getState(runtime);
@@ -1702,7 +2135,7 @@ class ChannelGateway {
1702
2135
  };
1703
2136
  }
1704
2137
  getState(runtime) {
1705
- const key = runtimeKey(runtime);
2138
+ const key = gatewayRuntimeKey(runtime);
1706
2139
  let state = this.states.get(key);
1707
2140
  if (!state) {
1708
2141
  state = {
@@ -1722,14 +2155,10 @@ class ChannelGateway {
1722
2155
  }
1723
2156
  return state;
1724
2157
  }
1725
- rememberAcceptedClientMessageId(state, clientMessageId) {
1726
- state.acceptedClientMessageIds.delete(clientMessageId);
1727
- state.acceptedClientMessageIds.add(clientMessageId);
1728
- if (state.acceptedClientMessageIds.size <= MAX_ACCEPTED_CLIENT_MESSAGE_IDS)
2158
+ finishToolScopesIfIdle(state) {
2159
+ if (state.active || state.pendingSourcesByClientMessageId.size > 0)
1729
2160
  return;
1730
- const oldest = state.acceptedClientMessageIds.values().next().value;
1731
- if (oldest)
1732
- state.acceptedClientMessageIds.delete(oldest);
2161
+ this.runtimeToolScopes.flushWhenIdle(state.runtime, () => hasGatewayRuntimeWork(this.states.get(gatewayRuntimeKey(state.runtime))), (task) => this.enqueueRegistration(task));
1733
2162
  }
1734
2163
  enqueueHook(state, hook) {
1735
2164
  let pending;
@@ -1763,52 +2192,15 @@ class ChannelGateway {
1763
2192
  });
1764
2193
  return result;
1765
2194
  }
1766
- async performRuntimeRegistration(state, delivery) {
1767
- const tool = await this.hooks.buildExternalTool(delivery.runtime, delivery.sources);
1768
- const conversationTags = channelTagsForSources(delivery.sources);
1769
- const signature = JSON.stringify({
1770
- mode: delivery.defaultPermissionMode ?? null,
1771
- tool,
1772
- conversationTags
2195
+ async performRuntimeRegistration(state, delivery, policyOverride, externalToolsOverride) {
2196
+ return await performGatewayRuntimeRegistration({
2197
+ client: this.client,
2198
+ hooks: this.hooks,
2199
+ state,
2200
+ delivery,
2201
+ ...policyOverride === undefined ? {} : { policyOverride },
2202
+ ...externalToolsOverride === undefined ? {} : { externalToolsOverride }
1773
2203
  });
1774
- if (state.registrationSignature === signature && state.registration) {
1775
- return state.registration;
1776
- }
1777
- const registration = this.client.runtimeStart({
1778
- agent_id: delivery.runtime.agent_id,
1779
- conversation_id: delivery.runtime.conversation_id,
1780
- ...conversationTags.length > 0 ? { conversation_source_tags: conversationTags } : {},
1781
- ...delivery.defaultPermissionMode ? { mode: delivery.defaultPermissionMode } : {},
1782
- recover_approvals: true,
1783
- force_device_status: false,
1784
- wait_for_replay: true,
1785
- preserve_skill_sources: true,
1786
- client_info: { name: "channel-gateway", title: "Channel Gateway" },
1787
- external_tools: tool ? [{ tools: [tool] }] : []
1788
- }).then((response) => {
1789
- if (!response.success) {
1790
- throw new Error(response.error ?? "Failed to register channel runtime");
1791
- }
1792
- const agentRecord = response.agent;
1793
- const conversationRecord = response.conversation;
1794
- const agentModel = typeof agentRecord?.model === "string" ? agentRecord.model : response.agent?.llm_config?.model ?? null;
1795
- const conversationModel = typeof conversationRecord?.model === "string" ? conversationRecord.model : null;
1796
- state.modelStatus = {
1797
- modelHandle: delivery.runtime.conversation_id === "default" ? agentModel : conversationModel ?? agentModel,
1798
- scope: delivery.runtime.conversation_id === "default" ? "agent" : "conversation"
1799
- };
1800
- });
1801
- state.registrationSignature = signature;
1802
- state.registration = registration;
1803
- try {
1804
- await registration;
1805
- } catch (error) {
1806
- if (state.registration === registration) {
1807
- state.registration = null;
1808
- state.registrationSignature = null;
1809
- }
1810
- throw error;
1811
- }
1812
2204
  }
1813
2205
  handleMessage(message) {
1814
2206
  if (message.type === "update_queue") {
@@ -1856,15 +2248,25 @@ class ChannelGateway {
1856
2248
  continue;
1857
2249
  }
1858
2250
  const target = pending.removalDisposition === "dequeued" ? dequeued : cancelled;
1859
- target.push({ clientMessageId, sources: pending.sources });
2251
+ target.push({
2252
+ clientMessageId,
2253
+ sources: pending.sources,
2254
+ automaticRelay: pending.automaticRelay,
2255
+ ...pending.toolScope ? { toolScope: pending.toolScope } : {}
2256
+ });
1860
2257
  state.pendingSourcesByClientMessageId.delete(clientMessageId);
1861
2258
  }
1862
2259
  const firstDequeued = dequeued[0];
1863
2260
  if (firstDequeued) {
1864
- this.activateSources(state, firstDequeued.clientMessageId, dequeued.flatMap((entry) => entry.sources));
2261
+ const dequeuedScopes = dequeued.flatMap((entry) => entry.toolScope ? [entry.toolScope] : []);
2262
+ this.activateSources(state, firstDequeued.clientMessageId, dequeued.flatMap((entry) => entry.sources), dequeued.every((entry) => entry.automaticRelay), dequeuedScopes[0]);
2263
+ if (state.active)
2264
+ state.active.toolScopes.push(...dequeuedScopes.slice(1));
1865
2265
  }
1866
2266
  for (const entry of cancelled) {
1867
- const remainingSources = this.remainingSources(state);
2267
+ if (entry.toolScope)
2268
+ this.runtimeToolScopes.release(entry.toolScope);
2269
+ const remainingSources = remainingGatewaySources(state);
1868
2270
  this.enqueueHook(state, () => this.hooks.onLifecycle({
1869
2271
  type: "finished",
1870
2272
  batchId: `channel-${entry.clientMessageId}`,
@@ -1874,9 +2276,12 @@ class ChannelGateway {
1874
2276
  ...remainingSources.length ? { remainingSources } : {}
1875
2277
  }));
1876
2278
  }
2279
+ this.finishToolScopesIfIdle(state);
1877
2280
  }
1878
- activateSources(state, clientMessageId, sources) {
2281
+ activateSources(state, clientMessageId, sources, automaticRelay, toolScope) {
1879
2282
  if (state.active) {
2283
+ if (toolScope)
2284
+ state.active.toolScopes.push(toolScope);
1880
2285
  const knownLifecycleKeys = new Set(state.active.lifecycleSources.map(sourceLifecycleKey));
1881
2286
  const addedLifecycleSources = uniqueLifecycleSources(sources).filter((source) => !knownLifecycleKeys.has(sourceLifecycleKey(source)));
1882
2287
  if (addedLifecycleSources.length === 0)
@@ -1889,6 +2294,7 @@ class ChannelGateway {
1889
2294
  ...state.active.routingSources,
1890
2295
  ...sources
1891
2296
  ]);
2297
+ state.active.relayEligible &&= automaticRelay;
1892
2298
  const processingEvent2 = {
1893
2299
  type: "processing",
1894
2300
  batchId: state.active.batchId,
@@ -1908,7 +2314,10 @@ class ChannelGateway {
1908
2314
  batchId: `channel-${clientMessageId}`,
1909
2315
  sources: routingSources
1910
2316
  }) ?? null,
1911
- idempotencyScope: createMessageChannelIdempotencyScope()
2317
+ assistantText: new GatewayAssistantTextAccumulator,
2318
+ idempotencyScope: createMessageChannelIdempotencyScope(),
2319
+ relayEligible: automaticRelay,
2320
+ toolScopes: toolScope ? [toolScope] : []
1912
2321
  };
1913
2322
  const processingEvent = {
1914
2323
  type: "processing",
@@ -1927,6 +2336,7 @@ class ChannelGateway {
1927
2336
  const runId = runIdFromDelta(message);
1928
2337
  if (runId)
1929
2338
  active.runId = runId;
2339
+ this.enqueueFinalizedAssistantMessages(state, active, active.assistantText.handleDelta(message));
1930
2340
  for (const update of active.progress.buildUpdates(message.delta)) {
1931
2341
  this.enqueueHook(state, () => this.hooks.onProgress({
1932
2342
  type: "progress",
@@ -1937,23 +2347,46 @@ class ChannelGateway {
1937
2347
  }
1938
2348
  active.richDraft?.handleDelta(message.delta);
1939
2349
  const stopReason = stopReasonFromDelta(message);
1940
- if (stopReason === "requires_approval" || stopReason === "end_turn") {
2350
+ if (stopReason === "requires_approval" || stopReason === "end_turn" || stopReason === "tool_rule") {
1941
2351
  active.richDraft?.flushPending();
2352
+ const finalized = active.assistantText.finalizeCurrent();
2353
+ if (finalized) {
2354
+ this.enqueueFinalizedAssistantMessages(state, active, [finalized]);
2355
+ }
1942
2356
  }
1943
2357
  }
2358
+ enqueueFinalizedAssistantMessages(state, active, messages) {
2359
+ const relay = active.relayEligible ? this.hooks.relayAssistantText : undefined;
2360
+ for (const message of messages)
2361
+ this.enqueueHook(state, () => relayFinalizedAssistantMessage({
2362
+ message,
2363
+ sources: active.routingSources,
2364
+ idempotencyScope: active.idempotencyScope,
2365
+ relay
2366
+ }));
2367
+ }
1944
2368
  handleTurnFinished(runtime, terminal) {
1945
2369
  const state = this.getState(runtime);
1946
2370
  const active = state.active;
1947
2371
  if (!active)
1948
2372
  return;
2373
+ if (terminal.stopReason === "end_turn" || terminal.stopReason === "tool_rule") {
2374
+ const finalized = active.assistantText.finalizeCurrent();
2375
+ if (finalized) {
2376
+ this.enqueueFinalizedAssistantMessages(state, active, [finalized]);
2377
+ }
2378
+ }
1949
2379
  state.active = null;
1950
2380
  active.richDraft?.dispose();
1951
- const remainingSources = lifecycleOutcome(terminal.stopReason) === "completed" ? this.remainingSources(state) : [];
2381
+ for (const scope of active.toolScopes)
2382
+ this.runtimeToolScopes.release(scope);
2383
+ this.finishToolScopesIfIdle(state);
2384
+ const remainingSources = channelTurnOutcome(terminal.stopReason) === "completed" ? remainingGatewaySources(state) : [];
1952
2385
  this.enqueueHook(state, () => this.hooks.onLifecycle({
1953
2386
  type: "finished",
1954
2387
  batchId: active.batchId,
1955
2388
  sources: active.lifecycleSources,
1956
- outcome: lifecycleOutcome(terminal.stopReason),
2389
+ outcome: channelTurnOutcome(terminal.stopReason),
1957
2390
  stopReason: terminal.stopReason,
1958
2391
  ...remainingSources.length ? { remainingSources } : {},
1959
2392
  ...terminal.runId ?? active.runId ? { runId: terminal.runId ?? active.runId } : {},
@@ -1963,7 +2396,7 @@ class ChannelGateway {
1963
2396
  handleControlRequest(message) {
1964
2397
  if (!message.agent_id || !message.conversation_id)
1965
2398
  return;
1966
- const state = this.states.get(runtimeKey({
2399
+ const state = this.states.get(gatewayRuntimeKey({
1967
2400
  agent_id: message.agent_id,
1968
2401
  conversation_id: message.conversation_id
1969
2402
  }));
@@ -2703,6 +3136,20 @@ function messageIdempotencyKey(request, route) {
2703
3136
  replyToMessageId: effectiveTextReplyId(request, route)
2704
3137
  });
2705
3138
  }
3139
+ function textDeliveryFingerprint(request, route) {
3140
+ if (request.action !== "send" && request.action !== "send-rich" || request.mediaPath) {
3141
+ return null;
3142
+ }
3143
+ return JSON.stringify({
3144
+ channel: request.channel,
3145
+ chatId: route.chatId,
3146
+ accountId: route.accountId ?? null,
3147
+ chatType: route.chatType ?? null,
3148
+ threadId: effectiveTextThreadId(request, route),
3149
+ message: request.message?.trim() ?? null,
3150
+ replyToMessageId: effectiveTextReplyId(request, route)
3151
+ });
3152
+ }
2706
3153
  async function executeMessageChannel(input, options) {
2707
3154
  const normalized = normalizeMessageChannelInput(input, options.resolver);
2708
3155
  if (typeof normalized === "string")
@@ -2771,7 +3218,7 @@ async function executeMessageChannel(input, options) {
2771
3218
  });
2772
3219
  const requestThreadId = normalized.action === "download-file" ? normalized.threadId : inferredThreadId ?? (normalized.channel === "telegram" && context2.route.chatType === "direct" ? normalized.threadId : context2.route.threadId ?? normalized.threadId);
2773
3220
  const request2 = buildMessageChannelRequest(normalized, normalized.chatId, requestThreadId);
2774
- return await dispatchWithIdempotency(request2, context2, options.idempotencyScope);
3221
+ return await dispatchWithIdempotency(request2, context2, options.idempotencyScope, options.idempotencyMode);
2775
3222
  }
2776
3223
  if (normalized.channel !== "slack") {
2777
3224
  return `Error: Explicit MessageChannel targets are not supported on ${normalized.channel}.`;
@@ -2804,10 +3251,13 @@ async function executeMessageChannel(input, options) {
2804
3251
  async function executeMessageChannelExternalTool(input, options) {
2805
3252
  return createMessageChannelExternalToolResult(await executeMessageChannel(input, options));
2806
3253
  }
2807
- function dispatchWithIdempotency(request, context, scope) {
3254
+ function dispatchWithIdempotency(request, context, scope, mode = "explicit") {
2808
3255
  const dispatch = () => dispatchMessageChannelAction({ request, context });
2809
- const key = messageIdempotencyKey(request, context.route);
2810
- return scope ? scope.execute(key, dispatch) : dispatch();
3256
+ const actionKey = messageIdempotencyKey(request, context.route);
3257
+ const deliveryKey = textDeliveryFingerprint(request, context.route);
3258
+ if (!scope)
3259
+ return dispatch();
3260
+ return mode === "relay" ? scope.executeRelay(actionKey, deliveryKey, dispatch) : scope.execute(actionKey, deliveryKey, dispatch);
2811
3261
  }
2812
3262
  // src/tools/descriptions/MessageChannel.md
2813
3263
  var MessageChannel_default = '# MessageChannel\n\nSend a message or channel action to an external channel.\n\nWhen you receive a `<channel-notification>`, use this tool to reply directly to the user on the same external channel. A normal assistant response is not delivered back to the external channel automatically.\n\nThere are two supported send modes:\n- Reply mode: use `channel` + `chat_id` from the notification to respond in the current routed chat.\n- Proactive mode: use `channel` + `target` on supported channels to send to an explicit outbound destination.\n\nPreferred reply pattern:\n- `action="send"` to send a normal reply\n- `channel` + `chat_id` from the notification attributes\n- `message` for the text body\n\nParameters:\n- `action`: The action to perform. The exact available actions depend on the active channel plugins and are reflected in the JSON schema.\n- `channel`: The platform to send to.\n- `chat_id`: Reply target for the current routed chat. Use this when responding to a channel notification.\n- `target`: Explicit outbound target for proactive sends on supported channels.\n- `accountId`: Optional channel account selector when multiple eligible accounts are available.\n- `message`: Text body for `action="send"`.\n- `replyTo`: Optional message ID to reply to. Omit this unless you intentionally want the platform\'s quote/reply UI.\n- `messageId`: Optional target message id for message-scoped actions like reactions.\n- `emoji`: Optional reaction payload for channels that support reactions.\n- `remove`: Optional boolean to remove a reaction instead of adding it.\n- `media`: Optional absolute local file path for file/media uploads on channels that support uploads.\n- `filename`: Optional uploaded filename override when supported by the channel.\n- `title`: Optional uploaded attachment title when supported by the channel.\n\nRules:\n- Always pass `action` explicitly, even for a normal reply.\n- Pass exactly one of `chat_id` or `target`.\n- `react` should be its own call.\n- `upload-file` can include both `media` and `message` so the uploaded file has a caption/comment when the channel supports it.\n- Telegram supports `action="send-rich"` for Bot API Rich Messages from Markdown content. Use it for headings, lists, tables, rich block quotes, details blocks, formulas, and longer structured messages; use `upload-file` for local media files.\n\nTelegram rich messages:\n- In Telegram private chats, normal `action="send"` messages are sent through Bot API Rich Messages by default when the Telegram account enables `rich_private_chat_default`; with that setting disabled, use explicit `action="send-rich"` for rich delivery.\n- Use `action="send-rich"` with `channel="telegram"` for structured Markdown rendered by Telegram Bot API Rich Messages.\n- Use this when the output benefits from real headings, tables, block quotes, collapsible details, footnotes, task lists, or formulas.\n- The `message` field is passed as Telegram rich Markdown. Supported examples include:\n - headings: `# Heading`, `## Heading`\n - lists: `- item`, `1. item`, `- [ ] task`, `- [x] done`\n - tables: GitHub-style pipe tables\n - block quotes: `> quoted text`\n - collapsible details: `<details><summary>Title</summary>content</details>`\n - inline math: `$E = mc^2$`\n - display math: `$$E = mc^2$$` or fenced `math` code blocks\n - footnotes: `text[^id]` with `[^id]: definition`\n- Prefer dollar-delimited math. `\\(...\\)` and `\\[...\\]` do not reliably render as formulas in Telegram rich Markdown.\n- Do not use `send-rich` for local file uploads. Use `upload-file`; rich Markdown media blocks only support HTTP/HTTPS URLs.\n- Rich messages persist only when the final `send-rich` call executes. If Telegram draft streaming is enabled for the account, the channel runtime may show ephemeral previews automatically; do not try to manage draft lifecycle from the tool call.\n';
@@ -2907,12 +3357,15 @@ function resolveMessageChannelToolChannels(channels) {
2907
3357
  actions.add(action);
2908
3358
  schemaContributions.push(...asSchemaContributionArray(discovery?.schema));
2909
3359
  }
3360
+ const routedDestinationKeys = new Set(channels.map((channel) => channel.routedDestinationKey).filter(Boolean));
3361
+ const hasCompleteDestinationKeys = channels.every((channel) => channel.routedDestinationKey);
2910
3362
  return {
2911
3363
  activeChannels: [...uniqueChannels.keys()],
2912
3364
  displayNames: [...uniqueChannels.values()].map((channel) => channel.displayName),
2913
3365
  accountIds: [...accountIds],
2914
3366
  actions: [...actions],
2915
- schemaContributions
3367
+ schemaContributions,
3368
+ automaticRelay: channels.length > 0 && channels.every((channel) => channel.replyMode === "relay") && (hasCompleteDestinationKeys ? routedDestinationKeys.size === 1 : channels.length === 1)
2916
3369
  };
2917
3370
  }
2918
3371
  function mergeSchemaContributions(schema, contributions) {
@@ -2990,7 +3443,9 @@ No external channel adapters are currently running.`;
2990
3443
  const channelList = discovery.displayNames.join(", ");
2991
3444
  const actionList = discovery.actions.join(", ");
2992
3445
  const hasAction = (action) => discovery.actions.includes(action);
2993
- const scopedReplyContract = scoped ? '\n\nThis tool is currently scoped to a routed external channel turn. Plain assistant text is not delivered to that external user. If a user-visible reply is appropriate, your final action for the turn must be one MessageChannel call with action="send", channel from the notification, chat_id from the notification, and message containing the reply. After that final send succeeds, do not repeat or paraphrase the sent message in assistant text; finish with only `Sent.` as the internal confirmation. This does not apply to a short acknowledgement sent before continuing substantive work. If no user-visible response is appropriate, do not call MessageChannel and do not send an empty acknowledgement. For lightweight acknowledgement, prefer action="react" when supported. If the useful response belongs later, schedule the follow-up instead of sending a placeholder.' : "";
3446
+ const scopedReplyContract = scoped ? discovery.automaticRelay ? `
3447
+
3448
+ This routed external channel turn uses automatic relay. Plain assistant text is delivered to the external user as each assistant message is finalized, so write the user-visible reply normally and do not make a duplicate MessageChannel send. When a host exposes MessageChannel alongside relay, use it only for explicit actions such as reactions and files; exact duplicate final text is suppressed.` : '\n\nThis tool is currently scoped to a routed external channel turn. Plain assistant text is not delivered to that external user. If a user-visible reply is appropriate, your final action for the turn must be one MessageChannel call with action="send", channel from the notification, chat_id from the notification, and message containing the reply. After that final send succeeds, do not repeat or paraphrase the sent message in assistant text; finish with only `Sent.` as the internal confirmation. This does not apply to a short acknowledgement sent before continuing substantive work. If no user-visible response is appropriate, do not call MessageChannel and do not send an empty acknowledgement. For lightweight acknowledgement, prefer action="react" when supported. If the useful response belongs later, schedule the follow-up instead of sending a placeholder.' : "";
2994
3449
  const slackWorkAcknowledgement = discovery.activeChannels.includes("slack") ? `
2995
3450
 
2996
3451
  For Slack requests that require nontrivial work or several tool calls, send one short MessageChannel call with action="send" before starting other tools. This gives the Slack user verbal acknowledgement and a View in web link. Do not do this for no-ops, reaction-only responses, or simple no-tool answers.` : "";
@@ -3061,4 +3516,4 @@ export {
3061
3516
  ChannelControlRequestCoordinator
3062
3517
  };
3063
3518
 
3064
- //# debugId=B90B3E001C72BB3264756E2164756E21
3519
+ //# debugId=88EE0831E20B6BB164756E2164756E21