kimi-agent-module-api 2.1.0__tar.gz → 2.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.
Files changed (24) hide show
  1. {kimi_agent_module_api-2.1.0/src/kimi_agent_module_api.egg-info → kimi_agent_module_api-2.3.0}/PKG-INFO +24 -6
  2. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/README.md +23 -5
  3. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/pyproject.toml +1 -1
  4. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/src/kimi_agent_module_api/__init__.py +9 -0
  5. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/src/kimi_agent_module_api/contracts.py +2 -0
  6. kimi_agent_module_api-2.3.0/src/kimi_agent_module_api/files.py +61 -0
  7. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/src/kimi_agent_module_api/testing.py +66 -0
  8. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/src/kimi_agent_module_api/tools.py +39 -1
  9. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0/src/kimi_agent_module_api.egg-info}/PKG-INFO +24 -6
  10. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/src/kimi_agent_module_api.egg-info/SOURCES.txt +2 -0
  11. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/tests/test_contracts.py +62 -3
  12. kimi_agent_module_api-2.3.0/tests/test_files.py +41 -0
  13. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/LICENSE +0 -0
  14. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/setup.cfg +0 -0
  15. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/src/kimi_agent_module_api/events.py +0 -0
  16. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/src/kimi_agent_module_api/images.py +0 -0
  17. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/src/kimi_agent_module_api/py.typed +0 -0
  18. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/src/kimi_agent_module_api/settings.py +0 -0
  19. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/src/kimi_agent_module_api/trust.py +0 -0
  20. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/src/kimi_agent_module_api.egg-info/dependency_links.txt +0 -0
  21. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/src/kimi_agent_module_api.egg-info/requires.txt +0 -0
  22. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/src/kimi_agent_module_api.egg-info/top_level.txt +0 -0
  23. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/tests/test_public_api.py +0 -0
  24. {kimi_agent_module_api-2.1.0 → kimi_agent_module_api-2.3.0}/tests/test_testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kimi-agent-module-api
3
- Version: 2.1.0
3
+ Version: 2.3.0
4
4
  Summary: Stable contracts for community-built assistant modules
5
5
  Author: Webhead
6
6
  License-Expression: MIT
@@ -30,6 +30,8 @@ implementation, or module loader. It exports:
30
30
  (storage, scheduler, events, Discord actions, interactions, HTTP, services,
31
31
  trust, proposals, health) plus the validators the host runs at preflight.
32
32
  - `kimi_agent_module_api.events`: the normalized `discord.*` event payloads.
33
+ - `kimi_agent_module_api.files`: `ToolFiles`, `ToolAttachment`, `ToolFile`, and
34
+ `FileAccessError` for invocation-scoped attachment and workspace reads.
33
35
  - `kimi_agent_module_api.testing`: a fake for every port, `load_context()` for
34
36
  exercising `create()`, and `MemoryStorage` (install the `testing` extra) so a
35
37
  module can unit test itself with only this package installed.
@@ -82,15 +84,31 @@ Message-deletion events include cached author classification:
82
84
  `MessageDeleteEvent.author_is_bot` and `MessageBulkDeleteEvent.bot_message_ids`.
83
85
  The values remain unknown for messages that were absent from Discord's cache.
84
86
 
85
- SDK 2.1 adds `ModuleToolContext.trigger_discord_message_id`. Mention-path tool
86
- calls receive the exact source Discord message snowflake; other surfaces receive
87
- `None`. Modules that act on a user's source message should require
88
- `kimi-agent-module-api>=2.1,<3`, reject `None`, fetch that exact message through
89
- `ctx.discord`, and verify its author before acting.
87
+ SDK 2.1 adds `ModuleToolContext.trigger_discord_message_id`. SDK 2.3 adds
88
+ `trigger_discord_message_snapshot`, an immutable host-owned capture of the
89
+ message, guild, channel, author, content, and bot status made at turn entry.
90
+ Mention-path tool calls receive both; personal and non-message surfaces receive
91
+ `None`. Modules that need authoritative evidence from the triggering message
92
+ should require `kimi-agent-module-api>=2.3,<3` and use the snapshot instead of
93
+ re-fetching mutable or deletable Discord state.
90
94
 
91
95
  Modules use namespaced guild documents and the physical table names returned
92
96
  by `ctx.storage.table()`.
