kimi-agent-module-api 1.2.0__tar.gz → 1.3.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.
- {kimi_agent_module_api-1.2.0/src/kimi_agent_module_api.egg-info → kimi_agent_module_api-1.3.0}/PKG-INFO +13 -1
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/README.md +12 -0
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/pyproject.toml +8 -1
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/contracts.py +211 -0
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/events.py +2 -0
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/testing.py +216 -4
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/tools.py +4 -0
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0/src/kimi_agent_module_api.egg-info}/PKG-INFO +13 -1
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api.egg-info/SOURCES.txt +4 -1
- kimi_agent_module_api-1.3.0/tests/test_contracts.py +187 -0
- kimi_agent_module_api-1.3.0/tests/test_public_api.py +43 -0
- kimi_agent_module_api-1.3.0/tests/test_testing.py +114 -0
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/LICENSE +0 -0
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/setup.cfg +0 -0
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/__init__.py +0 -0
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/images.py +0 -0
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/py.typed +0 -0
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/settings.py +0 -0
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/trust.py +0 -0
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api.egg-info/dependency_links.txt +0 -0
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api.egg-info/requires.txt +0 -0
- {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.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.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Stable contracts for community-built assistant modules
|
|
5
5
|
Author: Webhead
|
|
6
6
|
License-Expression: MIT
|
|
@@ -59,3 +59,15 @@ Version 1.2 adds typed modal forms and a narrow Components V2 layout model. Once
|
|
|
59
59
|
that layout model, Discord requires every later edit of the same message to remain a layout.
|
|
60
60
|
Modules using them should depend on `kimi-agent-module-api>=1.2,<2` and require
|
|
61
61
|
`discord.modals.v1` and/or `discord.components_v2.v1`.
|
|
62
|
+
|
|
63
|
+
Version 1.3 adds cached author classification to message-deletion events:
|
|
64
|
+
`MessageDeleteEvent.author_is_bot` and `MessageBulkDeleteEvent.bot_message_ids`.
|
|
65
|
+
The values remain unknown for messages that were absent from Discord's cache.
|
|
66
|
+
|
|
67
|
+
## Testing the SDK
|
|
68
|
+
|
|
69
|
+
From this package directory, run its tests without installing the Kimi application:
|
|
70
|
+
|
|
71
|
+
```console
|
|
72
|
+
uv run --isolated --group test python -m pytest -q
|
|
73
|
+
```
|
|
@@ -42,3 +42,15 @@ Version 1.2 adds typed modal forms and a narrow Components V2 layout model. Once
|
|
|
42
42
|
that layout model, Discord requires every later edit of the same message to remain a layout.
|
|
43
43
|
Modules using them should depend on `kimi-agent-module-api>=1.2,<2` and require
|
|
44
44
|
`discord.modals.v1` and/or `discord.components_v2.v1`.
|
|
45
|
+
|
|
46
|
+
Version 1.3 adds cached author classification to message-deletion events:
|
|
47
|
+
`MessageDeleteEvent.author_is_bot` and `MessageBulkDeleteEvent.bot_message_ids`.
|
|
48
|
+
The values remain unknown for messages that were absent from Discord's cache.
|
|
49
|
+
|
|
50
|
+
## Testing the SDK
|
|
51
|
+
|
|
52
|
+
From this package directory, run its tests without installing the Kimi application:
|
|
53
|
+
|
|
54
|
+
```console
|
|
55
|
+
uv run --isolated --group test python -m pytest -q
|
|
56
|
+
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "kimi-agent-module-api"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.3.0"
|
|
4
4
|
description = "Stable contracts for community-built assistant modules"
|
|
5
5
|
requires-python = ">=3.14"
|
|
6
6
|
authors = [{ name = "Webhead" }]
|
|
@@ -18,6 +18,9 @@ Repository = "https://github.com/webhead2oo9/kimi-agent"
|
|
|
18
18
|
# kimi_agent_module_api.testing.MemoryStorage runs real SQL in unit tests.
|
|
19
19
|
testing = ["aiosqlite"]
|
|
20
20
|
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
test = ["aiosqlite", "pytest", "pytest-asyncio"]
|
|
23
|
+
|
|
21
24
|
[build-system]
|
|
22
25
|
requires = ["setuptools>=84"]
|
|
23
26
|
build-backend = "setuptools.build_meta"
|
|
@@ -27,3 +30,7 @@ where = ["src"]
|
|
|
27
30
|
|
|
28
31
|
[tool.setuptools.package-data]
|
|
29
32
|
kimi_agent_module_api = ["py.typed"]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
36
|
+
asyncio_default_fixture_loop_scope = "function"
|
{kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/contracts.py
RENAMED
|
@@ -12,6 +12,7 @@ owner manifest and enforced through the ports below; they are not a sandbox.
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
import json
|
|
15
|
+
import keyword
|
|
15
16
|
import math
|
|
16
17
|
import re
|
|
17
18
|
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
|
@@ -326,6 +327,13 @@ CORE_TOPIC_PREFIX = "discord"
|
|
|
326
327
|
_CORE_RESERVED_MODULE_NAMES = frozenset({CORE_TOPIC_PREFIX, "proposals"})
|
|
327
328
|
CUSTOM_ID_PREFIX = "m"
|
|
328
329
|
CUSTOM_ID_MAX_LENGTH = 100
|
|
330
|
+
# A modal ID also carries a fixed-width per-open suffix so two people opening the
|
|
331
|
+
# same form do not share one entry in the host's modal table. That suffix is not
|
|
332
|
+
# the module's to spend, so a modal's declared ID has a smaller budget than a
|
|
333
|
+
# button's. Fixed width on purpose: a variable one would shrink the budget as the
|
|
334
|
+
# host ran, letting a modal open successfully and then fail later.
|
|
335
|
+
MODAL_NONCE_CHARS = 8
|
|
336
|
+
MODAL_CUSTOM_ID_MAX_LENGTH = CUSTOM_ID_MAX_LENGTH - 1 - MODAL_NONCE_CHARS
|
|
329
337
|
|
|
330
338
|
|
|
331
339
|
def table_prefix(module_name: str) -> str:
|
|
@@ -987,6 +995,7 @@ class TrustLookup(Protocol):
|
|
|
987
995
|
|
|
988
996
|
|
|
989
997
|
type CommandOptionKind = Literal["string", "integer", "boolean", "user", "channel", "role"]
|
|
998
|
+
_OPTION_KINDS = frozenset({"string", "integer", "boolean", "user", "channel", "role"})
|
|
990
999
|
|
|
991
1000
|
|
|
992
1001
|
@dataclass(frozen=True, slots=True)
|
|
@@ -1147,6 +1156,208 @@ class ModalSpec:
|
|
|
1147
1156
|
parts: tuple[str, ...] = ()
|
|
1148
1157
|
|
|
1149
1158
|
|
|
1159
|
+
_OPTIONS_WITH_CHOICES = frozenset({"string", "integer"})
|
|
1160
|
+
# Keep command and group name validation in the SDK so the public fakes reject
|
|
1161
|
+
# the same payloads discord.py rejects while constructing the live tree.
|
|
1162
|
+
_COMMAND_NAME_RE = re.compile(
|
|
1163
|
+
r"^[-_\w\u0e31-\u0e3a\u0e47-\u0e4e\u0900-\u0903\u093a\u093b\u093c"
|
|
1164
|
+
r"\u093e\u093f\u0940-\u094f\u0955\u0956\u0957\u0962\u0963]{1,32}$"
|
|
1165
|
+
)
|
|
1166
|
+
_BUTTON_STYLES = frozenset({"primary", "secondary", "success", "danger"})
|
|
1167
|
+
_DISCORD_INTEGER_MAX = (1 << 53) - 1
|
|
1168
|
+
_DISCORD_INTEGER_MIN = -_DISCORD_INTEGER_MAX
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def validate_command_spec(spec: CommandSpec) -> None:
|
|
1172
|
+
"""Validate the Discord limits a command payload must satisfy.
|
|
1173
|
+
|
|
1174
|
+
A whole ``tree.sync()`` is one bulk PUT, so a single malformed command
|
|
1175
|
+
rejects every command in that scope. Discord.py checks command and group
|
|
1176
|
+
*names* itself and reorders required options ahead of optional ones; these
|
|
1177
|
+
are the limits nothing else enforces before the payload reaches Discord.
|
|
1178
|
+
"""
|
|
1179
|
+
|
|
1180
|
+
for label, name in (("command", spec.name), ("command group", spec.group)):
|
|
1181
|
+
if name is None:
|
|
1182
|
+
continue
|
|
1183
|
+
if (
|
|
1184
|
+
not isinstance(name, str)
|
|
1185
|
+
or _COMMAND_NAME_RE.fullmatch(name) is None
|
|
1186
|
+
or name.lower() != name
|
|
1187
|
+
):
|
|
1188
|
+
raise ModuleContractError(f"invalid {label} name {name!r}")
|
|
1189
|
+
if not isinstance(spec.description, str) or not 1 <= len(spec.description) <= 100:
|
|
1190
|
+
raise ModuleContractError("a command description must contain 1 to 100 characters")
|
|
1191
|
+
if spec.group is not None and (
|
|
1192
|
+
not isinstance(spec.group_description, str) or len(spec.group_description) > 100
|
|
1193
|
+
):
|
|
1194
|
+
raise ModuleContractError("a command group description cannot exceed 100 characters")
|
|
1195
|
+
if len(spec.options) > 25:
|
|
1196
|
+
raise ModuleContractError("a command cannot declare more than 25 options")
|
|
1197
|
+
|
|
1198
|
+
names: set[str] = set()
|
|
1199
|
+
for option in spec.options:
|
|
1200
|
+
# Discord applies the slash-command name grammar to option names, while
|
|
1201
|
+
# the host also builds each option into a Python parameter. Enforce the
|
|
1202
|
+
# intersection here: this keeps valid leading underscores and lowercase
|
|
1203
|
+
# Unicode identifiers while rejecting Discord-only names such as ``a-b``.
|
|
1204
|
+
if (
|
|
1205
|
+
not isinstance(option.name, str)
|
|
1206
|
+
or _COMMAND_NAME_RE.fullmatch(option.name) is None
|
|
1207
|
+
or option.name.lower() != option.name
|
|
1208
|
+
or not option.name.isidentifier()
|
|
1209
|
+
or keyword.iskeyword(option.name)
|
|
1210
|
+
):
|
|
1211
|
+
raise ModuleContractError(f"invalid command option name {option.name!r}")
|
|
1212
|
+
if option.name in names:
|
|
1213
|
+
raise ModuleContractError("command option names must be unique")
|
|
1214
|
+
names.add(option.name)
|
|
1215
|
+
if option.kind not in _OPTION_KINDS:
|
|
1216
|
+
raise ModuleContractError(f"unsupported command option kind {option.kind!r}")
|
|
1217
|
+
if not isinstance(option.description, str) or not 1 <= len(option.description) <= 100:
|
|
1218
|
+
raise ModuleContractError(
|
|
1219
|
+
f"the description for option {option.name!r} must contain 1 to 100 characters"
|
|
1220
|
+
)
|
|
1221
|
+
if option.choices and option.autocomplete:
|
|
1222
|
+
# Discord rejects a payload carrying both, and discord.py emits both.
|
|
1223
|
+
raise ModuleContractError(
|
|
1224
|
+
f"option {option.name!r} cannot use choices and autocomplete together"
|
|
1225
|
+
)
|
|
1226
|
+
if (option.choices or option.autocomplete) and option.kind not in _OPTIONS_WITH_CHOICES:
|
|
1227
|
+
raise ModuleContractError(
|
|
1228
|
+
f"option {option.name!r} cannot use choices or autocomplete on a "
|
|
1229
|
+
f"{option.kind} option"
|
|
1230
|
+
)
|
|
1231
|
+
if len(option.choices) > 25:
|
|
1232
|
+
raise ModuleContractError(f"option {option.name!r} cannot declare more than 25 choices")
|
|
1233
|
+
values: set[str | int] = set()
|
|
1234
|
+
for name, value in option.choices:
|
|
1235
|
+
if not isinstance(name, str) or not 1 <= len(name) <= 100:
|
|
1236
|
+
raise ModuleContractError(f"invalid choice name {name!r} on option {option.name!r}")
|
|
1237
|
+
if isinstance(value, bool) or not isinstance(value, str | int):
|
|
1238
|
+
raise ModuleContractError(
|
|
1239
|
+
f"invalid choice value {value!r} on option {option.name!r}"
|
|
1240
|
+
)
|
|
1241
|
+
# A choice whose value type disagrees with its option is a 400 on the
|
|
1242
|
+
# whole payload, and nothing downstream cross-checks the two.
|
|
1243
|
+
if not isinstance(value, str if option.kind == "string" else int):
|
|
1244
|
+
raise ModuleContractError(
|
|
1245
|
+
f"choice values on {option.kind} option {option.name!r} must be "
|
|
1246
|
+
f"{'strings' if option.kind == 'string' else 'integers'}"
|
|
1247
|
+
)
|
|
1248
|
+
if (
|
|
1249
|
+
option.kind == "integer"
|
|
1250
|
+
and isinstance(value, int)
|
|
1251
|
+
and not _DISCORD_INTEGER_MIN <= value <= _DISCORD_INTEGER_MAX
|
|
1252
|
+
):
|
|
1253
|
+
raise ModuleContractError(
|
|
1254
|
+
f"an integer choice value on option {option.name!r} must be between "
|
|
1255
|
+
f"{_DISCORD_INTEGER_MIN} and {_DISCORD_INTEGER_MAX}"
|
|
1256
|
+
)
|
|
1257
|
+
if isinstance(value, str) and not 1 <= len(value) <= 100:
|
|
1258
|
+
raise ModuleContractError(
|
|
1259
|
+
f"a choice value on option {option.name!r} must contain 1 to 100 characters"
|
|
1260
|
+
)
|
|
1261
|
+
if value in values:
|
|
1262
|
+
raise ModuleContractError(f"choice values on option {option.name!r} must be unique")
|
|
1263
|
+
values.add(value)
|
|
1264
|
+
for bound in (option.min_value, option.max_value):
|
|
1265
|
+
if bound is None:
|
|
1266
|
+
continue
|
|
1267
|
+
if isinstance(bound, bool) or not isinstance(bound, int):
|
|
1268
|
+
raise ModuleContractError(f"option {option.name!r} bounds must be integers")
|
|
1269
|
+
if option.kind != "integer":
|
|
1270
|
+
# Only integer options carry the bounds through to Discord, so a
|
|
1271
|
+
# bound anywhere else would be silently dropped.
|
|
1272
|
+
raise ModuleContractError(
|
|
1273
|
+
f"option {option.name!r} cannot set min_value or max_value on a "
|
|
1274
|
+
f"{option.kind} option"
|
|
1275
|
+
)
|
|
1276
|
+
if not _DISCORD_INTEGER_MIN <= bound <= _DISCORD_INTEGER_MAX:
|
|
1277
|
+
raise ModuleContractError(
|
|
1278
|
+
f"option {option.name!r} bounds must be between "
|
|
1279
|
+
f"{_DISCORD_INTEGER_MIN} and {_DISCORD_INTEGER_MAX}"
|
|
1280
|
+
)
|
|
1281
|
+
if (
|
|
1282
|
+
option.min_value is not None
|
|
1283
|
+
and option.max_value is not None
|
|
1284
|
+
and option.min_value > option.max_value
|
|
1285
|
+
):
|
|
1286
|
+
raise ModuleContractError(f"option {option.name!r} min_value cannot exceed max_value")
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def validate_button_spec(button: ButtonSpec) -> None:
|
|
1290
|
+
"""Validate Discord's hard button limits."""
|
|
1291
|
+
|
|
1292
|
+
if not isinstance(button.key, str) or not _TOPIC_SEGMENT_RE.fullmatch(button.key):
|
|
1293
|
+
raise ModuleContractError(f"invalid button key {button.key!r}")
|
|
1294
|
+
if any(not isinstance(part, str) or ":" in part for part in button.parts):
|
|
1295
|
+
raise ModuleContractError("button custom_id parts must be strings without ':'")
|
|
1296
|
+
if not isinstance(button.label, str) or len(button.label) > 80:
|
|
1297
|
+
raise ModuleContractError("a button label cannot exceed 80 characters")
|
|
1298
|
+
if button.style not in _BUTTON_STYLES:
|
|
1299
|
+
raise ModuleContractError(f"invalid button style {button.style!r}")
|
|
1300
|
+
if button.emoji is not None and (not isinstance(button.emoji, str) or not button.emoji):
|
|
1301
|
+
raise ModuleContractError("a button emoji must be a non-empty string")
|
|
1302
|
+
if not button.label and button.emoji is None:
|
|
1303
|
+
raise ModuleContractError("a button must provide a label or emoji")
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def validate_component_spec(component: Any) -> None:
|
|
1307
|
+
"""Validate one interactive component, whichever kind it is."""
|
|
1308
|
+
|
|
1309
|
+
if isinstance(component, ButtonSpec):
|
|
1310
|
+
validate_button_spec(component)
|
|
1311
|
+
elif isinstance(component, SelectSpec):
|
|
1312
|
+
validate_select_spec(component)
|
|
1313
|
+
else:
|
|
1314
|
+
raise ModuleContractError(f"unsupported component {component!r}")
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def validate_select_spec(select: SelectSpec) -> None:
|
|
1318
|
+
"""Validate Discord's hard select-menu limits."""
|
|
1319
|
+
|
|
1320
|
+
if not isinstance(select.key, str) or not _TOPIC_SEGMENT_RE.fullmatch(select.key):
|
|
1321
|
+
raise ModuleContractError(f"invalid select key {select.key!r}")
|
|
1322
|
+
if any(not isinstance(part, str) or ":" in part for part in select.parts):
|
|
1323
|
+
raise ModuleContractError("select custom_id parts must be strings without ':'")
|
|
1324
|
+
if select.placeholder is not None and (
|
|
1325
|
+
not isinstance(select.placeholder, str) or len(select.placeholder) > 150
|
|
1326
|
+
):
|
|
1327
|
+
raise ModuleContractError("a select placeholder cannot exceed 150 characters")
|
|
1328
|
+
if not 1 <= len(select.options) <= 25:
|
|
1329
|
+
raise ModuleContractError("a select must contain between one and 25 options")
|
|
1330
|
+
|
|
1331
|
+
values: set[str] = set()
|
|
1332
|
+
for label, value, description in select.options:
|
|
1333
|
+
if not isinstance(label, str) or not 1 <= len(label) <= 100:
|
|
1334
|
+
raise ModuleContractError("a select option label must contain 1 to 100 characters")
|
|
1335
|
+
if not isinstance(value, str) or not 1 <= len(value) <= 100:
|
|
1336
|
+
raise ModuleContractError("a select option value must contain 1 to 100 characters")
|
|
1337
|
+
if value in values:
|
|
1338
|
+
raise ModuleContractError("select option values must be unique")
|
|
1339
|
+
values.add(value)
|
|
1340
|
+
if description is not None and (
|
|
1341
|
+
not isinstance(description, str) or not 1 <= len(description) <= 100
|
|
1342
|
+
):
|
|
1343
|
+
raise ModuleContractError(
|
|
1344
|
+
"a select option description must contain 1 to 100 characters"
|
|
1345
|
+
)
|
|
1346
|
+
|
|
1347
|
+
for name, bound, low in (
|
|
1348
|
+
("min_values", select.min_values, 0),
|
|
1349
|
+
("max_values", select.max_values, 1),
|
|
1350
|
+
):
|
|
1351
|
+
if isinstance(bound, bool) or not isinstance(bound, int) or not low <= bound <= 25:
|
|
1352
|
+
raise ModuleContractError(f"select {name} must be between {low} and 25")
|
|
1353
|
+
if select.min_values > select.max_values:
|
|
1354
|
+
raise ModuleContractError("select min_values cannot exceed max_values")
|
|
1355
|
+
# max_values may exceed the option count (Discord clamps it), but a
|
|
1356
|
+
# minimum above the count can never be satisfied, so the select is dead.
|
|
1357
|
+
if select.min_values > len(select.options):
|
|
1358
|
+
raise ModuleContractError("select min_values cannot exceed the number of options")
|
|
1359
|
+
|
|
1360
|
+
|
|
1150
1361
|
def validate_modal_spec(modal: ModalSpec) -> None:
|
|
1151
1362
|
"""Validate Discord's hard modal and text-input limits."""
|
|
1152
1363
|
if not isinstance(modal.key, str) or not _TOPIC_SEGMENT_RE.fullmatch(modal.key):
|
{kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/events.py
RENAMED
|
@@ -69,11 +69,13 @@ class MessageDeleteEvent:
|
|
|
69
69
|
author_id: int | None
|
|
70
70
|
cached_content: str | None
|
|
71
71
|
cached_attachments: tuple[AttachmentSnapshot, ...]
|
|
72
|
+
author_is_bot: bool | None = None
|
|
72
73
|
|
|
73
74
|
|
|
74
75
|
@dataclass(frozen=True, slots=True)
|
|
75
76
|
class MessageBulkDeleteEvent:
|
|
76
77
|
refs: tuple[MessageRef, ...]
|
|
78
|
+
bot_message_ids: tuple[int, ...] = ()
|
|
77
79
|
|
|
78
80
|
|
|
79
81
|
@dataclass(frozen=True, slots=True)
|
{kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/testing.py
RENAMED
|
@@ -26,7 +26,9 @@ from kimi_agent_module_api.trust import TrustTier
|
|
|
26
26
|
|
|
27
27
|
from kimi_agent_module_api.contracts import (
|
|
28
28
|
ALL_DISCORD_ACTIONS,
|
|
29
|
+
MODAL_CUSTOM_ID_MAX_LENGTH,
|
|
29
30
|
Backoff,
|
|
31
|
+
ButtonSpec,
|
|
30
32
|
ChannelSnapshot,
|
|
31
33
|
CommandSpec,
|
|
32
34
|
GuildCommand,
|
|
@@ -58,10 +60,14 @@ from kimi_agent_module_api.contracts import (
|
|
|
58
60
|
ProposalState,
|
|
59
61
|
RoleSnapshot,
|
|
60
62
|
ScopedModuleMigration,
|
|
63
|
+
SelectSpec,
|
|
61
64
|
ServiceUnavailable,
|
|
62
65
|
TrustTierName,
|
|
63
66
|
UndeclaredDiscordAction,
|
|
64
67
|
build_custom_id,
|
|
68
|
+
parse_custom_id,
|
|
69
|
+
validate_command_spec,
|
|
70
|
+
validate_component_spec,
|
|
65
71
|
validate_modal_spec,
|
|
66
72
|
validate_layout_components,
|
|
67
73
|
validate_outgoing_layout,
|
|
@@ -590,6 +596,7 @@ class FakeInteraction:
|
|
|
590
596
|
guild_name: str | None = "Test Guild",
|
|
591
597
|
message: MessageRef | None = None,
|
|
592
598
|
message_uses_layout: bool = False,
|
|
599
|
+
module_name: str | None = None,
|
|
593
600
|
) -> None:
|
|
594
601
|
self._message = message
|
|
595
602
|
self._guild_name = guild_name
|
|
@@ -604,6 +611,31 @@ class FakeInteraction:
|
|
|
604
611
|
self.shown_modals: list[ModalSpec] = []
|
|
605
612
|
self.deferred: bool | None = None
|
|
606
613
|
self._original_uses_layout = message_uses_layout
|
|
614
|
+
parsed_custom_id = parse_custom_id(custom_id or "")
|
|
615
|
+
self._module_name = module_name or (
|
|
616
|
+
parsed_custom_id[0] if parsed_custom_id is not None else None
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
def _component_module_name(self) -> str:
|
|
620
|
+
if self._module_name is None:
|
|
621
|
+
raise ModuleContractError(
|
|
622
|
+
"FakeInteraction needs module_name to validate component custom_ids; "
|
|
623
|
+
"use FakeInteractions.interaction() or pass module_name explicitly"
|
|
624
|
+
)
|
|
625
|
+
return self._module_name
|
|
626
|
+
|
|
627
|
+
def _validate_components(
|
|
628
|
+
self, components: Sequence[Any], *, layout: OutgoingLayout | None
|
|
629
|
+
) -> None:
|
|
630
|
+
for component in components:
|
|
631
|
+
validate_component_spec(component)
|
|
632
|
+
if isinstance(component, ButtonSpec | SelectSpec):
|
|
633
|
+
build_custom_id(
|
|
634
|
+
self._component_module_name(),
|
|
635
|
+
component.key,
|
|
636
|
+
*component.parts,
|
|
637
|
+
)
|
|
638
|
+
validate_layout_components(components, layout=layout)
|
|
607
639
|
|
|
608
640
|
@property
|
|
609
641
|
def guild_id(self) -> int:
|
|
@@ -652,9 +684,9 @@ class FakeInteraction:
|
|
|
652
684
|
) -> None:
|
|
653
685
|
if layout is not None and (content is not None or embed is not None):
|
|
654
686
|
raise ModuleContractError("layout cannot be combined with content or embed")
|
|
687
|
+
self._validate_components(components, layout=layout)
|
|
655
688
|
if layout is not None:
|
|
656
689
|
validate_outgoing_layout(layout)
|
|
657
|
-
validate_layout_components(components, layout=layout)
|
|
658
690
|
self.responses.append(
|
|
659
691
|
FakeResponse(content, embed, ephemeral, tuple(components), "respond", layout=layout)
|
|
660
692
|
)
|
|
@@ -665,6 +697,11 @@ class FakeInteraction:
|
|
|
665
697
|
|
|
666
698
|
async def show_modal(self, modal: ModalSpec) -> None:
|
|
667
699
|
validate_modal_spec(modal)
|
|
700
|
+
declared = build_custom_id(self._component_module_name(), modal.key, *modal.parts)
|
|
701
|
+
if len(declared) > MODAL_CUSTOM_ID_MAX_LENGTH:
|
|
702
|
+
raise ModuleContractError(
|
|
703
|
+
f"modal custom_id exceeds {MODAL_CUSTOM_ID_MAX_LENGTH} characters"
|
|
704
|
+
)
|
|
668
705
|
self.shown_modals.append(modal)
|
|
669
706
|
|
|
670
707
|
async def edit_original(
|
|
@@ -681,9 +718,9 @@ class FakeInteraction:
|
|
|
681
718
|
)
|
|
682
719
|
if layout is not None and (content is not None or embed is not None):
|
|
683
720
|
raise ModuleContractError("layout cannot be combined with content or embed")
|
|
721
|
+
self._validate_components(components, layout=layout)
|
|
684
722
|
if layout is not None:
|
|
685
723
|
validate_outgoing_layout(layout)
|
|
686
|
-
validate_layout_components(components, layout=layout)
|
|
687
724
|
self.responses.append(
|
|
688
725
|
FakeResponse(content, embed, False, tuple(components), "edit", layout=layout)
|
|
689
726
|
)
|
|
@@ -700,17 +737,107 @@ class FakeInteraction:
|
|
|
700
737
|
return self.responses[-1]
|
|
701
738
|
|
|
702
739
|
|
|
740
|
+
@dataclass(slots=True)
|
|
741
|
+
class FakeInteractionOwnership:
|
|
742
|
+
"""Shared top-level command ownership for a multi-module test host."""
|
|
743
|
+
|
|
744
|
+
routers: list[FakeInteractions] = field(default_factory=list)
|
|
745
|
+
|
|
746
|
+
def register(self, router: FakeInteractions) -> None:
|
|
747
|
+
self.routers.append(router)
|
|
748
|
+
|
|
749
|
+
def unregister(self, router: FakeInteractions) -> None:
|
|
750
|
+
if router in self.routers:
|
|
751
|
+
self.routers.remove(router)
|
|
752
|
+
|
|
753
|
+
def global_top_names(self) -> set[str]:
|
|
754
|
+
return {top_name for router in self.routers for top_name in router._global_top_kinds()}
|
|
755
|
+
|
|
756
|
+
def guild_top_names(
|
|
757
|
+
self,
|
|
758
|
+
guild_id: int,
|
|
759
|
+
*,
|
|
760
|
+
excluding: FakeInteractions | None = None,
|
|
761
|
+
) -> set[str]:
|
|
762
|
+
return {
|
|
763
|
+
spec.group or spec.name
|
|
764
|
+
for router in self.routers
|
|
765
|
+
if router is not excluding
|
|
766
|
+
for spec, _handler in router.guild_commands.get(guild_id, {}).values()
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
def global_owner(self, top_name: str) -> tuple[FakeInteractions, str] | None:
|
|
770
|
+
for router in self.routers:
|
|
771
|
+
kind = router._global_top_kinds().get(top_name)
|
|
772
|
+
if kind is not None:
|
|
773
|
+
return router, kind
|
|
774
|
+
return None
|
|
775
|
+
|
|
776
|
+
def guild_owner(self, top_name: str, *, guild_id: int | None = None) -> FakeInteractions | None:
|
|
777
|
+
for router in self.routers:
|
|
778
|
+
guild_sets = (
|
|
779
|
+
(router.guild_commands.get(guild_id, {}),)
|
|
780
|
+
if guild_id is not None
|
|
781
|
+
else tuple(router.guild_commands.values())
|
|
782
|
+
)
|
|
783
|
+
for commands in guild_sets:
|
|
784
|
+
if any(
|
|
785
|
+
(spec.group or spec.name) == top_name for spec, _handler in commands.values()
|
|
786
|
+
):
|
|
787
|
+
return router
|
|
788
|
+
return None
|
|
789
|
+
|
|
790
|
+
|
|
703
791
|
class FakeInteractions:
|
|
704
792
|
"""Records command and component registrations; tests invoke handlers directly."""
|
|
705
793
|
|
|
706
|
-
def __init__(
|
|
794
|
+
def __init__(
|
|
795
|
+
self,
|
|
796
|
+
module_name: str,
|
|
797
|
+
*,
|
|
798
|
+
ownership: FakeInteractionOwnership | None = None,
|
|
799
|
+
is_guild_active: Callable[[int], bool] | None = None,
|
|
800
|
+
) -> None:
|
|
707
801
|
self.module_name = module_name
|
|
802
|
+
self.ownership = ownership or FakeInteractionOwnership()
|
|
803
|
+
self.ownership.register(self)
|
|
804
|
+
self._is_guild_active = (
|
|
805
|
+
is_guild_active if is_guild_active is not None else lambda _guild_id: True
|
|
806
|
+
)
|
|
708
807
|
self.commands: dict[str, tuple[CommandSpec, Callable[..., Any]]] = {}
|
|
709
808
|
self.guild_commands: dict[int, dict[str, tuple[CommandSpec, Callable[..., Any]]]] = {}
|
|
710
809
|
self.guild_autocompletes: dict[int, dict[str, Callable[..., Any]]] = {}
|
|
711
810
|
self.components: dict[tuple[str, str], Callable[..., Any]] = {}
|
|
712
811
|
self.component_min_tiers: dict[tuple[str, str], TrustTierName] = {}
|
|
713
812
|
self.autocompletes: dict[str, Callable[..., Any]] = {}
|
|
813
|
+
self._closed = False
|
|
814
|
+
|
|
815
|
+
def _ensure_open(self) -> None:
|
|
816
|
+
if self._closed:
|
|
817
|
+
raise RuntimeError(f"module {self.module_name!r} interactions are closed")
|
|
818
|
+
|
|
819
|
+
def interaction(self, **kwargs: Any) -> FakeInteraction:
|
|
820
|
+
"""Create an interaction bound to this router's exact custom-ID namespace."""
|
|
821
|
+
|
|
822
|
+
self._ensure_open()
|
|
823
|
+
supplied = kwargs.pop("module_name", self.module_name)
|
|
824
|
+
if supplied != self.module_name:
|
|
825
|
+
raise ValueError(
|
|
826
|
+
f"interaction module_name {supplied!r} does not match {self.module_name!r}"
|
|
827
|
+
)
|
|
828
|
+
return FakeInteraction(module_name=self.module_name, **kwargs)
|
|
829
|
+
|
|
830
|
+
def _global_top_kinds(self) -> dict[str, str]:
|
|
831
|
+
"""Top-level names the host tree would hold, derived so close() cannot drift.
|
|
832
|
+
|
|
833
|
+
Commands are stored by qualified name, but Discord and the host tree own
|
|
834
|
+
the *top* name, so every collision check has to look at that instead.
|
|
835
|
+
"""
|
|
836
|
+
|
|
837
|
+
return {
|
|
838
|
+
(spec.group or spec.name): ("group" if spec.group else "command")
|
|
839
|
+
for spec, _handler in self.commands.values()
|
|
840
|
+
}
|
|
714
841
|
|
|
715
842
|
def add_command(
|
|
716
843
|
self,
|
|
@@ -719,11 +846,45 @@ class FakeInteractions:
|
|
|
719
846
|
*,
|
|
720
847
|
autocomplete: Callable[..., Any] | None = None,
|
|
721
848
|
) -> _Closable:
|
|
849
|
+
self._ensure_open()
|
|
850
|
+
validate_command_spec(spec)
|
|
851
|
+
if autocomplete is None and any(option.autocomplete for option in spec.options):
|
|
852
|
+
raise ModuleContractError(
|
|
853
|
+
f"module {self.module_name!r} command {spec.name!r} declares an autocomplete "
|
|
854
|
+
"option but supplied no autocomplete handler"
|
|
855
|
+
)
|
|
722
856
|
qualified = f"{spec.group}.{spec.name}" if spec.group else spec.name
|
|
723
857
|
if qualified in self.commands:
|
|
724
858
|
raise ModuleContractError(
|
|
725
859
|
f"module {self.module_name!r} command {qualified!r} is already registered"
|
|
726
860
|
)
|
|
861
|
+
top_name = spec.group or spec.name
|
|
862
|
+
kind = "group" if spec.group else "command"
|
|
863
|
+
existing_global = self.ownership.global_owner(top_name)
|
|
864
|
+
if existing_global is not None and existing_global[0] is not self:
|
|
865
|
+
raise ModuleContractError(
|
|
866
|
+
f"module {self.module_name!r} command {top_name!r} is already owned"
|
|
867
|
+
)
|
|
868
|
+
if existing_global is not None and existing_global[1] != kind:
|
|
869
|
+
raise ModuleContractError(
|
|
870
|
+
f"module {self.module_name!r} command {top_name!r} is both a command and a group"
|
|
871
|
+
)
|
|
872
|
+
if self.ownership.guild_owner(top_name) is not None:
|
|
873
|
+
raise ModuleContractError(
|
|
874
|
+
f"module {self.module_name!r} global command {top_name!r} "
|
|
875
|
+
"would shadow a guild command"
|
|
876
|
+
)
|
|
877
|
+
if spec.group is not None:
|
|
878
|
+
group_size = sum(
|
|
879
|
+
existing.group == spec.group for existing, _handler in self.commands.values()
|
|
880
|
+
)
|
|
881
|
+
if group_size >= 25:
|
|
882
|
+
raise ModuleContractError(
|
|
883
|
+
f"module {self.module_name!r} command group {spec.group!r} "
|
|
884
|
+
"cannot contain more than 25 commands"
|
|
885
|
+
)
|
|
886
|
+
if existing_global is None and len(self.ownership.global_top_names()) >= 100:
|
|
887
|
+
raise ModuleContractError("Discord allows at most 100 global slash commands")
|
|
727
888
|
self.commands[qualified] = (spec, handler)
|
|
728
889
|
if autocomplete is not None:
|
|
729
890
|
self.autocompletes[qualified] = autocomplete
|
|
@@ -736,25 +897,53 @@ class FakeInteractions:
|
|
|
736
897
|
guild_id: int,
|
|
737
898
|
commands: Sequence[GuildCommand],
|
|
738
899
|
) -> None:
|
|
900
|
+
self._ensure_open()
|
|
739
901
|
if guild_id <= 0:
|
|
740
902
|
raise ModuleContractError("guild_id must be a positive Discord snowflake")
|
|
903
|
+
if commands and not self._is_guild_active(guild_id):
|
|
904
|
+
raise ModuleContractError(
|
|
905
|
+
f"module {self.module_name!r} is not active in guild {guild_id}"
|
|
906
|
+
)
|
|
741
907
|
desired: dict[str, tuple[CommandSpec, Callable[..., Any]]] = {}
|
|
742
908
|
autocompletes: dict[str, Callable[..., Any]] = {}
|
|
743
909
|
top_kinds: dict[str, str] = {}
|
|
910
|
+
group_sizes: dict[str, int] = {}
|
|
911
|
+
for command in commands:
|
|
912
|
+
validate_command_spec(command.spec)
|
|
913
|
+
if command.autocomplete is None and any(
|
|
914
|
+
option.autocomplete for option in command.spec.options
|
|
915
|
+
):
|
|
916
|
+
raise ModuleContractError(
|
|
917
|
+
f"module {self.module_name!r} command {command.spec.name!r} declares an "
|
|
918
|
+
"autocomplete option but supplied no autocomplete handler"
|
|
919
|
+
)
|
|
744
920
|
for command in commands:
|
|
745
921
|
top_name = command.spec.group or command.spec.name
|
|
746
922
|
kind = "group" if command.spec.group else "command"
|
|
747
|
-
if top_name
|
|
923
|
+
if self.ownership.global_owner(top_name) is not None:
|
|
748
924
|
raise ModuleContractError(
|
|
749
925
|
f"module {self.module_name!r} guild command {top_name!r} "
|
|
750
926
|
"would shadow a global command"
|
|
751
927
|
)
|
|
928
|
+
guild_owner = self.ownership.guild_owner(top_name, guild_id=guild_id)
|
|
929
|
+
if guild_owner is not None and guild_owner is not self:
|
|
930
|
+
raise ModuleContractError(
|
|
931
|
+
f"module {self.module_name!r} guild command {top_name!r} is already owned"
|
|
932
|
+
)
|
|
752
933
|
previous_kind = top_kinds.setdefault(top_name, kind)
|
|
753
934
|
if previous_kind != kind:
|
|
754
935
|
raise ModuleContractError(
|
|
755
936
|
f"module {self.module_name!r} guild command {top_name!r} "
|
|
756
937
|
"is both a command and a group"
|
|
757
938
|
)
|
|
939
|
+
if command.spec.group is not None:
|
|
940
|
+
group_size = group_sizes.get(command.spec.group, 0) + 1
|
|
941
|
+
if group_size > 25:
|
|
942
|
+
raise ModuleContractError(
|
|
943
|
+
f"module {self.module_name!r} guild command group "
|
|
944
|
+
f"{command.spec.group!r} cannot contain more than 25 commands"
|
|
945
|
+
)
|
|
946
|
+
group_sizes[command.spec.group] = group_size
|
|
758
947
|
qualified = (
|
|
759
948
|
f"{command.spec.group}.{command.spec.name}"
|
|
760
949
|
if command.spec.group
|
|
@@ -768,6 +957,9 @@ class FakeInteractions:
|
|
|
768
957
|
desired[qualified] = (command.spec, command.handler)
|
|
769
958
|
if command.autocomplete is not None:
|
|
770
959
|
autocompletes[qualified] = command.autocomplete
|
|
960
|
+
other_top_names = self.ownership.guild_top_names(guild_id, excluding=self)
|
|
961
|
+
if len(other_top_names | set(top_kinds)) > 100:
|
|
962
|
+
raise ModuleContractError(f"guild {guild_id} would have more than 100 slash commands")
|
|
771
963
|
if desired:
|
|
772
964
|
self.guild_commands[guild_id] = desired
|
|
773
965
|
self.guild_autocompletes[guild_id] = autocompletes
|
|
@@ -784,6 +976,7 @@ class FakeInteractions:
|
|
|
784
976
|
expires_after_seconds: float | None = None,
|
|
785
977
|
min_tier: TrustTierName = "member",
|
|
786
978
|
) -> _Closable:
|
|
979
|
+
self._ensure_open()
|
|
787
980
|
if kind not in ("button", "select", "modal"):
|
|
788
981
|
raise ModuleContractError(f"unsupported component kind {kind!r}")
|
|
789
982
|
build_custom_id(self.module_name, key)
|
|
@@ -802,8 +995,23 @@ class FakeInteractions:
|
|
|
802
995
|
)
|
|
803
996
|
|
|
804
997
|
def custom_id(self, key: str, *parts: str) -> str:
|
|
998
|
+
self._ensure_open()
|
|
805
999
|
return build_custom_id(self.module_name, key, *parts)
|
|
806
1000
|
|
|
1001
|
+
def close(self) -> None:
|
|
1002
|
+
"""Release fake registrations and shared ownership like the live router."""
|
|
1003
|
+
|
|
1004
|
+
if self._closed:
|
|
1005
|
+
return
|
|
1006
|
+
self._closed = True
|
|
1007
|
+
self.commands.clear()
|
|
1008
|
+
self.guild_commands.clear()
|
|
1009
|
+
self.guild_autocompletes.clear()
|
|
1010
|
+
self.components.clear()
|
|
1011
|
+
self.component_min_tiers.clear()
|
|
1012
|
+
self.autocompletes.clear()
|
|
1013
|
+
self.ownership.unregister(self)
|
|
1014
|
+
|
|
807
1015
|
|
|
808
1016
|
class FakeGuildSettings:
|
|
809
1017
|
def __init__(
|
|
@@ -1038,6 +1246,7 @@ class RecordedTool:
|
|
|
1038
1246
|
owner_only: bool
|
|
1039
1247
|
guild_only: bool
|
|
1040
1248
|
guild_ids: frozenset[int] | None
|
|
1249
|
+
untrusted: bool
|
|
1041
1250
|
|
|
1042
1251
|
|
|
1043
1252
|
class RecordingToolRegistry:
|
|
@@ -1058,6 +1267,7 @@ class RecordingToolRegistry:
|
|
|
1058
1267
|
owner_only: bool = False,
|
|
1059
1268
|
guild_only: bool = True,
|
|
1060
1269
|
guild_ids: frozenset[int] | None = None,
|
|
1270
|
+
untrusted: bool = True,
|
|
1061
1271
|
) -> None:
|
|
1062
1272
|
self.tools[name] = RecordedTool(
|
|
1063
1273
|
description,
|
|
@@ -1068,6 +1278,7 @@ class RecordingToolRegistry:
|
|
|
1068
1278
|
owner_only,
|
|
1069
1279
|
guild_only,
|
|
1070
1280
|
guild_ids,
|
|
1281
|
+
untrusted,
|
|
1071
1282
|
)
|
|
1072
1283
|
|
|
1073
1284
|
|
|
@@ -1110,6 +1321,7 @@ __all__ = [
|
|
|
1110
1321
|
"FakeHealth",
|
|
1111
1322
|
"FakeHttp",
|
|
1112
1323
|
"FakeInteraction",
|
|
1324
|
+
"FakeInteractionOwnership",
|
|
1113
1325
|
"FakeInteractions",
|
|
1114
1326
|
"FakeProposals",
|
|
1115
1327
|
"FakeResponse",
|
{kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/tools.py
RENAMED
|
@@ -43,6 +43,9 @@ class ModuleToolRegistry(Protocol):
|
|
|
43
43
|
``guild_only`` (the default) it is also hidden from DMs and personal chat,
|
|
44
44
|
so its handler always sees a guild. ``guild_ids`` further scopes a tool to
|
|
45
45
|
specific guilds (``None`` is everywhere; an empty set is nowhere).
|
|
46
|
+
Results default to untrusted because module tools commonly return Discord,
|
|
47
|
+
network, or user-authored data. Set ``untrusted=False`` only for output that
|
|
48
|
+
is wholly controlled by the installed module.
|
|
46
49
|
"""
|
|
47
50
|
|
|
48
51
|
def register(
|
|
@@ -57,6 +60,7 @@ class ModuleToolRegistry(Protocol):
|
|
|
57
60
|
owner_only: bool = False,
|
|
58
61
|
guild_only: bool = True,
|
|
59
62
|
guild_ids: frozenset[int] | None = None,
|
|
63
|
+
untrusted: bool = True,
|
|
60
64
|
) -> None: ...
|
|
61
65
|
|
|
62
66
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kimi-agent-module-api
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Stable contracts for community-built assistant modules
|
|
5
5
|
Author: Webhead
|
|
6
6
|
License-Expression: MIT
|
|
@@ -59,3 +59,15 @@ Version 1.2 adds typed modal forms and a narrow Components V2 layout model. Once
|
|
|
59
59
|
that layout model, Discord requires every later edit of the same message to remain a layout.
|
|
60
60
|
Modules using them should depend on `kimi-agent-module-api>=1.2,<2` and require
|
|
61
61
|
`discord.modals.v1` and/or `discord.components_v2.v1`.
|
|
62
|
+
|
|
63
|
+
Version 1.3 adds cached author classification to message-deletion events:
|
|
64
|
+
`MessageDeleteEvent.author_is_bot` and `MessageBulkDeleteEvent.bot_message_ids`.
|
|
65
|
+
The values remain unknown for messages that were absent from Discord's cache.
|
|
66
|
+
|
|
67
|
+
## Testing the SDK
|
|
68
|
+
|
|
69
|
+
From this package directory, run its tests without installing the Kimi application:
|
|
70
|
+
|
|
71
|
+
```console
|
|
72
|
+
uv run --isolated --group test python -m pytest -q
|
|
73
|
+
```
|
|
@@ -14,4 +14,7 @@ src/kimi_agent_module_api.egg-info/PKG-INFO
|
|
|
14
14
|
src/kimi_agent_module_api.egg-info/SOURCES.txt
|
|
15
15
|
src/kimi_agent_module_api.egg-info/dependency_links.txt
|
|
16
16
|
src/kimi_agent_module_api.egg-info/requires.txt
|
|
17
|
-
src/kimi_agent_module_api.egg-info/top_level.txt
|
|
17
|
+
src/kimi_agent_module_api.egg-info/top_level.txt
|
|
18
|
+
tests/test_contracts.py
|
|
19
|
+
tests/test_public_api.py
|
|
20
|
+
tests/test_testing.py
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Representative contract tests owned by the standalone SDK package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from pydantic_settings import BaseSettings
|
|
10
|
+
|
|
11
|
+
from kimi_agent_module_api import (
|
|
12
|
+
BASELINE_CAPABILITIES,
|
|
13
|
+
MODULE_API_VERSION,
|
|
14
|
+
ModuleCapabilities,
|
|
15
|
+
ModuleLoadContext,
|
|
16
|
+
ModulePermissions,
|
|
17
|
+
ModuleRuntimeContext,
|
|
18
|
+
ModuleSpec,
|
|
19
|
+
ModuleToolContext,
|
|
20
|
+
TrustTier,
|
|
21
|
+
render_guild_settings,
|
|
22
|
+
)
|
|
23
|
+
from kimi_agent_module_api.contracts import (
|
|
24
|
+
Backoff,
|
|
25
|
+
ButtonSpec,
|
|
26
|
+
CommandSpec,
|
|
27
|
+
EventTopicError,
|
|
28
|
+
GuildSettingField,
|
|
29
|
+
GuildSettingsSchema,
|
|
30
|
+
ModuleContractError,
|
|
31
|
+
ServiceDeclaration,
|
|
32
|
+
ServiceRequirement,
|
|
33
|
+
build_custom_id,
|
|
34
|
+
parse_custom_id,
|
|
35
|
+
split_topic,
|
|
36
|
+
table_prefix,
|
|
37
|
+
validate_command_spec,
|
|
38
|
+
validate_component_spec,
|
|
39
|
+
validate_guild_settings_schema,
|
|
40
|
+
validate_module_name,
|
|
41
|
+
validate_permissions,
|
|
42
|
+
validate_publish_topic,
|
|
43
|
+
validate_services,
|
|
44
|
+
validate_subscription,
|
|
45
|
+
)
|
|
46
|
+
from kimi_agent_module_api.events import CORE_TOPICS
|
|
47
|
+
from kimi_agent_module_api.images import looks_like_image_attachment, sniff_image_media_type
|
|
48
|
+
from kimi_agent_module_api.testing import load_context
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DemoSettings(BaseSettings):
|
|
52
|
+
greeting: str = "hello"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OtherSettings(BaseSettings):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _spec(name: str = "demo") -> ModuleSpec:
|
|
60
|
+
def create(_ctx: ModuleLoadContext) -> object:
|
|
61
|
+
raise AssertionError("not called")
|
|
62
|
+
|
|
63
|
+
return ModuleSpec(name=name, version="1.0.0", create=create) # type: ignore[arg-type]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_spec_and_runtime_context_keep_stable_defaults() -> None:
|
|
67
|
+
spec = _spec()
|
|
68
|
+
assert spec.api_version == MODULE_API_VERSION == 1
|
|
69
|
+
assert spec.permissions == ModulePermissions()
|
|
70
|
+
assert spec.dependencies == ()
|
|
71
|
+
required = {
|
|
72
|
+
field.name
|
|
73
|
+
for field in dataclasses.fields(ModuleRuntimeContext)
|
|
74
|
+
if field.default is dataclasses.MISSING
|
|
75
|
+
}
|
|
76
|
+
assert {"events", "scheduler", "storage", "discord", "interactions", "services"} <= required
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_load_context_exercises_public_create_helpers() -> None:
|
|
80
|
+
settings = DemoSettings(greeting="hi")
|
|
81
|
+
context, recorder = load_context(settings)
|
|
82
|
+
|
|
83
|
+
async def handler(_arguments: dict[str, Any], _ctx: ModuleToolContext) -> str:
|
|
84
|
+
return "ok"
|
|
85
|
+
|
|
86
|
+
assert context.settings_for(DemoSettings) is settings
|
|
87
|
+
context.registry.register("demo", "Demo", {"type": "object"}, handler)
|
|
88
|
+
context.register_tool_labels({"demo": "Doing a demo"})
|
|
89
|
+
context.declare_surface_tools("default", ("demo",))
|
|
90
|
+
|
|
91
|
+
assert recorder.labels == {"demo": "Doing a demo"}
|
|
92
|
+
assert recorder.surfaces == {"default": ("demo",)}
|
|
93
|
+
assert recorder.registry.tools["demo"].untrusted is True
|
|
94
|
+
assert context.capabilities.available == BASELINE_CAPABILITIES
|
|
95
|
+
with pytest.raises(TypeError, match="prepared module settings"):
|
|
96
|
+
context.settings_for(OtherSettings)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_capabilities_and_trust_tiers_fail_closed() -> None:
|
|
100
|
+
capabilities = ModuleCapabilities(BASELINE_CAPABILITIES, False, False)
|
|
101
|
+
capabilities.require("discord.history.v1")
|
|
102
|
+
with pytest.raises(RuntimeError, match="does not provide"):
|
|
103
|
+
capabilities.require("discord.guild_commands.v1")
|
|
104
|
+
assert TrustTier.STAFF > TrustTier.REGULAR > TrustTier.MEMBER
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.parametrize("name", ["Bad", "two words", "discord", "proposals"])
|
|
108
|
+
def test_module_names_reject_reserved_or_malformed_values(name: str) -> None:
|
|
109
|
+
with pytest.raises(ModuleContractError):
|
|
110
|
+
validate_module_name(name)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_topics_and_component_ids_enforce_module_namespaces() -> None:
|
|
114
|
+
assert table_prefix("audit-log") == "audit_log"
|
|
115
|
+
assert split_topic("discord.message") == ("discord", "message")
|
|
116
|
+
validate_publish_topic("case-manager", "case_manager.changed")
|
|
117
|
+
validate_subscription(
|
|
118
|
+
"case_audit",
|
|
119
|
+
ModulePermissions(event_topics=("case_manager.*",)),
|
|
120
|
+
"case_manager.changed",
|
|
121
|
+
)
|
|
122
|
+
custom_id = build_custom_id("case_manager", "approve", "123")
|
|
123
|
+
assert parse_custom_id(custom_id) == ("case_manager", "approve", ("123",))
|
|
124
|
+
with pytest.raises(EventTopicError):
|
|
125
|
+
validate_publish_topic("case-manager", "other.changed")
|
|
126
|
+
with pytest.raises(ModuleContractError):
|
|
127
|
+
build_custom_id("case_manager", "approve", "bad:part")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_permissions_services_and_guild_settings_validate_together() -> None:
|
|
131
|
+
permissions = ModulePermissions(
|
|
132
|
+
discord_actions=frozenset({"send_message"}),
|
|
133
|
+
event_topics=("discord.message",),
|
|
134
|
+
)
|
|
135
|
+
validate_permissions("demo", permissions)
|
|
136
|
+
validate_services(
|
|
137
|
+
"consumer",
|
|
138
|
+
("provider",),
|
|
139
|
+
(),
|
|
140
|
+
(ServiceRequirement("records.cases", 1, provider="provider"),),
|
|
141
|
+
)
|
|
142
|
+
validate_services("provider", (), (ServiceDeclaration("records.cases", 1),), ())
|
|
143
|
+
schema = GuildSettingsSchema(
|
|
144
|
+
fields=(GuildSettingField("mode", "enum", choices=("safe", "fast"), default="safe"),)
|
|
145
|
+
)
|
|
146
|
+
validate_guild_settings_schema("demo", schema)
|
|
147
|
+
|
|
148
|
+
with pytest.raises(ModuleContractError):
|
|
149
|
+
validate_permissions("demo", ModulePermissions(discord_actions=frozenset({"nuke"})))
|
|
150
|
+
with pytest.raises(ModuleContractError):
|
|
151
|
+
validate_guild_settings_schema(
|
|
152
|
+
"demo",
|
|
153
|
+
GuildSettingsSchema(fields=(GuildSettingField("Bad", "int"),)),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_command_and_component_validation_match_discord_limits() -> None:
|
|
158
|
+
validate_command_spec(CommandSpec(name="ping", description="Ping the module"))
|
|
159
|
+
validate_component_spec(ButtonSpec("confirm", "Confirm"))
|
|
160
|
+
with pytest.raises(ModuleContractError):
|
|
161
|
+
validate_command_spec(CommandSpec(name="Bad", description="invalid name"))
|
|
162
|
+
with pytest.raises(ModuleContractError):
|
|
163
|
+
validate_component_spec(ButtonSpec("confirm", ""))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_scheduler_backoff_rejects_non_finite_or_non_positive_values() -> None:
|
|
167
|
+
assert Backoff(base_seconds=2, max_seconds=8, multiplier=2) == Backoff(2, 8, 2)
|
|
168
|
+
for invalid in (0, -1, float("nan"), float("inf")):
|
|
169
|
+
with pytest.raises(ModuleContractError, match="backoff"):
|
|
170
|
+
Backoff(base_seconds=invalid)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_rendered_settings_are_deterministic_and_safe() -> None:
|
|
174
|
+
assert render_guild_settings({"b": True, "a": [1, 2], "c": "x: y", "empty": None}) == (
|
|
175
|
+
'---\na: [1, 2]\nb: true\nc: "x: y"\n---\n'
|
|
176
|
+
)
|
|
177
|
+
with pytest.raises(ValueError, match="invalid guild setting name"):
|
|
178
|
+
render_guild_settings({"bad:key": True})
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_event_and_image_helpers_remain_host_independent() -> None:
|
|
182
|
+
assert CORE_TOPICS
|
|
183
|
+
assert all(split_topic(topic)[0] == "discord" for topic in CORE_TOPICS)
|
|
184
|
+
assert looks_like_image_attachment("photo.PNG", None)
|
|
185
|
+
assert looks_like_image_attachment(None, "image/jpeg")
|
|
186
|
+
assert sniff_image_media_type(b"\x89PNG\r\n\x1a\nrest") == "image/png"
|
|
187
|
+
assert sniff_image_media_type(b"not an image") is None
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The published SDK stays importable without the Kimi application runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import importlib.util
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import kimi_agent_module_api as api
|
|
11
|
+
|
|
12
|
+
SDK_ROOT = Path(__file__).resolve().parents[1] / "src" / "kimi_agent_module_api"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_public_exports_resolve() -> None:
|
|
16
|
+
assert api.__all__
|
|
17
|
+
assert all(getattr(api, name, None) is not None for name in api.__all__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_standalone_environment_has_no_core_runtime() -> None:
|
|
21
|
+
"""CI runs this package from an isolated environment, outside core's import root."""
|
|
22
|
+
assert importlib.util.find_spec("app") is None
|
|
23
|
+
assert importlib.util.find_spec("discord") is None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_sdk_imports_only_declared_dependencies() -> None:
|
|
27
|
+
allowed = set(sys.stdlib_module_names) | {
|
|
28
|
+
"aiosqlite",
|
|
29
|
+
"kimi_agent_module_api",
|
|
30
|
+
"pydantic_settings",
|
|
31
|
+
}
|
|
32
|
+
unexpected: dict[str, set[str]] = {}
|
|
33
|
+
for path in SDK_ROOT.glob("*.py"):
|
|
34
|
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
35
|
+
imported: set[str] = set()
|
|
36
|
+
for node in ast.walk(tree):
|
|
37
|
+
if isinstance(node, ast.Import):
|
|
38
|
+
imported.update(alias.name.split(".")[0] for alias in node.names)
|
|
39
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
40
|
+
imported.add(node.module.split(".")[0])
|
|
41
|
+
if outside := imported - allowed:
|
|
42
|
+
unexpected[path.name] = outside
|
|
43
|
+
assert unexpected == {}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""The SDK's protocol fakes work without importing host implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from kimi_agent_module_api.contracts import (
|
|
8
|
+
Backoff,
|
|
9
|
+
Event,
|
|
10
|
+
JobRun,
|
|
11
|
+
MessageRef,
|
|
12
|
+
MigrationContext,
|
|
13
|
+
ServiceUnavailable,
|
|
14
|
+
UndeclaredDiscordAction,
|
|
15
|
+
)
|
|
16
|
+
from kimi_agent_module_api.testing import (
|
|
17
|
+
FakeDiscordActions,
|
|
18
|
+
FakeEvents,
|
|
19
|
+
FakeInteraction,
|
|
20
|
+
FakeScheduler,
|
|
21
|
+
FakeServiceRegistry,
|
|
22
|
+
MemoryStorage,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.mark.asyncio
|
|
27
|
+
async def test_fake_events_deliver_only_matching_subscriptions() -> None:
|
|
28
|
+
events = FakeEvents("demo")
|
|
29
|
+
seen: list[str] = []
|
|
30
|
+
|
|
31
|
+
async def handler(event: Event) -> None:
|
|
32
|
+
seen.append(event.topic)
|
|
33
|
+
|
|
34
|
+
registration = events.subscribe("discord.*", handler)
|
|
35
|
+
assert await events.deliver("discord.message", {"id": 1}) == 1
|
|
36
|
+
registration.close()
|
|
37
|
+
assert await events.deliver("discord.message_edit", {"id": 1}) == 0
|
|
38
|
+
assert seen == ["discord.message"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.asyncio
|
|
42
|
+
async def test_fake_scheduler_retries_then_settles_jobs() -> None:
|
|
43
|
+
scheduler = FakeScheduler()
|
|
44
|
+
attempts: list[int] = []
|
|
45
|
+
|
|
46
|
+
async def flaky(run: JobRun) -> None:
|
|
47
|
+
attempts.append(run.attempt)
|
|
48
|
+
if run.attempt == 1:
|
|
49
|
+
raise RuntimeError("retry")
|
|
50
|
+
|
|
51
|
+
scheduler.register("work", flaky)
|
|
52
|
+
await scheduler.run_every(
|
|
53
|
+
"job",
|
|
54
|
+
60,
|
|
55
|
+
"work",
|
|
56
|
+
backoff=Backoff(base_seconds=5, max_seconds=10, multiplier=2),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
assert await scheduler.run_due(0) == 1
|
|
60
|
+
assert scheduler.jobs["job"].run_at == 5
|
|
61
|
+
assert await scheduler.run_due(5) == 1
|
|
62
|
+
assert scheduler.jobs["job"].run_at == 65
|
|
63
|
+
assert attempts == [1, 2]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_fake_discord_actions_enforce_declared_permissions() -> None:
|
|
68
|
+
actions = FakeDiscordActions("demo", frozenset({"send_message"}))
|
|
69
|
+
ref = await actions.send_message(42, "hello")
|
|
70
|
+
|
|
71
|
+
assert isinstance(ref, MessageRef)
|
|
72
|
+
assert actions.calls_for("send_message")[0].args[:2] == (42, "hello")
|
|
73
|
+
with pytest.raises(UndeclaredDiscordAction):
|
|
74
|
+
await actions.fetch_message(ref)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.asyncio
|
|
78
|
+
async def test_fake_interaction_records_responses() -> None:
|
|
79
|
+
interaction = FakeInteraction(module_name="demo")
|
|
80
|
+
await interaction.respond("done", ephemeral=True)
|
|
81
|
+
await interaction.follow_up("more")
|
|
82
|
+
|
|
83
|
+
assert interaction.responses[0].content == "done"
|
|
84
|
+
assert interaction.responses[0].ephemeral is True
|
|
85
|
+
assert interaction.last.content == "more"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_fake_service_proxy_closes_with_its_registration() -> None:
|
|
89
|
+
class Board:
|
|
90
|
+
def answer(self) -> int:
|
|
91
|
+
return 42
|
|
92
|
+
|
|
93
|
+
registry = FakeServiceRegistry()
|
|
94
|
+
registration = registry.provide("records.board", 1, Board())
|
|
95
|
+
proxy = registry.get("records.board", 1, Board)
|
|
96
|
+
|
|
97
|
+
assert proxy.answer() == 42
|
|
98
|
+
registration.close()
|
|
99
|
+
with pytest.raises(ServiceUnavailable):
|
|
100
|
+
proxy.answer()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.asyncio
|
|
104
|
+
async def test_memory_storage_runs_scoped_migrations_and_transactions() -> None:
|
|
105
|
+
async def create(ctx: MigrationContext) -> None:
|
|
106
|
+
await ctx.connection.execute(f"CREATE TABLE {ctx.table('rows')} (value INTEGER)")
|
|
107
|
+
|
|
108
|
+
async with MemoryStorage.open("my-module") as storage:
|
|
109
|
+
assert storage.table("rows") == '"my_module_rows"'
|
|
110
|
+
await storage.migrate((("001", create),))
|
|
111
|
+
async with storage.write_transaction() as connection:
|
|
112
|
+
await connection.execute('INSERT INTO "my_module_rows" (value) VALUES (7)')
|
|
113
|
+
cursor = await storage.connection.execute('SELECT value FROM "my_module_rows"')
|
|
114
|
+
assert await cursor.fetchone() == (7,)
|
|
File without changes
|
|
File without changes
|
{kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/__init__.py
RENAMED
|
File without changes
|
{kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/images.py
RENAMED
|
File without changes
|
{kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/py.typed
RENAMED
|
File without changes
|
{kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/settings.py
RENAMED
|
File without changes
|
{kimi_agent_module_api-1.2.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/trust.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|