kimi-agent-module-api 1.0.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.0.0/src/kimi_agent_module_api.egg-info → kimi_agent_module_api-1.2.0}/PKG-INFO +12 -2
  2. {kimi_agent_module_api-1.0.0 → kimi_agent_module_api-1.2.0}/README.md +11 -1
  3. {kimi_agent_module_api-1.0.0 → kimi_agent_module_api-1.2.0}/pyproject.toml +1 -1
  4. {kimi_agent_module_api-1.0.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/__init__.py +20 -0
  5. {kimi_agent_module_api-1.0.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/contracts.py +208 -1
  6. {kimi_agent_module_api-1.0.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/testing.py +92 -3
  7. {kimi_agent_module_api-1.0.0 → kimi_agent_module_api-1.2.0/src/kimi_agent_module_api.egg-info}/PKG-INFO +12 -2
  8. {kimi_agent_module_api-1.0.0 → kimi_agent_module_api-1.2.0}/LICENSE +0 -0
  9. {kimi_agent_module_api-1.0.0 → kimi_agent_module_api-1.2.0}/setup.cfg +0 -0
  10. {kimi_agent_module_api-1.0.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/events.py +0 -0
  11. {kimi_agent_module_api-1.0.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/images.py +0 -0
  12. {kimi_agent_module_api-1.0.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/py.typed +0 -0
  13. {kimi_agent_module_api-1.0.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/settings.py +0 -0
  14. {kimi_agent_module_api-1.0.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/tools.py +0 -0
  15. {kimi_agent_module_api-1.0.0 → kimi_agent_module_api-1.2.0}/src/kimi_agent_module_api/trust.py +0 -0
  16. {kimi_agent_module_api-1.0.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.0.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.0.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.0.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.0.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
@@ -48,4 +48,14 @@ my_module = "my_module_package:SPEC"
48
48
  The [module guide](https://github.com/webhead2oo9/kimi-agent/blob/main/docs/modules.md)
49
49
  documents installation, declarations, lifecycle, and every runtime port. The
50
50
  [reference module](https://github.com/webhead2oo9/kimi-agent/tree/main/bot/modules/example)
51
- is a complete, commented example that exercises every port; start there.
51
+ is a complete, commented example that exercises most ports; start there.
52
+
53
+ Guild-scoped live command replacement was added in 1.1. Modules using
54
+ `InteractionRouter.replace_guild_commands()` should depend on
55
+ `kimi-agent-module-api>=1.1,<2` and require the host capability
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`.
@@ -31,4 +31,14 @@ my_module = "my_module_package:SPEC"
31
31
  The [module guide](https://github.com/webhead2oo9/kimi-agent/blob/main/docs/modules.md)
32
32
  documents installation, declarations, lifecycle, and every runtime port. The
33
33
  [reference module](https://github.com/webhead2oo9/kimi-agent/tree/main/bot/modules/example)
34
- is a complete, commented example that exercises every port; start there.
34
+ is a complete, commented example that exercises most ports; start there.
35
+
36
+ Guild-scoped live command replacement was added in 1.1. Modules using
37
+ `InteractionRouter.replace_guild_commands()` should depend on
38
+ `kimi-agent-module-api>=1.1,<2` and require the host capability
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.0.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
  ]
@@ -52,6 +52,10 @@ class ServiceUnavailable(RuntimeError):
52
52
  """Raised through a service proxy after its provider module closed."""
53
53
 
54
54
 
55
+ class CommandSyncError(RuntimeError):
56
+ """Discord did not accept a live guild-command synchronization."""
57
+
58
+
55
59
  # --------------------------------------------------------------------------
56
60
  # Declarations carried on ModuleSpec
57
61
  # --------------------------------------------------------------------------
@@ -815,6 +819,82 @@ class OutgoingEmbed:
815
819
  timestamp: bool = False
816
820
 
817
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
+
818
898
  class DiscordActions(Protocol):
819
899
  """Declared Discord operations on stable IDs.
820
900
 
@@ -953,6 +1033,9 @@ class ModuleInteraction(Protocol):
953
1033
  @property
954
1034
  def values(self) -> tuple[str, ...]: ...
955
1035
 
1036
+ @property
1037
+ def text_values(self) -> Mapping[str, str]: ...
1038
+
956
1039
  @property
957
1040
  def message(self) -> MessageRef | None:
958
1041
  """The message a button or select lives on; ``None`` for slash commands."""
@@ -963,17 +1046,21 @@ class ModuleInteraction(Protocol):
963
1046
  content: str | None = None,
964
1047
  *,
965
1048
  embed: OutgoingEmbed | None = None,
1049
+ layout: OutgoingLayout | None = None,
966
1050
  ephemeral: bool = False,
967
1051
  components: Sequence[Any] = (),
968
1052
  ) -> None: ...
969
1053
 
970
1054
  async def defer(self, *, ephemeral: bool = False) -> None: ...
971
1055
 
1056
+ async def show_modal(self, modal: ModalSpec) -> None: ...
1057
+
972
1058
  async def edit_original(
973
1059
  self,
974
1060
  content: str | None = None,
975
1061
  *,
976
1062
  embed: OutgoingEmbed | None = None,
1063
+ layout: OutgoingLayout | None = None,
977
1064
  components: Sequence[Any] = (),
978
1065
  ) -> None: ...
979
1066
 
@@ -1009,11 +1096,123 @@ class SelectSpec:
1009
1096
  max_values: int = 1
1010
1097
 
1011
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
+
1012
1202
  type CommandHandler = Callable[[ModuleInteraction], Awaitable[None]]
1013
1203
  type AutocompleteHandler = Callable[
1014
1204
  [ModuleInteraction, str, str], Awaitable[Sequence[tuple[str, str | int]]]
1015
1205
  ]
1016
- type ComponentKind = Literal["button", "select"]
1206
+ type ComponentKind = Literal["button", "select", "modal"]
1207
+
1208
+
1209
+ @dataclass(frozen=True, slots=True)
1210
+ class GuildCommand:
1211
+ """One command in a module's desired command set for a guild."""
1212
+
1213
+ spec: CommandSpec
1214
+ handler: CommandHandler
1215
+ autocomplete: AutocompleteHandler | None = None
1017
1216
 
1018
1217
 
1019
1218
  class Registration(Protocol):
@@ -1029,6 +1228,14 @@ class InteractionRouter(Protocol):
1029
1228
  autocomplete: AutocompleteHandler | None = None,
1030
1229
  ) -> Registration: ...
1031
1230
 
1231
+ async def replace_guild_commands(
1232
+ self,
1233
+ guild_id: int,
1234
+ commands: Sequence[GuildCommand],
1235
+ ) -> None:
1236
+ """Replace this module's complete command set for one guild."""
1237
+ ...
1238
+
1032
1239
  def register_component(
1033
1240
  self,
1034
1241
  kind: ComponentKind,
@@ -29,6 +29,7 @@ from kimi_agent_module_api.contracts import (
29
29
  Backoff,
30
30
  ChannelSnapshot,
31
31
  CommandSpec,
32
+ GuildCommand,
32
33
  ConfigSnapshot,
33
34
  Event,
34
35
  EventHandler,
@@ -44,11 +45,13 @@ from kimi_agent_module_api.contracts import (
44
45
  MessagePage,
45
46
  MessageRef,
46
47
  MessageSnapshot,
48
+ ModalSpec,
47
49
  TABLE_NAME_RE,
48
50
  MigrationContext,
49
51
  ModuleContractError,
50
52
  ModuleHealth,
51
53
  OutgoingEmbed,
54
+ OutgoingLayout,
52
55
  ProposalActor,
53
56
  ProposalError,
54
57
  ProposalRef,
@@ -59,6 +62,9 @@ from kimi_agent_module_api.contracts import (
59
62
  TrustTierName,
60
63
  UndeclaredDiscordAction,
61
64
  build_custom_id,
65
+ validate_modal_spec,
66
+ validate_layout_components,
67
+ validate_outgoing_layout,
62
68
  validate_publish_topic,
63
69
  )
64
70
  from kimi_agent_module_api.tools import ModuleToolHandler
@@ -567,6 +573,7 @@ class FakeResponse:
567
573
  ephemeral: bool
568
574
  components: tuple[Any, ...]
569
575
  kind: str
576
+ layout: OutgoingLayout | None = None
570
577
 
571
578
 
572
579
  class FakeInteraction:
@@ -579,8 +586,10 @@ class FakeInteraction:
579
586
  options: Mapping[str, Any] | None = None,
580
587
  custom_id: str | None = None,
581
588
  values: Sequence[str] = (),
589
+ text_values: Mapping[str, str] | None = None,
582
590
  guild_name: str | None = "Test Guild",
583
591
  message: MessageRef | None = None,
592
+ message_uses_layout: bool = False,
584
593
  ) -> None:
585
594
  self._message = message
586
595
  self._guild_name = guild_name
@@ -590,8 +599,11 @@ class FakeInteraction:
590
599
  self._options = dict(options or {})
591
600
  self._custom_id = custom_id
592
601
  self._values = tuple(values)
602
+ self._text_values = dict(text_values or {})
593
603
  self.responses: list[FakeResponse] = []
604
+ self.shown_modals: list[ModalSpec] = []
594
605
  self.deferred: bool | None = None
606
+ self._original_uses_layout = message_uses_layout
595
607
 
596
608
  @property
597
609
  def guild_id(self) -> int:
@@ -621,6 +633,10 @@ class FakeInteraction:
621
633
  def values(self) -> tuple[str, ...]:
622
634
  return self._values
623
635
 
636
+ @property
637
+ def text_values(self) -> Mapping[str, str]:
638
+ return self._text_values
639
+
624
640
  @property
625
641
  def message(self) -> MessageRef | None:
626
642
  return self._message
@@ -630,22 +646,49 @@ class FakeInteraction:
630
646
  content: str | None = None,
631
647
  *,
632
648
  embed: OutgoingEmbed | None = None,
649
+ layout: OutgoingLayout | None = None,
633
650
  ephemeral: bool = False,
634
651
  components: Sequence[Any] = (),
635
652
  ) -> None:
636
- 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
637
662
 
638
663
  async def defer(self, *, ephemeral: bool = False) -> None:
639
664
  self.deferred = ephemeral
640
665
 
666
+ async def show_modal(self, modal: ModalSpec) -> None:
667
+ validate_modal_spec(modal)
668
+ self.shown_modals.append(modal)
669
+
641
670
  async def edit_original(
642
671
  self,
643
672
  content: str | None = None,
644
673
  *,
645
674
  embed: OutgoingEmbed | None = None,
675
+ layout: OutgoingLayout | None = None,
646
676
  components: Sequence[Any] = (),
647
677
  ) -> None:
648
- 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
649
692
 
650
693
  async def follow_up(
651
694
  self, content: str, *, embed: OutgoingEmbed | None = None, ephemeral: bool = False
@@ -663,6 +706,8 @@ class FakeInteractions:
663
706
  def __init__(self, module_name: str) -> None:
664
707
  self.module_name = module_name
665
708
  self.commands: dict[str, tuple[CommandSpec, Callable[..., Any]]] = {}
709
+ self.guild_commands: dict[int, dict[str, tuple[CommandSpec, Callable[..., Any]]]] = {}
710
+ self.guild_autocompletes: dict[int, dict[str, Callable[..., Any]]] = {}
666
711
  self.components: dict[tuple[str, str], Callable[..., Any]] = {}
667
712
  self.component_min_tiers: dict[tuple[str, str], TrustTierName] = {}
668
713
  self.autocompletes: dict[str, Callable[..., Any]] = {}
@@ -686,6 +731,50 @@ class FakeInteractions:
686
731
  lambda: (self.commands.pop(qualified, None), self.autocompletes.pop(qualified, None))
687
732
  )
688
733
 
734
+ async def replace_guild_commands(
735
+ self,
736
+ guild_id: int,
737
+ commands: Sequence[GuildCommand],
738
+ ) -> None:
739
+ if guild_id <= 0:
740
+ raise ModuleContractError("guild_id must be a positive Discord snowflake")
741
+ desired: dict[str, tuple[CommandSpec, Callable[..., Any]]] = {}
742
+ autocompletes: dict[str, Callable[..., Any]] = {}
743
+ top_kinds: dict[str, str] = {}
744
+ for command in commands:
745
+ top_name = command.spec.group or command.spec.name
746
+ kind = "group" if command.spec.group else "command"
747
+ if top_name in self.commands:
748
+ raise ModuleContractError(
749
+ f"module {self.module_name!r} guild command {top_name!r} "
750
+ "would shadow a global command"
751
+ )
752
+ previous_kind = top_kinds.setdefault(top_name, kind)
753
+ if previous_kind != kind:
754
+ raise ModuleContractError(
755
+ f"module {self.module_name!r} guild command {top_name!r} "
756
+ "is both a command and a group"
757
+ )
758
+ qualified = (
759
+ f"{command.spec.group}.{command.spec.name}"
760
+ if command.spec.group
761
+ else command.spec.name
762
+ )
763
+ if qualified in desired:
764
+ raise ModuleContractError(
765
+ f"module {self.module_name!r} guild command {qualified!r} "
766
+ "is registered more than once"
767
+ )
768
+ desired[qualified] = (command.spec, command.handler)
769
+ if command.autocomplete is not None:
770
+ autocompletes[qualified] = command.autocomplete
771
+ if desired:
772
+ self.guild_commands[guild_id] = desired
773
+ self.guild_autocompletes[guild_id] = autocompletes
774
+ else:
775
+ self.guild_commands.pop(guild_id, None)
776
+ self.guild_autocompletes.pop(guild_id, None)
777
+
689
778
  def register_component(
690
779
  self,
691
780
  kind: str,
@@ -695,7 +784,7 @@ class FakeInteractions:
695
784
  expires_after_seconds: float | None = None,
696
785
  min_tier: TrustTierName = "member",
697
786
  ) -> _Closable:
698
- if kind not in ("button", "select"):
787
+ if kind not in ("button", "select", "modal"):
699
788
  raise ModuleContractError(f"unsupported component kind {kind!r}")
700
789
  build_custom_id(self.module_name, key)
701
790
  identity = (kind, key)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kimi-agent-module-api
3
- Version: 1.0.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
@@ -48,4 +48,14 @@ my_module = "my_module_package:SPEC"
48
48
  The [module guide](https://github.com/webhead2oo9/kimi-agent/blob/main/docs/modules.md)
49
49
  documents installation, declarations, lifecycle, and every runtime port. The
50
50
  [reference module](https://github.com/webhead2oo9/kimi-agent/tree/main/bot/modules/example)
51
- is a complete, commented example that exercises every port; start there.
51
+ is a complete, commented example that exercises most ports; start there.
52
+
53
+ Guild-scoped live command replacement was added in 1.1. Modules using
54
+ `InteractionRouter.replace_guild_commands()` should depend on
55
+ `kimi-agent-module-api>=1.1,<2` and require the host capability
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`.