kimi-agent-module-api 1.1.0__tar.gz → 1.2.0__tar.gz

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 (19) hide show
  1. {kimi_agent_module_api-1.1.0/src/kimi_agent_module_api.egg-info → kimi_agent_module_api-1.2.0}/PKG-INFO +6 -1
  2. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/README.md +5 -0
  3. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/pyproject.toml +1 -1
  4. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/__init__.py +20 -0
  5. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/contracts.py +187 -1
  6. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/testing.py +45 -3
  7. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0/src/kimi_agent_module_api.egg-info}/PKG-INFO +6 -1
  8. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/LICENSE +0 -0
  9. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/setup.cfg +0 -0
  10. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/events.py +0 -0
  11. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/images.py +0 -0
  12. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/py.typed +0 -0
  13. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/settings.py +0 -0
  14. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/tools.py +0 -0
  15. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/trust.py +0 -0
  16. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api.egg-info/SOURCES.txt +0 -0
  17. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api.egg-info/dependency_links.txt +0 -0
  18. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api.egg-info/requires.txt +0 -0
  19. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kimi-agent-module-api
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: Stable contracts for community-built assistant modules
5
5
  Author: Webhead
6
6
  License-Expression: MIT
@@ -54,3 +54,8 @@ Guild-scoped live command replacement was added in 1.1. Modules using
54
54
  `InteractionRouter.replace_guild_commands()` should depend on
55
55
  `kimi-agent-module-api>=1.1,<2` and require the host capability
56
56
  `discord.guild_commands.v1`.
57
+
58
+ Version 1.2 adds typed modal forms and a narrow Components V2 layout model. Once a response uses
59
+ that layout model, Discord requires every later edit of the same message to remain a layout.
60
+ Modules using them should depend on `kimi-agent-module-api>=1.2,<2` and require
61
+ `discord.modals.v1` and/or `discord.components_v2.v1`.
@@ -37,3 +37,8 @@ Guild-scoped live command replacement was added in 1.1. Modules using
37
37
  `InteractionRouter.replace_guild_commands()` should depend on
38
38
  `kimi-agent-module-api>=1.1,<2` and require the host capability
39
39
  `discord.guild_commands.v1`.
40
+
41
+ Version 1.2 adds typed modal forms and a narrow Components V2 layout model. Once a response uses
42
+ that layout model, Discord requires every later edit of the same message to remain a layout.
43
+ Modules using them should depend on `kimi-agent-module-api>=1.2,<2` and require
44
+ `discord.modals.v1` and/or `discord.components_v2.v1`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kimi-agent-module-api"
3
- version = "1.1.0"
3
+ version = "1.2.0"
4
4
  description = "Stable contracts for community-built assistant modules"
5
5
  requires-python = ">=3.14"
6
6
  authors = [{ name = "Webhead" }]