93
97
 
98
+ SDK 2.2 adds optional `ModuleToolContext.files`. Declare
99
+ `ModulePermissions(tool_files=True)`, depend on `kimi-agent-module-api>=2.2,<3`,
100
+ and require `tools.files.v1` in `ModuleSpec.requires_capabilities`. Existing modules
101
+ keep `api_version=2` and receive `files=None` without the permission.
102
+ `ctx.files.attachments` lists admitted current/reply entries;
103
+ `read_attachment(id, max_bytes=...)` and `read_workspace(path, max_bytes=...)`
104
+ return bounded bytes without network downloads. The reader expires when the handler
105
+ returns and is scoped to the actual caller. `testing.FakeToolFiles` supports
106
+ independent tests. Module `ctx.http` methods apply an 8 MiB host ceiling even when
107
+ callers supply `max_bytes`; `download` buffers and validates the bounded response
108
+ before yielding chunks so connections are released on early consumer exit. See the
109
+ [file access guide](https://github.com/webhead2oo9/kimi-agent/blob/main/docs/module-files.md)
110
+ for moderation, reply-image availability, privacy, and limits.
111
+
94
112
  ## Testing the SDK
95
113
 
96
114
  From this package directory, run its tests without installing the Kimi application:
@@ -13,6 +13,8 @@ implementation, or module loader. It exports:
13
13
  (storage, scheduler, events, Discord actions, interactions, HTTP, services,
14
14
  trust, proposals, health) plus the validators the host runs at preflight.
15
15
  - `kimi_agent_module_api.events`: the normalized `discord.*` event payloads.
16
+ - `kimi_agent_module_api.files`: `ToolFiles`, `ToolAttachment`, `ToolFile`, and
17
+ `FileAccessError` for invocation-scoped attachment and workspace reads.
16
18
  - `kimi_agent_module_api.testing`: a fake for every port, `load_context()` for
17
19
  exercising `create()`, and `MemoryStorage` (install the `testing` extra) so a
18
20
  module can unit test itself with only this package installed.
@@ -65,15 +67,31 @@ Message-deletion events include cached author classification:
65
67
  `MessageDeleteEvent.author_is_bot` and `MessageBulkDeleteEvent.bot_message_ids`.
66
68
  The values remain unknown for messages that were absent from Discord's cache.
67
69
 
68
- SDK 2.1 adds `ModuleToolContext.trigger_discord_message_id`. Mention-path tool
69
- calls receive the exact source Discord message snowflake; other surfaces receive
70
- `None`. Modules that act on a user's source message should require
71
- `kimi-agent-module-api>=2.1,<3`, reject `None`, fetch that exact message through
72
- `ctx.discord`, and verify its author before acting.
70
+ SDK 2.1 adds `ModuleToolContext.trigger_discord_message_id`. SDK 2.3 adds
71
+ `trigger_discord_message_snapshot`, an immutable host-owned capture of the
72
+ message, guild, channel, author, content, and bot status made at turn entry.
73
+ Mention-path tool calls receive both; personal and non-message surfaces receive
74
+ `None`. Modules that need authoritative evidence from the triggering message
75
+ should require `kimi-agent-module-api>=2.3,<3` and use the snapshot instead of
76
+ re-fetching mutable or deletable Discord state.
73
77
 
74
78
  Modules use namespaced guild documents and the physical table names returned
75
79
  by `ctx.storage.table()`.
76
80
 
81
+ SDK 2.2 adds optional `ModuleToolContext.files`. Declare
82
+ `ModulePermissions(tool_files=True)`, depend on `kimi-agent-module-api>=2.2,<3`,
83
+ and require `tools.files.v1` in `ModuleSpec.requires_capabilities`. Existing modules
84
+ keep `api_version=2` and receive `files=None` without the permission.
85
+ `ctx.files.attachments` lists admitted current/reply entries;
86
+ `read_attachment(id, max_bytes=...)` and `read_workspace(path, max_bytes=...)`
87
+ return bounded bytes without network downloads. The reader expires when the handler
88
+ returns and is scoped to the actual caller. `testing.FakeToolFiles` supports
89
+ independent tests. Module `ctx.http` methods apply an 8 MiB host ceiling even when
90
+ callers supply `max_bytes`; `download` buffers and validates the bounded response
91
+ before yielding chunks so connections are released on early consumer exit. See the
92
+ [file access guide](https://github.com/webhead2oo9/kimi-agent/blob/main/docs/module-files.md)
93
+ for moderation, reply-image availability, privacy, and limits.
94
+
77
95
  ## Testing the SDK
78
96
 
79
97
  From this package directory, run its tests without installing the Kimi application:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kimi-agent-module-api"
3
- version = "2.1.0"
3
+ version = "2.3.0"
4
4
  description = "Stable contracts for community-built assistant modules"
5
5
  requires-python = ">=3.14"
6
6
  authors = [{ name = "Webhead" }]
@@ -37,10 +37,13 @@ from kimi_agent_module_api.contracts import (
37
37
  TextInputStyle,
38
38
  )
39
39
  from kimi_agent_module_api.settings import ModuleSetting, ModuleSettingsDefinition
40
+ from kimi_agent_module_api.files import FileAccessError, ToolAttachment, ToolFile, ToolFiles
40
41
  from kimi_agent_module_api.tools import (
41
42
  ModuleToolContext,
42
43
  ModuleToolHandler,
43
44
  ModuleToolRegistry,
45
+ ModuleTurnBudget,
46
+ TriggeringDiscordMessageSnapshot,
44
47
  )
45
48
  from kimi_agent_module_api.trust import TrustTier
46
49
 
@@ -152,6 +155,7 @@ __all__ = [
152
155
  "MODULE_ENTRYPOINT_GROUP",
153
156
  "AppModule",
154
157
  "ConfigSnapshot",
158
+ "FileAccessError",
155
159
  "GuildSettingsSchema",
156
160
  "InviteSnapshot",
157
161
  "LayoutGallery",
@@ -171,6 +175,7 @@ __all__ = [
171
175
  "ModuleToolContext",
172
176
  "ModuleToolHandler",
173
177
  "ModuleToolRegistry",
178
+ "ModuleTurnBudget",
174
179
  "OutgoingLayout",
175
180
  "ProposalActor",
176
181
  "ProposalError",
@@ -183,6 +188,10 @@ __all__ = [
183
188
  "ServiceRequirement",
184
189
  "TextInputSpec",
185
190
  "TextInputStyle",
191
+ "ToolAttachment",
192
+ "ToolFile",
193
+ "ToolFiles",
194
+ "TriggeringDiscordMessageSnapshot",
186
195
  "TrustTier",
187
196
  "render_guild_settings",
188
197
  ]
@@ -143,6 +143,8 @@ class ModulePermissions:
143
143
  override_target_policy: bool = False
144
144
  raw_bot: bool = False
145
145
  raw_storage: bool = False
146
+ # Read admitted attachments and caller-owned workspace files in tool handlers.
147
+ tool_files: bool = False
146
148
 
147
149
 
148
150
  @dataclass(frozen=True, slots=True)
@@ -0,0 +1,61 @@
1
+ """Read-only, caller-scoped file access during one module tool invocation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Literal, Protocol
7
+
8
+
9
+ class FileAccessError(ValueError):
10
+ """A bounded, safe-to-display file error; never includes host paths or URLs."""
11
+
12
+ def __init__(self, code: str, message: str) -> None:
13
+ super().__init__(message)
14
+ self.code = code
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class ToolAttachment:
19
+ """An admitted attachment or an explicit unavailable entry.
20
+
21
+ ``id`` is opaque and valid only for this invocation. ``workspace_path`` is
22
+ relative to the caller's workspace, and may be reused in later turns.
23
+ Reply images have generated filenames when the host has no source name.
24
+ No Discord SDK objects, signed URLs, or absolute paths are exposed.
25
+ """
26
+
27
+ id: str
28
+ filename: str
29
+ size: int
30
+ media_type: str | None
31
+ source: Literal["current", "reply"] = "current"
32
+ workspace_path: str | None = None
33
+ unavailable_reason: str | None = None
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class ToolFile:
38
+ filename: str
39
+ media_type: str | None
40
+ data: bytes = field(repr=False)
41
+
42
+
43
+ class ToolFiles(Protocol):
44
+ """Available only when ``permissions.tool_files`` is declared.
45
+
46
+ Reads are bounded by the requested limit and host limits, and share an
47
+ aggregate byte budget per invocation. They use the caller's workspace and
48
+ admitted turn inputs; they never fetch arbitrary URLs or re-fetch Discord.
49
+ The port expires when the handler returns. Copying bytes does not extend
50
+ the host's privacy/retention guarantees; modules own any further persistence.
51
+ """
52
+
53
+ @property
54
+ def attachments(self) -> tuple[ToolAttachment, ...]: ...
55
+
56
+ async def read_attachment(self, attachment_id: str, *, max_bytes: int) -> ToolFile: ...
57
+
58
+ async def read_workspace(self, path: str, *, max_bytes: int) -> ToolFile: ...
59
+
60
+
61
+ __all__ = ["FileAccessError", "ToolAttachment", "ToolFile", "ToolFiles"]
@@ -23,6 +23,7 @@ from typing import Any, TypeVar, overload
23
23
  from pydantic_settings import BaseSettings
24
24
 
25
25
  from kimi_agent_module_api.trust import TrustTier
26
+ from kimi_agent_module_api.files import FileAccessError, ToolAttachment, ToolFile
26
27
 
27
28
  from kimi_agent_module_api.contracts import (
28
29
  ALL_DISCORD_ACTIONS,
@@ -1313,6 +1314,70 @@ def load_context(
1313
1314
  return context, recorder
1314
1315
 
1315
1316
 
1317
+ class FakeToolFiles:
1318
+ """In-memory invocation files; paths and IDs are exact fixture keys.
1319
+
1320
+ ``close()`` simulates handler return. Bounds and aggregate accounting match
1321
+ the host. This fake never reads the test process's filesystem or network.
1322
+ """
1323
+
1324
+ def __init__(
1325
+ self,
1326
+ attachments: tuple[ToolAttachment, ...] = (),
1327
+ *,
1328
+ attachment_files: Mapping[str, ToolFile] | None = None,
1329
+ workspace_files: Mapping[str, ToolFile] | None = None,
1330
+ max_bytes: int = 50 * 1024 * 1024,
1331
+ ) -> None:
1332
+ self._attachments = attachments
1333
+ self.attachment_files = dict(attachment_files or {})
1334
+ self.workspace_files = dict(workspace_files or {})
1335
+ self.reads: list[tuple[str, str, int]] = []
1336
+ self._remaining = min(max_bytes, 50 * 1024 * 1024)
1337
+ self._active = True
1338
+
1339
+ def close(self) -> None:
1340
+ self._active = False
1341
+
1342
+ def _check_active(self) -> None:
1343
+ if not self._active:
1344
+ raise FileAccessError("expired", "File access expired when the tool invocation ended.")
1345
+
1346
+ @property
1347
+ def attachments(self) -> tuple[ToolAttachment, ...]:
1348
+ self._check_active()
1349
+ return self._attachments
1350
+
1351
+ def _read(self, file: ToolFile | None, max_bytes: int) -> ToolFile:
1352
+ self._check_active()
1353
+ if type(max_bytes) is not int or max_bytes < 1:
1354
+ raise FileAccessError("invalid_limit", "max_bytes must be a positive integer.")
1355
+ if self._remaining <= 0:
1356
+ raise FileAccessError(
1357
+ "budget_exhausted", "The invocation's file read budget is exhausted."
1358
+ )
1359
+ if file is None:
1360
+ raise FileAccessError("unavailable", "File is not available in this fixture.")
1361
+ if len(file.data) > min(max_bytes, self._remaining):
1362
+ raise FileAccessError("too_large", "File exceeds the file read limit.")
1363
+ self._remaining -= len(file.data)
1364
+ return file
1365
+
1366
+ async def read_attachment(self, attachment_id: str, *, max_bytes: int) -> ToolFile:
1367
+ self._check_active()
1368
+ entry = next((item for item in self.attachments if item.id == attachment_id), None)
1369
+ if entry is None:
1370
+ raise FileAccessError("unknown_attachment", "Unknown attachment for this invocation.")
1371
+ if entry.unavailable_reason:
1372
+ raise FileAccessError("unavailable", entry.unavailable_reason)
1373
+ self.reads.append(("attachment", attachment_id, max_bytes))
1374
+ return self._read(self.attachment_files.get(attachment_id), max_bytes)
1375
+
1376
+ async def read_workspace(self, path: str, *, max_bytes: int) -> ToolFile:
1377
+ self.reads.append(("workspace", path, max_bytes))
1378
+ return self._read(self.workspace_files.get(path), max_bytes)
1379
+
1380
+
1316
1381
  __all__ = [
1317
1382
  "DiscordCall",
1318
1383
  "FakeDiscordActions",
@@ -1327,6 +1392,7 @@ __all__ = [
1327
1392
  "FakeResponse",
1328
1393
  "FakeScheduler",
1329
1394
  "FakeServiceRegistry",
1395
+ "FakeToolFiles",
1330
1396
  "FakeTrust",
1331
1397
  "LoadContextRecorder",
1332
1398
  "MemoryStorage",
@@ -6,9 +6,31 @@ from collections.abc import Callable, Coroutine, Mapping
6
6
  from dataclasses import dataclass, field
7
7
  from typing import Any, Protocol
8
8
 
9
+ from kimi_agent_module_api.files import ToolFiles
9
10
  from kimi_agent_module_api.trust import TrustTier
10
11
 
11
12
 
13
+ class ModuleTurnBudget(Protocol):
14
+ """Host-owned counters shared by every module call in one outer model turn."""
15
+
16
+ def consume(self, name: str, limit: int) -> bool:
17
+ """Consume one named allowance, or return ``False`` without changing it."""
18
+
19
+ ...
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class TriggeringDiscordMessageSnapshot:
24
+ """Immutable evidence captured from the Discord message at turn entry."""
25
+
26
+ message_id: int
27
+ guild_id: int
28
+ channel_id: int
29
+ author_id: int
30
+ content: str
31
+ author_is_bot: bool
32
+
33
+
12
34
  @dataclass(frozen=True, slots=True)
13
35
  class ModuleToolContext:
14
36
  """Who is calling a module tool, and from where.
@@ -32,6 +54,16 @@ class ModuleToolContext:
32
54
  # Discord message that initiated this model turn. It is absent for personal
33
55
  # app commands and other surfaces that are not rooted in a Discord message.
34
56
  trigger_discord_message_id: int | None = None
57
+ # Invocation-scoped read-only port. Declare permissions.tool_files and
58
+ # require tools.files.v1; never retain this port beyond the handler.
59
+ files: ToolFiles | None = None
60
+ # Host-owned values captured before turn preparation can await. Unlike a
61
+ # later Discord fetch, this evidence cannot change or disappear mid-turn.
62
+ # Added after all API 2.2 fields to preserve positional construction.
63
+ trigger_discord_message_snapshot: TriggeringDiscordMessageSnapshot | None = None
64
+ # Mutable host-owned port whose lifetime is the complete outer model turn.
65
+ # Counters are automatically namespaced to the installed module.
66
+ turn_budget: ModuleTurnBudget | None = None
35
67
 
36
68
 
37
69
  type ModuleToolHandler = Callable[[dict[str, Any], ModuleToolContext], Coroutine[Any, Any, str]]
@@ -67,4 +99,10 @@ class ModuleToolRegistry(Protocol):
67
99
  ) -> None: ...
68
100
 
69
101
 
70
- __all__ = ["ModuleToolContext", "ModuleToolHandler", "ModuleToolRegistry"]
102
+ __all__ = [
103
+ "ModuleToolContext",
104
+ "ModuleToolHandler",
105
+ "ModuleToolRegistry",
106
+ "ModuleTurnBudget",
107
+ "TriggeringDiscordMessageSnapshot",
108
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kimi-agent-module-api
3
- Version: 2.1.0
3
+ Version: 2.3.0
4
4
  Summary: Stable contracts for community-built assistant modules
5
5
  Author: Webhead
6
6
  License-Expression: MIT
@@ -30,6 +30,8 @@ implementation, or module loader. It exports:
30
30
  (storage, scheduler, events, Discord actions, interactions, HTTP, services,
31
31
  trust, proposals, health) plus the validators the host runs at preflight.
32
32
  - `kimi_agent_module_api.events`: the normalized `discord.*` event payloads.
33
+ - `kimi_agent_module_api.files`: `ToolFiles`, `ToolAttachment`, `ToolFile`, and
34
+ `FileAccessError` for invocation-scoped attachment and workspace reads.
33
35
  - `kimi_agent_module_api.testing`: a fake for every port, `load_context()` for
34
36
  exercising `create()`, and `MemoryStorage` (install the `testing` extra) so a
35
37
  module can unit test itself with only this package installed.
@@ -82,15 +84,31 @@ Message-deletion events include cached author classification:
82
84
  `MessageDeleteEvent.author_is_bot` and `MessageBulkDeleteEvent.bot_message_ids`.
83
85
  The values remain unknown for messages that were absent from Discord's cache.
84
86
 
85
- SDK 2.1 adds `ModuleToolContext.trigger_discord_message_id`. Mention-path tool
86
- calls receive the exact source Discord message snowflake; other surfaces receive
87
- `None`. Modules that act on a user's source message should require
88
- `kimi-agent-module-api>=2.1,<3`, reject `None`, fetch that exact message through
89
- `ctx.discord`, and verify its author before acting.
87
+ SDK 2.1 adds `ModuleToolContext.trigger_discord_message_id`. SDK 2.3 adds
88
+ `trigger_discord_message_snapshot`, an immutable host-owned capture of the
89
+ message, guild, channel, author, content, and bot status made at turn entry.
90
+ Mention-path tool calls receive both; personal and non-message surfaces receive
91
+ `None`. Modules that need authoritative evidence from the triggering message
92
+ should require `kimi-agent-module-api>=2.3,<3` and use the snapshot instead of
93
+ re-fetching mutable or deletable Discord state.
90
94
 
91
95
  Modules use namespaced guild documents and the physical table names returned
92
96
  by `ctx.storage.table()`.
93
97
 
98
+ SDK 2.2 adds optional `ModuleToolContext.files`. Declare
99
+ `ModulePermissions(tool_files=True)`, depend on `kimi-agent-module-api>=2.2,<3`,
100
+ and require `tools.files.v1` in `ModuleSpec.requires_capabilities`. Existing modules
101
+ keep `api_version=2` and receive `files=None` without the permission.
102
+ `ctx.files.attachments` lists admitted current/reply entries;
103
+ `read_attachment(id, max_bytes=...)` and `read_workspace(path, max_bytes=...)`
104
+ return bounded bytes without network downloads. The reader expires when the handler
105
+ returns and is scoped to the actual caller. `testing.FakeToolFiles` supports
106
+ independent tests. Module `ctx.http` methods apply an 8 MiB host ceiling even when
107
+ callers supply `max_bytes`; `download` buffers and validates the bounded response
108
+ before yielding chunks so connections are released on early consumer exit. See the
109
+ [file access guide](https://github.com/webhead2oo9/kimi-agent/blob/main/docs/module-files.md)
110
+ for moderation, reply-image availability, privacy, and limits.
111
+
94
112
  ## Testing the SDK
95
113
 
96
114
  From this package directory, run its tests without installing the Kimi application:
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  src/kimi_agent_module_api/__init__.py
5
5
  src/kimi_agent_module_api/contracts.py
6
6
  src/kimi_agent_module_api/events.py
7
+ src/kimi_agent_module_api/files.py
7
8
  src/kimi_agent_module_api/images.py
8
9
  src/kimi_agent_module_api/py.typed
9
10
  src/kimi_agent_module_api/settings.py
@@ -16,5 +17,6 @@ src/kimi_agent_module_api.egg-info/dependency_links.txt
16
17
  src/kimi_agent_module_api.egg-info/requires.txt
17
18
  src/kimi_agent_module_api.egg-info/top_level.txt
18
19
  tests/test_contracts.py
20
+ tests/test_files.py
19
21
  tests/test_public_api.py
20
22
  tests/test_testing.py
@@ -2,8 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from importlib.metadata import version
6
5
  import dataclasses
6
+ from importlib.metadata import version
7
7
  from typing import Any
8
8
 
9
9
  import pytest
@@ -19,6 +19,7 @@ from kimi_agent_module_api import (
19
19
  ModuleRuntimeContext,
20
20
  ModuleSpec,
21
21
  ModuleToolContext,
22
+ TriggeringDiscordMessageSnapshot,
22
23
  TrustTier,
23
24
  render_guild_settings,
24
25
  )
@@ -83,8 +84,66 @@ def test_spec_and_runtime_context_keep_stable_defaults() -> None:
83
84
  assert {"events", "scheduler", "storage", "discord", "interactions", "services"} <= required
84
85
 
85
86
 
86
- def test_distribution_version_includes_trigger_message_contract() -> None:
87
- assert version("kimi-agent-module-api") == "2.1.0"
87
+ def test_distribution_version_includes_triggering_message_snapshot_contract() -> None:
88
+ assert version("kimi-agent-module-api") == "2.3.0"
89
+
90
+
91
+ def test_triggering_discord_message_snapshot_is_immutable() -> None:
92
+ snapshot = TriggeringDiscordMessageSnapshot(
93
+ message_id=11,
94
+ guild_id=22,
95
+ channel_id=33,
96
+ author_id=44,
97
+ content="original",
98
+ author_is_bot=False,
99
+ )
100
+
101
+ with pytest.raises(dataclasses.FrozenInstanceError):
102
+ snapshot.content = "edited" # type: ignore[misc]
103
+
104
+
105
+ def test_module_tool_context_preserves_api_2_2_nine_positional_arguments() -> None:
106
+ files = object()
107
+
108
+ context = ModuleToolContext(
109
+ 11,
110
+ "Alice",
111
+ 22,
112
+ 33,
113
+ 44,
114
+ TrustTier.REGULAR,
115
+ {"demo": {"enabled": True}},
116
+ 55,
117
+ files, # type: ignore[arg-type]
118
+ )
119
+
120
+ assert context.files is files
121
+ assert context.trigger_discord_message_snapshot is None
122
+
123
+
124
+ def test_module_tool_context_adds_turn_budget_after_prior_positional_fields() -> None:
125
+ class Budget:
126
+ def consume(self, name: str, limit: int) -> bool:
127
+ return bool(name) and limit > 0
128
+
129
+ snapshot = TriggeringDiscordMessageSnapshot(55, 22, 33, 11, "content", False)
130
+ budget = Budget()
131
+ context = ModuleToolContext(
132
+ 11,
133
+ "Alice",
134
+ 22,
135
+ 33,
136
+ 44,
137
+ TrustTier.REGULAR,
138
+ {},
139
+ 55,
140
+ None,
141
+ snapshot,
142
+ budget,
143
+ )
144
+
145
+ assert context.trigger_discord_message_snapshot is snapshot
146
+ assert context.turn_budget is budget
88
147
 
89
148
 
90
149
  def test_spec_requires_an_explicit_keyword_api_version() -> None:
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from kimi_agent_module_api import FileAccessError, ToolAttachment, ToolFile, ToolFiles
6
+ from kimi_agent_module_api.testing import FakeToolFiles
7
+
8
+
9
+ @pytest.mark.asyncio
10
+ async def test_fake_preserves_bytes_and_enforces_aggregate_budget() -> None:
11
+ attachment = ToolAttachment("a", "image.png", 3, "image/png")
12
+ file = ToolFile("image.png", "image/png", b"raw")
13
+ fake = FakeToolFiles(
14
+ (attachment,),
15
+ attachment_files={"a": file},
16
+ workspace_files={"saved.png": file},
17
+ max_bytes=5,
18
+ )
19
+ port: ToolFiles = fake
20
+ assert (await port.read_attachment("a", max_bytes=3)).data == b"raw"
21
+ with pytest.raises(FileAccessError, match="limit"):
22
+ await port.read_workspace("saved.png", max_bytes=3)
23
+ assert "raw" not in repr(file)
24
+ fake.close()
25
+ with pytest.raises(FileAccessError) as error:
26
+ await port.read_attachment("a", max_bytes=3)
27
+ assert error.value.code == "expired"
28
+
29
+
30
+ @pytest.mark.asyncio
31
+ async def test_fake_does_not_read_unavailable_attachments() -> None:
32
+ fake = FakeToolFiles(
33
+ (ToolAttachment("a", "audio.wav", 3, "audio/wav", unavailable_reason="Blocked"),),
34
+ attachment_files={"a": ToolFile("audio.wav", "audio/wav", b"raw")},
35
+ )
36
+ with pytest.raises(FileAccessError, match="Blocked"):
37
+ await fake.read_attachment("a", max_bytes=3)
38
+ with pytest.raises(FileAccessError) as error:
39
+ await fake.read_attachment("other", max_bytes=3)
40
+ assert error.value.code == "unknown_attachment"
41
+ assert not fake.reads