@@ -20,9 +20,17 @@ from kimi_agent_module_api.contracts import (
20
20
  HealthReporter,
21
21
  InviteSnapshot,
22
22
  InteractionRouter,
23
+ LayoutGallery,
24
+ LayoutItem,
25
+ LayoutSection,
26
+ LayoutSeparator,
27
+ LayoutSeparatorSpacing,
28
+ LayoutText,
29
+ ModalSpec,
23
30
  ModuleHttp,
24
31
  ModulePermissions,
25
32
  ModuleStorage,
33
+ OutgoingLayout,
26
34
  ScopedModuleMigration,
27
35
  ProposalActor,
28
36
  ProposalError,
@@ -34,6 +42,8 @@ from kimi_agent_module_api.contracts import (
34
42
  ServiceRegistry,
35
43
  ServiceRequirement,
36
44
  TrustLookup,
45
+ TextInputSpec,
46
+ TextInputStyle,
37
47
  )
38
48
  from kimi_agent_module_api.settings import ModuleSetting, ModuleSettingsDefinition
39
49
  from kimi_agent_module_api.tools import (
@@ -151,6 +161,13 @@ __all__ = [
151
161
  "ConfigSnapshot",
152
162
  "GuildSettingsSchema",
153
163
  "InviteSnapshot",
164
+ "LayoutGallery",
165
+ "LayoutItem",
166
+ "LayoutSection",
167
+ "LayoutSeparator",
168
+ "LayoutSeparatorSpacing",
169
+ "LayoutText",
170
+ "ModalSpec",
154
171
  "ModuleCapabilities",
155
172
  "ModuleLoadContext",
156
173
  "ModulePermissions",
@@ -161,6 +178,7 @@ __all__ = [
161
178
  "ModuleToolContext",
162
179
  "ModuleToolHandler",
163
180
  "ModuleToolRegistry",
181
+ "OutgoingLayout",
164
182
  "ProposalActor",
165
183
  "ProposalError",
166
184
  "ProposalRef",
@@ -170,6 +188,8 @@ __all__ = [
170
188
  "ScopedModuleMigration",
171
189
  "ServiceDeclaration",
172
190
  "ServiceRequirement",
191
+ "TextInputSpec",
192
+ "TextInputStyle",
173
193
  "TrustTier",
174
194
  "render_guild_settings",
175
195
  ]
@@ -819,6 +819,82 @@ class OutgoingEmbed:
819
819
  timestamp: bool = False
820
820
 
821
821
 
822
+ @dataclass(frozen=True, slots=True)
823
+ class LayoutText:
824
+ content: str
825
+
826
+
827
+ type LayoutSeparatorSpacing = Literal["small", "large"]
828
+
829
+
830
+ @dataclass(frozen=True, slots=True)
831
+ class LayoutSeparator:
832
+ visible: bool = True
833
+ spacing: LayoutSeparatorSpacing = "small"
834
+
835
+
836
+ @dataclass(frozen=True, slots=True)
837
+ class LayoutGallery:
838
+ urls: tuple[str, ...]
839
+
840
+
841
+ @dataclass(frozen=True, slots=True)
842
+ class LayoutSection:
843
+ texts: tuple[str, ...]
844
+ thumbnail_url: str
845
+
846
+
847
+ type LayoutItem = LayoutText | LayoutSeparator | LayoutGallery | LayoutSection
848
+
849
+
850
+ @dataclass(frozen=True, slots=True)
851
+ class OutgoingLayout:
852
+ """One Components V2 container, optionally followed by interactive controls."""
853
+
854
+ items: tuple[LayoutItem, ...]
855
+ accent_color: int | None = None
856
+
857
+
858
+ def validate_outgoing_layout(layout: OutgoingLayout) -> None:
859
+ """Validate the Discord hard limits represented by ``OutgoingLayout``."""
860
+ if not 1 <= len(layout.items) <= 40:
861
+ raise ModuleContractError("a layout must contain between one and 40 items")
862
+ if layout.accent_color is not None and (
863
+ isinstance(layout.accent_color, bool) or not 0 <= layout.accent_color <= 0xFFFFFF
864
+ ):
865
+ raise ModuleContractError("layout accent_color must be between 0 and 0xFFFFFF")
866
+
867
+ def validate_text(text: Any, label: str) -> None:
868
+ if not isinstance(text, str) or not 1 <= len(text) <= 4_000:
869
+ raise ModuleContractError(f"{label} must contain between one and 4000 characters")
870
+
871
+ text_length = 0
872
+ for item in layout.items:
873
+ if isinstance(item, LayoutText):
874
+ validate_text(item.content, "layout text")
875
+ text_length += len(item.content)
876
+ elif isinstance(item, LayoutSeparator):
877
+ if item.spacing not in ("small", "large"):
878
+ raise ModuleContractError(f"invalid layout separator spacing {item.spacing!r}")
879
+ elif isinstance(item, LayoutGallery):
880
+ if not 1 <= len(item.urls) <= 10:
881
+ raise ModuleContractError("a layout gallery must contain between one and 10 URLs")
882
+ if any(not isinstance(url, str) or not url for url in item.urls):
883
+ raise ModuleContractError("layout gallery URLs must be non-empty strings")
884
+ elif isinstance(item, LayoutSection):
885
+ if not 1 <= len(item.texts) <= 3:
886
+ raise ModuleContractError("a layout section must contain between one and 3 texts")
887
+ for text in item.texts:
888
+ validate_text(text, "layout section text")
889
+ text_length += len(text)
890
+ if not isinstance(item.thumbnail_url, str) or not item.thumbnail_url:
891
+ raise ModuleContractError("a layout section thumbnail URL must be non-empty")
892
+ else:
893
+ raise ModuleContractError(f"unsupported layout item {item!r}")
894
+ if text_length > 4_000:
895
+ raise ModuleContractError("layout text cannot exceed 4000 characters in total")
896
+
897
+
822
898
  class DiscordActions(Protocol):
823
899
  """Declared Discord operations on stable IDs.
824
900
 
@@ -957,6 +1033,9 @@ class ModuleInteraction(Protocol):
957
1033
  @property
958
1034
  def values(self) -> tuple[str, ...]: ...
959
1035
 
1036
+ @property
1037
+ def text_values(self) -> Mapping[str, str]: ...
1038
+
960
1039
  @property
961
1040
  def message(self) -> MessageRef | None:
962
1041
  """The message a button or select lives on; ``None`` for slash commands."""
@@ -967,17 +1046,21 @@ class ModuleInteraction(Protocol):
967
1046
  content: str | None = None,
968
1047
  *,
969
1048
  embed: OutgoingEmbed | None = None,
1049
+ layout: OutgoingLayout | None = None,
970
1050
  ephemeral: bool = False,
971
1051
  components: Sequence[Any] = (),
972
1052
  ) -> None: ...
973
1053
 
974
1054
  async def defer(self, *, ephemeral: bool = False) -> None: ...
975
1055
 
1056
+ async def show_modal(self, modal: ModalSpec) -> None: ...
1057
+
976
1058
  async def edit_original(
977
1059
  self,
978
1060
  content: str | None = None,
979
1061
  *,
980
1062
  embed: OutgoingEmbed | None = None,
1063
+ layout: OutgoingLayout | None = None,
981
1064
  components: Sequence[Any] = (),
982
1065
  ) -> None: ...
983
1066
 
@@ -1013,11 +1096,114 @@ class SelectSpec:
1013
1096
  max_values: int = 1
1014
1097
 
1015
1098
 
1099
+ def validate_layout_components(
1100
+ components: Sequence[Any], *, layout: OutgoingLayout | None = None
1101
+ ) -> None:
1102
+ """Validate control rows and the full Components V2 descendant limit."""
1103
+ rows = 0
1104
+ buttons_in_row = 0
1105
+ for component in components:
1106
+ if isinstance(component, ButtonSpec):
1107
+ if buttons_in_row == 0:
1108
+ rows += 1
1109
+ buttons_in_row = (buttons_in_row + 1) % 5
1110
+ elif isinstance(component, SelectSpec):
1111
+ buttons_in_row = 0
1112
+ rows += 1
1113
+ else:
1114
+ raise ModuleContractError(f"unsupported component {component!r}")
1115
+ if rows > 5:
1116
+ raise ModuleContractError("layout controls cannot require more than five action rows")
1117
+ if layout is not None:
1118
+ descendants = 1 + rows + len(components)
1119
+ for item in layout.items:
1120
+ descendants += len(item.texts) + 2 if isinstance(item, LayoutSection) else 1
1121
+ if descendants > 40:
1122
+ raise ModuleContractError("a layout cannot exceed 40 components in total")
1123
+
1124
+
1125
+ type TextInputStyle = Literal["short", "paragraph"]
1126
+
1127
+
1128
+ @dataclass(frozen=True, slots=True)
1129
+ class TextInputSpec:
1130
+ key: str
1131
+ label: str
1132
+ style: TextInputStyle = "short"
1133
+ default: str | None = None
1134
+ placeholder: str | None = None
1135
+ required: bool = True
1136
+ min_length: int | None = None
1137
+ max_length: int | None = None
1138
+
1139
+
1140
+ @dataclass(frozen=True, slots=True)
1141
+ class ModalSpec:
1142
+ """A modal whose submit handler is registered under ``key``."""
1143
+
1144
+ key: str
1145
+ title: str
1146
+ inputs: tuple[TextInputSpec, ...]
1147
+ parts: tuple[str, ...] = ()
1148
+
1149
+
1150
+ def validate_modal_spec(modal: ModalSpec) -> None:
1151
+ """Validate Discord's hard modal and text-input limits."""
1152
+ if not isinstance(modal.key, str) or not _TOPIC_SEGMENT_RE.fullmatch(modal.key):
1153
+ raise ModuleContractError(f"invalid modal key {modal.key!r}")
1154
+ if any(not isinstance(part, str) or ":" in part for part in modal.parts):
1155
+ raise ModuleContractError("modal custom_id parts must be strings without ':'")
1156
+ if not isinstance(modal.title, str) or not 1 <= len(modal.title) <= 45:
1157
+ raise ModuleContractError("a modal title must contain between one and 45 characters")
1158
+ if not 1 <= len(modal.inputs) <= 5:
1159
+ raise ModuleContractError("a modal must contain between one and five text inputs")
1160
+
1161
+ keys: set[str] = set()
1162
+ for input_spec in modal.inputs:
1163
+ if not isinstance(input_spec.key, str) or not _TOPIC_SEGMENT_RE.fullmatch(input_spec.key):
1164
+ raise ModuleContractError(f"invalid modal text input key {input_spec.key!r}")
1165
+ if input_spec.key in keys:
1166
+ raise ModuleContractError("modal text input keys must be unique")
1167
+ keys.add(input_spec.key)
1168
+ if not isinstance(input_spec.label, str) or not 1 <= len(input_spec.label) <= 45:
1169
+ raise ModuleContractError(
1170
+ "a modal text input label must contain between one and 45 characters"
1171
+ )
1172
+ if input_spec.style not in ("short", "paragraph"):
1173
+ raise ModuleContractError(f"invalid modal text input style {input_spec.style!r}")
1174
+ if input_spec.placeholder is not None and (
1175
+ not isinstance(input_spec.placeholder, str) or len(input_spec.placeholder) > 100
1176
+ ):
1177
+ raise ModuleContractError("a modal text input placeholder cannot exceed 100 characters")
1178
+ if input_spec.default is not None and (
1179
+ not isinstance(input_spec.default, str) or len(input_spec.default) > 4_000
1180
+ ):
1181
+ raise ModuleContractError("a modal text input default cannot exceed 4000 characters")
1182
+ if input_spec.min_length is not None and (
1183
+ isinstance(input_spec.min_length, bool)
1184
+ or not isinstance(input_spec.min_length, int)
1185
+ or not 0 <= input_spec.min_length <= 4_000
1186
+ ):
1187
+ raise ModuleContractError("modal text input min_length must be between 0 and 4000")
1188
+ if input_spec.max_length is not None and (
1189
+ isinstance(input_spec.max_length, bool)
1190
+ or not isinstance(input_spec.max_length, int)
1191
+ or not 1 <= input_spec.max_length <= 4_000
1192
+ ):
1193
+ raise ModuleContractError("modal text input max_length must be between 1 and 4000")
1194
+ if (
1195
+ input_spec.min_length is not None
1196
+ and input_spec.max_length is not None
1197
+ and input_spec.min_length > input_spec.max_length
1198
+ ):
1199
+ raise ModuleContractError("modal text input min_length cannot exceed max_length")
1200
+
1201
+
1016
1202
  type CommandHandler = Callable[[ModuleInteraction], Awaitable[None]]
1017
1203
  type AutocompleteHandler = Callable[
1018
1204
  [ModuleInteraction, str, str], Awaitable[Sequence[tuple[str, str | int]]]
1019
1205
  ]
1020
- type ComponentKind = Literal["button", "select"]
1206
+ type ComponentKind = Literal["button", "select", "modal"]
1021
1207
 
1022
1208
 
1023
1209
  @dataclass(frozen=True, slots=True)
@@ -45,11 +45,13 @@ from kimi_agent_module_api.contracts import (
45
45
  MessagePage,
46
46
  MessageRef,
47
47
  MessageSnapshot,
48
+ ModalSpec,
48
49
  TABLE_NAME_RE,
49
50
  MigrationContext,
50
51
  ModuleContractError,
51
52
  ModuleHealth,
52
53
  OutgoingEmbed,
54
+ OutgoingLayout,
53
55
  ProposalActor,
54
56
  ProposalError,
55
57
  ProposalRef,
@@ -60,6 +62,9 @@ from kimi_agent_module_api.contracts import (
60
62
  TrustTierName,
61
63
  UndeclaredDiscordAction,
62
64
  build_custom_id,
65
+ validate_modal_spec,
66
+ validate_layout_components,
67
+ validate_outgoing_layout,
63
68
  validate_publish_topic,
64
69
  )
65
70
  from kimi_agent_module_api.tools import ModuleToolHandler
@@ -568,6 +573,7 @@ class FakeResponse:
568
573
  ephemeral: bool
569
574
  components: tuple[Any, ...]
570
575
  kind: str
576
+ layout: OutgoingLayout | None = None
571
577
 
572
578
 
573
579
  class FakeInteraction:
@@ -580,8 +586,10 @@ class FakeInteraction:
580
586
  options: Mapping[str, Any] | None = None,
581
587
  custom_id: str | None = None,
582
588
  values: Sequence[str] = (),
589
+ text_values: Mapping[str, str] | None = None,
583
590
  guild_name: str | None = "Test Guild",
584
591
  message: MessageRef | None = None,
592
+ message_uses_layout: bool = False,
585
593
  ) -> None:
586
594
  self._message = message
587
595
  self._guild_name = guild_name
@@ -591,8 +599,11 @@ class FakeInteraction:
591
599
  self._options = dict(options or {})
592
600
  self._custom_id = custom_id
593
601
  self._values = tuple(values)
602
+ self._text_values = dict(text_values or {})
594
603
  self.responses: list[FakeResponse] = []
604
+ self.shown_modals: list[ModalSpec] = []
595
605
  self.deferred: bool | None = None
606
+ self._original_uses_layout = message_uses_layout
596
607
 
597
608
  @property
598
609
  def guild_id(self) -> int:
@@ -622,6 +633,10 @@ class FakeInteraction:
622
633
  def values(self) -> tuple[str, ...]:
623
634
  return self._values
624
635
 
636
+ @property
637
+ def text_values(self) -> Mapping[str, str]:
638
+ return self._text_values
639
+
625
640
  @property
626
641
  def message(self) -> MessageRef | None:
627
642
  return self._message
@@ -631,22 +646,49 @@ class FakeInteraction:
631
646
  content: str | None = None,
632
647
  *,
633
648
  embed: OutgoingEmbed | None = None,
649
+ layout: OutgoingLayout | None = None,
634
650
  ephemeral: bool = False,
635
651
  components: Sequence[Any] = (),
636
652
  ) -> None:
637
- self.responses.append(FakeResponse(content, embed, ephemeral, tuple(components), "respond"))
653
+ if layout is not None and (content is not None or embed is not None):
654
+ raise ModuleContractError("layout cannot be combined with content or embed")
655
+ if layout is not None:
656
+ validate_outgoing_layout(layout)
657
+ validate_layout_components(components, layout=layout)
658
+ self.responses.append(
659
+ FakeResponse(content, embed, ephemeral, tuple(components), "respond", layout=layout)
660
+ )
661
+ self._original_uses_layout = layout is not None
638
662
 
639
663
  async def defer(self, *, ephemeral: bool = False) -> None:
640
664
  self.deferred = ephemeral
641
665
 
666
+ async def show_modal(self, modal: ModalSpec) -> None:
667
+ validate_modal_spec(modal)
668
+ self.shown_modals.append(modal)
669
+
642
670
  async def edit_original(
643
671
  self,
644
672
  content: str | None = None,
645
673
  *,
646
674
  embed: OutgoingEmbed | None = None,
675
+ layout: OutgoingLayout | None = None,
647
676
  components: Sequence[Any] = (),
648
677
  ) -> None:
649
- self.responses.append(FakeResponse(content, embed, False, tuple(components), "edit"))
678
+ if self._original_uses_layout and layout is None:
679
+ raise ModuleContractError(
680
+ "a Components V2 message must continue to use layout when edited"
681
+ )
682
+ if layout is not None and (content is not None or embed is not None):
683
+ raise ModuleContractError("layout cannot be combined with content or embed")
684
+ if layout is not None:
685
+ validate_outgoing_layout(layout)
686
+ validate_layout_components(components, layout=layout)
687
+ self.responses.append(
688
+ FakeResponse(content, embed, False, tuple(components), "edit", layout=layout)
689
+ )
690
+ if layout is not None:
691
+ self._original_uses_layout = True
650
692
 
651
693
  async def follow_up(
652
694
  self, content: str, *, embed: OutgoingEmbed | None = None, ephemeral: bool = False
@@ -742,7 +784,7 @@ class FakeInteractions:
742
784
  expires_after_seconds: float | None = None,
743
785
  min_tier: TrustTierName = "member",
744
786
  ) -> _Closable:
745
- if kind not in ("button", "select"):
787
+ if kind not in ("button", "select", "modal"):
746
788
  raise ModuleContractError(f"unsupported component kind {kind!r}")
747
789
  build_custom_id(self.module_name, key)
748
790
  identity = (kind, key)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kimi-agent-module-api
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: Stable contracts for community-built assistant modules
5
5
  Author: Webhead
6
6
  License-Expression: MIT
@@ -54,3 +54,8 @@ Guild-scoped live command replacement was added in 1.1. Modules using
54
54
  `InteractionRouter.replace_guild_commands()` should depend on
55
55
  `kimi-agent-module-api>=1.1,<2` and require the host capability
56
56
  `discord.guild_commands.v1`.
57
+
58
+ Version 1.2 adds typed modal forms and a narrow Components V2 layout model. Once a response uses
59
+ that layout model, Discord requires every later edit of the same message to remain a layout.
60
+ Modules using them should depend on `kimi-agent-module-api>=1.2,<2` and require
61
+ `discord.modals.v1` and/or `discord.components_v2.v1`.