kimi-agent-module-api 2.2.0__tar.gz → 2.4.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 (26) hide show
  1. {kimi_agent_module_api-2.2.0/src/kimi_agent_module_api.egg-info → kimi_agent_module_api-2.4.0}/PKG-INFO +24 -7
  2. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/README.md +23 -6
  3. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/pyproject.toml +1 -1
  4. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/__init__.py +21 -0
  5. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/contracts.py +7 -0
  6. kimi_agent_module_api-2.4.0/src/kimi_agent_module_api/scheduled_results.py +105 -0
  7. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/testing.py +98 -0
  8. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/tools.py +36 -2
  9. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0/src/kimi_agent_module_api.egg-info}/PKG-INFO +24 -7
  10. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api.egg-info/SOURCES.txt +2 -0
  11. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/tests/test_contracts.py +62 -3
  12. kimi_agent_module_api-2.4.0/tests/test_scheduled_results.py +99 -0
  13. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/LICENSE +0 -0
  14. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/setup.cfg +0 -0
  15. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/events.py +0 -0
  16. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/files.py +0 -0
  17. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/images.py +0 -0
  18. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/py.typed +0 -0
  19. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/settings.py +0 -0
  20. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/trust.py +0 -0
  21. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api.egg-info/dependency_links.txt +0 -0
  22. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api.egg-info/requires.txt +0 -0
  23. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api.egg-info/top_level.txt +0 -0
  24. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/tests/test_files.py +0 -0
  25. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.0}/tests/test_public_api.py +0 -0
  26. {kimi_agent_module_api-2.2.0 → kimi_agent_module_api-2.4.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.2.0
3
+ Version: 2.4.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.scheduled_results`: durable subscriptions to published
34
+ task output, typed results, and bounded attachment readers (SDK 2.4).
33
35
  - `kimi_agent_module_api.files`: `ToolFiles`, `ToolAttachment`, `ToolFile`, and
34
36
  `FileAccessError` for invocation-scoped attachment and workspace reads.
35
37
  - `kimi_agent_module_api.testing`: a fake for every port, `load_context()` for
@@ -84,11 +86,13 @@ Message-deletion events include cached author classification:
84
86
  `MessageDeleteEvent.author_is_bot` and `MessageBulkDeleteEvent.bot_message_ids`.
85
87
  The values remain unknown for messages that were absent from Discord's cache.
86
88
 
87
- SDK 2.1 adds `ModuleToolContext.trigger_discord_message_id`. Mention-path tool
88
- calls receive the exact source Discord message snowflake; other surfaces receive
89
- `None`. Modules that act on a user's source message should require
90
- `kimi-agent-module-api>=2.1,<3`, reject `None`, fetch that exact message through
91
- `ctx.discord`, and verify its author before acting.
89
+ SDK 2.1 adds `ModuleToolContext.trigger_discord_message_id`. SDK 2.3 adds
90
+ `trigger_discord_message_snapshot`, an immutable host-owned capture of the
91
+ message, guild, channel, author, content, and bot status made at turn entry.
92
+ Mention-path tool calls receive both; personal and non-message surfaces receive
93
+ `None`. Modules that need authoritative evidence from the triggering message
94
+ should require `kimi-agent-module-api>=2.3,<3` and use the snapshot instead of
95
+ re-fetching mutable or deletable Discord state.
92
96
 
93
97
  Modules use namespaced guild documents and the physical table names returned
94
98
  by `ctx.storage.table()`.
@@ -101,7 +105,9 @@ keep `api_version=2` and receive `files=None` without the permission.
101
105
  `read_attachment(id, max_bytes=...)` and `read_workspace(path, max_bytes=...)`
102
106
  return bounded bytes without network downloads. The reader expires when the handler
103
107
  returns and is scoped to the actual caller. `testing.FakeToolFiles` supports
104
- independent tests. See the
108
+ independent tests. Module `ctx.http` methods apply an 8 MiB host ceiling even when
109
+ callers supply `max_bytes`; `download` buffers and validates the bounded response
110
+ before yielding chunks so connections are released on early consumer exit. See the
105
111
  [file access guide](https://github.com/webhead2oo9/kimi-agent/blob/main/docs/module-files.md)
106
112
  for moderation, reply-image availability, privacy, and limits.
107
113
 
@@ -112,3 +118,14 @@ From this package directory, run its tests without installing the Kimi applicati
112
118
  ```console
113
119
  uv run --isolated --group test python -m pytest -q
114
120
  ```
121
+
122
+ SDK 2.4 adds `ScheduledResults`: named, guild-scoped subscriptions to task output
123
+ following confirmed Discord publication. Declare
124
+ `ModulePermissions(scheduled_results=("my_subscription",))`, require the host
125
+ capability `scheduled_results.v1`, and register through `ctx.scheduled_results`
126
+ during startup. Handlers receive a typed `ScheduledResult` and an invocation-scoped
127
+ `ScheduledResultFiles` reader. Returning acknowledges the result; exceptions retry
128
+ with the same notification ID. Use that ID for idempotent processing.
129
+ `FakeScheduledResults` supports standalone tests. See the
130
+ [published-result guide](https://github.com/Kimi-Discord-Agent/kimi-agent/blob/main/docs/module-scheduled-results.md)
131
+ for access checks, limits, retention, and deletion responsibilities.
@@ -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.scheduled_results`: durable subscriptions to published
17
+ task output, typed results, and bounded attachment readers (SDK 2.4).
16
18
  - `kimi_agent_module_api.files`: `ToolFiles`, `ToolAttachment`, `ToolFile`, and
17
19
  `FileAccessError` for invocation-scoped attachment and workspace reads.
18
20
  - `kimi_agent_module_api.testing`: a fake for every port, `load_context()` for
@@ -67,11 +69,13 @@ Message-deletion events include cached author classification:
67
69
  `MessageDeleteEvent.author_is_bot` and `MessageBulkDeleteEvent.bot_message_ids`.
68
70
  The values remain unknown for messages that were absent from Discord's cache.
69
71
 
70
- SDK 2.1 adds `ModuleToolContext.trigger_discord_message_id`. Mention-path tool
71
- calls receive the exact source Discord message snowflake; other surfaces receive
72
- `None`. Modules that act on a user's source message should require
73
- `kimi-agent-module-api>=2.1,<3`, reject `None`, fetch that exact message through
74
- `ctx.discord`, and verify its author before acting.
72
+ SDK 2.1 adds `ModuleToolContext.trigger_discord_message_id`. SDK 2.3 adds
73
+ `trigger_discord_message_snapshot`, an immutable host-owned capture of the
74
+ message, guild, channel, author, content, and bot status made at turn entry.
75
+ Mention-path tool calls receive both; personal and non-message surfaces receive
76
+ `None`. Modules that need authoritative evidence from the triggering message
77
+ should require `kimi-agent-module-api>=2.3,<3` and use the snapshot instead of
78
+ re-fetching mutable or deletable Discord state.
75
79
 
76
80
  Modules use namespaced guild documents and the physical table names returned
77
81
  by `ctx.storage.table()`.
@@ -84,7 +88,9 @@ keep `api_version=2` and receive `files=None` without the permission.
84
88
  `read_attachment(id, max_bytes=...)` and `read_workspace(path, max_bytes=...)`
85
89
  return bounded bytes without network downloads. The reader expires when the handler
86
90
  returns and is scoped to the actual caller. `testing.FakeToolFiles` supports
87
- independent tests. See the
91
+ independent tests. Module `ctx.http` methods apply an 8 MiB host ceiling even when
92
+ callers supply `max_bytes`; `download` buffers and validates the bounded response
93
+ before yielding chunks so connections are released on early consumer exit. See the
88
94
  [file access guide](https://github.com/webhead2oo9/kimi-agent/blob/main/docs/module-files.md)
89
95
  for moderation, reply-image availability, privacy, and limits.
90
96
 
@@ -95,3 +101,14 @@ From this package directory, run its tests without installing the Kimi applicati
95
101
  ```console
96
102
  uv run --isolated --group test python -m pytest -q
97
103
  ```
104
+
105
+ SDK 2.4 adds `ScheduledResults`: named, guild-scoped subscriptions to task output
106
+ following confirmed Discord publication. Declare
107
+ `ModulePermissions(scheduled_results=("my_subscription",))`, require the host
108
+ capability `scheduled_results.v1`, and register through `ctx.scheduled_results`
109
+ during startup. Handlers receive a typed `ScheduledResult` and an invocation-scoped
110
+ `ScheduledResultFiles` reader. Returning acknowledges the result; exceptions retry
111
+ with the same notification ID. Use that ID for idempotent processing.
112
+ `FakeScheduledResults` supports standalone tests. See the
113
+ [published-result guide](https://github.com/Kimi-Discord-Agent/kimi-agent/blob/main/docs/module-scheduled-results.md)
114
+ for access checks, limits, retention, and deletion responsibilities.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kimi-agent-module-api"
3
- version = "2.2.0"
3
+ version = "2.4.0"
4
4
  description = "Stable contracts for community-built assistant modules"
5
5
  requires-python = ">=3.14"
6
6
  authors = [{ name = "Webhead" }]
@@ -42,8 +42,19 @@ from kimi_agent_module_api.tools import (
42
42
  ModuleToolContext,
43
43
  ModuleToolHandler,
44
44
  ModuleToolRegistry,
45
+ ModuleTurnBudget,
46
+ TriggeringDiscordMessageSnapshot,
45
47
  )
46
48
  from kimi_agent_module_api.trust import TrustTier
49
+ from kimi_agent_module_api.scheduled_results import (
50
+ ScheduledResult,
51
+ ScheduledResultAccessError,
52
+ ScheduledResultAttachment,
53
+ ScheduledResultFiles,
54
+ ScheduledResultHandler,
55
+ ScheduledResultMessage,
56
+ ScheduledResults,
57
+ )
47
58
 
48
59
  MODULE_API_VERSION = 2
49
60
  MODULE_ENTRYPOINT_GROUP = "kimi_agent.modules"
@@ -145,6 +156,7 @@ class ModuleRuntimeContext:
145
156
  proposals: ProposalService | None = None
146
157
  raw_bot: Any = None
147
158
  raw_storage: Any = None
159
+ scheduled_results: ScheduledResults | None = None
148
160
 
149
161
 
150
162
  __all__ = [
@@ -173,6 +185,7 @@ __all__ = [
173
185
  "ModuleToolContext",
174
186
  "ModuleToolHandler",
175
187
  "ModuleToolRegistry",
188
+ "ModuleTurnBudget",
176
189
  "OutgoingLayout",
177
190
  "ProposalActor",
178
191
  "ProposalError",
@@ -180,6 +193,13 @@ __all__ = [
180
193
  "ProposalService",
181
194
  "ProposalState",
182
195
  "RoleSnapshot",
196
+ "ScheduledResult",
197
+ "ScheduledResultAccessError",
198
+ "ScheduledResultAttachment",
199
+ "ScheduledResultFiles",
200
+ "ScheduledResultHandler",
201
+ "ScheduledResultMessage",
202
+ "ScheduledResults",
183
203
  "ScopedModuleMigration",
184
204
  "ServiceDeclaration",
185
205
  "ServiceRequirement",
@@ -188,6 +208,7 @@ __all__ = [
188
208
  "ToolAttachment",
189
209
  "ToolFile",
190
210
  "ToolFiles",
211
+ "TriggeringDiscordMessageSnapshot",
191
212
  "TrustTier",
192
213
  "render_guild_settings",
193
214
  ]
@@ -145,6 +145,8 @@ class ModulePermissions:
145
145
  raw_storage: bool = False
146
146
  # Read admitted attachments and caller-owned workspace files in tool handlers.
147
147
  tool_files: bool = False
148
+ # Names of durable, explicitly guild-scoped published-task subscriptions.
149
+ scheduled_results: tuple[str, ...] = ()
148
150
 
149
151
 
150
152
  @dataclass(frozen=True, slots=True)
@@ -439,6 +441,11 @@ def validate_permissions(module_name: str, permissions: ModulePermissions) -> No
439
441
  )
440
442
  for rule in permissions.http_hosts:
441
443
  validate_host_rule(rule)
444
+ names = permissions.scheduled_results
445
+ if len(set(names)) != len(names) or any(
446
+ not re.fullmatch(r"[a-z0-9_]{1,64}", name) for name in names
447
+ ):
448
+ raise ModuleContractError("scheduled-result subscription names must be unique and valid")
442
449
 
443
450
 
444
451
  def validate_services(
@@ -0,0 +1,105 @@
1
+ """Durable subscriptions to confirmed Discord publications, separate from module jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass
7
+ from typing import Literal, Protocol
8
+
9
+ from kimi_agent_module_api.contracts import MessageRef, ModuleContractError
10
+
11
+ MAX_RESULT_FILE_BYTES = 8 * 1024 * 1024
12
+ MAX_RESULT_TOTAL_FILE_BYTES = 16 * 1024 * 1024
13
+
14
+
15
+ class ScheduledResultAccessError(ModuleContractError):
16
+ """The subscription or its retained output is unavailable to this caller."""
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class ScheduledResultAttachment:
21
+ id: str
22
+ filename: str
23
+ size_bytes: int
24
+ description: str | None = None
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class ScheduledResultMessage:
29
+ message: MessageRef
30
+ content: str
31
+ attachments: tuple[ScheduledResultAttachment, ...] = ()
32
+ # The published Discord embed as JSON, capped at 32 KiB. No network fetches.
33
+ embed_json: str | None = None
34
+ embed_unavailable_reason: str | None = None
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class ScheduledResult:
39
+ notification_id: str
40
+ subscription: str
41
+ task_id: str
42
+ run_id: str
43
+ revision: int
44
+ guild_id: int
45
+ owner_id: int
46
+ published_at: float
47
+ expires_at: float
48
+ messages: tuple[ScheduledResultMessage, ...]
49
+ outcome: Literal["completed"] = "completed"
50
+
51
+
52
+ class ScheduledResultFiles(Protocol):
53
+ """Read only this notification's attachments during its handler invocation.
54
+
55
+ The host rechecks access on every read. Reads are capped at 8 MiB each and
56
+ 16 MiB total per invocation, including repeated reads. Handles expire on
57
+ return, cancellation, deletion, or retention expiry; copy bytes deliberately.
58
+ """
59
+
60
+ async def read(
61
+ self, attachment_id: str, *, max_bytes: int = MAX_RESULT_FILE_BYTES
62
+ ) -> bytes: ...
63
+
64
+
65
+ type ScheduledResultHandler = Callable[[ScheduledResult, ScheduledResultFiles], Awaitable[None]]
66
+
67
+
68
+ class ScheduledResults(Protocol):
69
+ """A module-bound port. Declare each name in ``permissions.scheduled_results``.
70
+
71
+ Register on every start. A name is scoped to one guild per registration;
72
+ the same name may be registered in several guilds. Existing subscriptions
73
+ survive downtime; new registrations never replay historical publications.
74
+ Returning acknowledges the notification. Raising retries with the same ID.
75
+ Use that ID to make your own writes/remote requests idempotent.
76
+ """
77
+
78
+ async def subscribe(
79
+ self, name: str, *, guild_id: int, handler: ScheduledResultHandler
80
+ ) -> None: ...
81
+
82
+ async def unsubscribe(self, name: str, *, guild_id: int) -> None:
83
+ """Forget this subscription and its pending/acknowledged notifications."""
84
+ ...
85
+
86
+
87
+ def validate_result_subscription(name: str, guild_id: int) -> None:
88
+ # Keep validation here usable by the host and standalone test fakes.
89
+ if (
90
+ not isinstance(name, str)
91
+ or not 1 <= len(name) <= 64
92
+ or any(char not in "abcdefghijklmnopqrstuvwxyz0123456789_" for char in name)
93
+ ):
94
+ raise ModuleContractError("scheduled-result names use 1-64 lowercase letters, digits or _")
95
+ if isinstance(guild_id, bool) or not isinstance(guild_id, int) or not 0 < guild_id < 2**64:
96
+ raise ModuleContractError("scheduled-result subscriptions require a positive guild ID")
97
+
98
+
99
+ def validate_result_read_limit(max_bytes: int) -> None:
100
+ if (
101
+ isinstance(max_bytes, bool)
102
+ or not isinstance(max_bytes, int)
103
+ or not (0 < max_bytes <= MAX_RESULT_FILE_BYTES)
104
+ ):
105
+ raise ScheduledResultAccessError("attachment read limit must be between 1 byte and 8 MiB")
@@ -24,6 +24,15 @@ from pydantic_settings import BaseSettings
24
24
 
25
25
  from kimi_agent_module_api.trust import TrustTier
26
26
  from kimi_agent_module_api.files import FileAccessError, ToolAttachment, ToolFile
27
+ from kimi_agent_module_api.scheduled_results import (
28
+ MAX_RESULT_FILE_BYTES,
29
+ MAX_RESULT_TOTAL_FILE_BYTES,
30
+ ScheduledResult,
31
+ ScheduledResultAccessError,
32
+ ScheduledResultHandler,
33
+ validate_result_read_limit,
34
+ validate_result_subscription,
35
+ )
27
36
 
28
37
  from kimi_agent_module_api.contracts import (
29
38
  ALL_DISCORD_ACTIONS,
@@ -85,6 +94,93 @@ _MAX_FLOAT_LOG = math.log(sys.float_info.max)
85
94
  _T = TypeVar("_T")
86
95
 
87
96
 
97
+ class FakeScheduledResultFiles:
98
+ """An invocation-scoped reader; the same size/lifetime limits as the host."""
99
+
100
+ def __init__(
101
+ self, files: Mapping[str, bytes], *, available: Callable[[], bool] = lambda: True
102
+ ) -> None:
103
+ self.files = files
104
+ self.available = available
105
+ self.open = True
106
+ self.remaining = MAX_RESULT_TOTAL_FILE_BYTES
107
+
108
+ async def read(self, attachment_id: str, *, max_bytes: int = MAX_RESULT_FILE_BYTES) -> bytes:
109
+ validate_result_read_limit(max_bytes)
110
+ data = self.files.get(attachment_id)
111
+ if (
112
+ not self.open
113
+ or not self.available()
114
+ or self.remaining <= 0
115
+ or data is None
116
+ or len(data) > min(max_bytes, self.remaining)
117
+ ):
118
+ raise ScheduledResultAccessError("Result file unavailable or read limit exceeded")
119
+ self.remaining -= len(data)
120
+ return data
121
+
122
+
123
+ class FakeScheduledResults:
124
+ """Drive retries explicitly with ``deliver``; successful IDs are acknowledged.
125
+
126
+ A failed invocation propagates its exception and can be delivered again.
127
+ Supply the original result object to model a restart or duplicate attempt.
128
+ This fake has no background runner, Discord access, or persistence.
129
+ """
130
+
131
+ def __init__(
132
+ self,
133
+ declared: tuple[str, ...] = (),
134
+ *,
135
+ is_guild_active: Callable[[int], bool] = lambda _guild_id: True,
136
+ ) -> None:
137
+ self.declared, self.is_guild_active = declared, is_guild_active
138
+ self.handlers: dict[tuple[str, int], ScheduledResultHandler] = {}
139
+ self.acknowledged: set[str] = set()
140
+ self.attempts: list[str] = []
141
+
142
+ def _validate(self, name: str, guild_id: int) -> None:
143
+ validate_result_subscription(name, guild_id)
144
+ if name not in self.declared:
145
+ raise ScheduledResultAccessError("Undeclared scheduled-result subscription")
146
+
147
+ async def subscribe(self, name: str, *, guild_id: int, handler: ScheduledResultHandler) -> None:
148
+ self._validate(name, guild_id)
149
+ self.handlers[name, guild_id] = handler
150
+
151
+ async def unsubscribe(self, name: str, *, guild_id: int) -> None:
152
+ self._validate(name, guild_id)
153
+ self.handlers.pop((name, guild_id), None)
154
+
155
+ async def deliver(
156
+ self,
157
+ result: ScheduledResult,
158
+ *,
159
+ files: Mapping[str, bytes] | None = None,
160
+ preview: bool = False,
161
+ ) -> None:
162
+ if preview or result.notification_id in self.acknowledged:
163
+ return
164
+ handler = self.handlers.get((result.subscription, result.guild_id))
165
+ if handler is None or not self.is_guild_active(result.guild_id):
166
+ raise ScheduledResultAccessError("Subscriber unavailable in this guild")
167
+ visible = {a.id for m in result.messages for a in m.attachments}
168
+ reader = FakeScheduledResultFiles(
169
+ {k: v for k, v in (files or {}).items() if k in visible},
170
+ available=lambda: (
171
+ self.is_guild_active(result.guild_id)
172
+ and result.subscription in self.declared
173
+ and self.handlers.get((result.subscription, result.guild_id)) is handler
174
+ ),
175
+ )
176
+ self.attempts.append(result.notification_id)
177
+ try:
178
+ await handler(result, reader)
179
+ self.acknowledged.add(result.notification_id)
180
+ finally:
181
+ reader.open = False
182
+
183
+
88
184
  @dataclass(slots=True)
89
185
  class _Closable:
90
186
  _on_close: Callable[[], object]
@@ -1390,6 +1486,8 @@ __all__ = [
1390
1486
  "FakeInteractions",
1391
1487
  "FakeProposals",
1392
1488
  "FakeResponse",
1489
+ "FakeScheduledResultFiles",
1490
+ "FakeScheduledResults",
1393
1491
  "FakeScheduler",
1394
1492
  "FakeServiceRegistry",
1395
1493
  "FakeToolFiles",
@@ -6,8 +6,29 @@ 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.trust import TrustTier
10
9
  from kimi_agent_module_api.files import ToolFiles
10
+ from kimi_agent_module_api.trust import TrustTier
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
11
32
 
12
33
 
13
34
  @dataclass(frozen=True, slots=True)
@@ -36,6 +57,13 @@ class ModuleToolContext:
36
57
  # Invocation-scoped read-only port. Declare permissions.tool_files and
37
58
  # require tools.files.v1; never retain this port beyond the handler.
38
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
39
67
 
40
68
 
41
69
  type ModuleToolHandler = Callable[[dict[str, Any], ModuleToolContext], Coroutine[Any, Any, str]]
@@ -71,4 +99,10 @@ class ModuleToolRegistry(Protocol):
71
99
  ) -> None: ...
72
100
 
73
101
 
74
- __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.2.0
3
+ Version: 2.4.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.scheduled_results`: durable subscriptions to published
34
+ task output, typed results, and bounded attachment readers (SDK 2.4).
33
35
  - `kimi_agent_module_api.files`: `ToolFiles`, `ToolAttachment`, `ToolFile`, and
34
36
  `FileAccessError` for invocation-scoped attachment and workspace reads.
35
37
  - `kimi_agent_module_api.testing`: a fake for every port, `load_context()` for
@@ -84,11 +86,13 @@ Message-deletion events include cached author classification:
84
86
  `MessageDeleteEvent.author_is_bot` and `MessageBulkDeleteEvent.bot_message_ids`.
85
87
  The values remain unknown for messages that were absent from Discord's cache.
86
88
 
87
- SDK 2.1 adds `ModuleToolContext.trigger_discord_message_id`. Mention-path tool
88
- calls receive the exact source Discord message snowflake; other surfaces receive
89
- `None`. Modules that act on a user's source message should require
90
- `kimi-agent-module-api>=2.1,<3`, reject `None`, fetch that exact message through
91
- `ctx.discord`, and verify its author before acting.
89
+ SDK 2.1 adds `ModuleToolContext.trigger_discord_message_id`. SDK 2.3 adds
90
+ `trigger_discord_message_snapshot`, an immutable host-owned capture of the
91
+ message, guild, channel, author, content, and bot status made at turn entry.
92
+ Mention-path tool calls receive both; personal and non-message surfaces receive
93
+ `None`. Modules that need authoritative evidence from the triggering message
94
+ should require `kimi-agent-module-api>=2.3,<3` and use the snapshot instead of
95
+ re-fetching mutable or deletable Discord state.
92
96
 
93
97
  Modules use namespaced guild documents and the physical table names returned
94
98
  by `ctx.storage.table()`.
@@ -101,7 +105,9 @@ keep `api_version=2` and receive `files=None` without the permission.
101
105
  `read_attachment(id, max_bytes=...)` and `read_workspace(path, max_bytes=...)`
102
106
  return bounded bytes without network downloads. The reader expires when the handler
103
107
  returns and is scoped to the actual caller. `testing.FakeToolFiles` supports
104
- independent tests. See the
108
+ independent tests. Module `ctx.http` methods apply an 8 MiB host ceiling even when
109
+ callers supply `max_bytes`; `download` buffers and validates the bounded response
110
+ before yielding chunks so connections are released on early consumer exit. See the
105
111
  [file access guide](https://github.com/webhead2oo9/kimi-agent/blob/main/docs/module-files.md)
106
112
  for moderation, reply-image availability, privacy, and limits.
107
113
 
@@ -112,3 +118,14 @@ From this package directory, run its tests without installing the Kimi applicati
112
118
  ```console
113
119
  uv run --isolated --group test python -m pytest -q
114
120
  ```
121
+
122
+ SDK 2.4 adds `ScheduledResults`: named, guild-scoped subscriptions to task output
123
+ following confirmed Discord publication. Declare
124
+ `ModulePermissions(scheduled_results=("my_subscription",))`, require the host
125
+ capability `scheduled_results.v1`, and register through `ctx.scheduled_results`
126
+ during startup. Handlers receive a typed `ScheduledResult` and an invocation-scoped
127
+ `ScheduledResultFiles` reader. Returning acknowledges the result; exceptions retry
128
+ with the same notification ID. Use that ID for idempotent processing.
129
+ `FakeScheduledResults` supports standalone tests. See the
130
+ [published-result guide](https://github.com/Kimi-Discord-Agent/kimi-agent/blob/main/docs/module-scheduled-results.md)
131
+ for access checks, limits, retention, and deletion responsibilities.
@@ -7,6 +7,7 @@ src/kimi_agent_module_api/events.py
7
7
  src/kimi_agent_module_api/files.py
8
8
  src/kimi_agent_module_api/images.py
9
9
  src/kimi_agent_module_api/py.typed
10
+ src/kimi_agent_module_api/scheduled_results.py
10
11
  src/kimi_agent_module_api/settings.py
11
12
  src/kimi_agent_module_api/testing.py
12
13
  src/kimi_agent_module_api/tools.py
@@ -19,4 +20,5 @@ src/kimi_agent_module_api.egg-info/top_level.txt
19
20
  tests/test_contracts.py
20
21
  tests/test_files.py
21
22
  tests/test_public_api.py
23
+ tests/test_scheduled_results.py
22
24
  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_tool_files_contract() -> None:
87
- assert version("kimi-agent-module-api") == "2.2.0"
87
+ def test_distribution_version_includes_scheduled_result_contract() -> None:
88
+ assert version("kimi-agent-module-api") == "2.4.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,99 @@
1
+ """Exercise the public subscriber contract without importing a host."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import FrozenInstanceError, replace
6
+
7
+ import pytest
8
+
9
+ from kimi_agent_module_api import (
10
+ ModulePermissions,
11
+ ScheduledResult,
12
+ ScheduledResultAccessError,
13
+ ScheduledResultAttachment,
14
+ ScheduledResultMessage,
15
+ )
16
+ from kimi_agent_module_api.contracts import MessageRef, ModuleContractError, validate_permissions
17
+ from kimi_agent_module_api.testing import FakeScheduledResults
18
+
19
+
20
+ def result():
21
+ return ScheduledResult(
22
+ notification_id="notification",
23
+ subscription="reports",
24
+ task_id="task",
25
+ run_id="run",
26
+ revision=3,
27
+ guild_id=100,
28
+ owner_id=10,
29
+ published_at=1000,
30
+ expires_at=2000,
31
+ messages=(
32
+ ScheduledResultMessage(
33
+ MessageRef(100, 300, 900),
34
+ "Published",
35
+ (ScheduledResultAttachment("file", "result.txt", 3),),
36
+ ),
37
+ ),
38
+ )
39
+
40
+
41
+ def test_result_is_immutable_and_permissions_must_be_named():
42
+ with pytest.raises(FrozenInstanceError):
43
+ result().owner_id = 99
44
+ validate_permissions("reports", ModulePermissions(scheduled_results=("reports",)))
45
+ for names in (("Upper Case",), ("reports", "reports"), ("",)):
46
+ with pytest.raises(ModuleContractError):
47
+ validate_permissions("reports", ModulePermissions(scheduled_results=names))
48
+
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_fake_retry_acknowledgement_preview_and_file_lifetime():
52
+ fake = FakeScheduledResults(("reports",))
53
+ attempts, readers = [], []
54
+
55
+ async def handler(notification, files):
56
+ attempts.append(notification.notification_id)
57
+ readers.append(files)
58
+ assert await files.read("file") == b"abc"
59
+ with pytest.raises(ScheduledResultAccessError):
60
+ await files.read("not published")
61
+ with pytest.raises(ScheduledResultAccessError):
62
+ await files.read("file", max_bytes=2)
63
+ if len(attempts) == 1:
64
+ raise RuntimeError("retry me")
65
+
66
+ await fake.subscribe("reports", guild_id=100, handler=handler)
67
+ await fake.deliver(result(), preview=True)
68
+ assert attempts == []
69
+ files = {"file": b"abc", "not published": b"private"}
70
+ with pytest.raises(RuntimeError, match="retry me"):
71
+ await fake.deliver(result(), files=files)
72
+ assert not fake.acknowledged
73
+ await fake.deliver(result(), files=files)
74
+ await fake.deliver(result(), files=files)
75
+ assert attempts == ["notification", "notification"]
76
+ assert fake.acknowledged == {"notification"}
77
+ for reader in readers:
78
+ with pytest.raises(ScheduledResultAccessError):
79
+ await reader.read("file")
80
+
81
+
82
+ @pytest.mark.asyncio
83
+ async def test_fake_permission_and_guild_scope():
84
+ fake = FakeScheduledResults(("reports",), is_guild_active=lambda guild: guild == 100)
85
+
86
+ async def handler(_result, _files):
87
+ pass
88
+
89
+ with pytest.raises(ScheduledResultAccessError):
90
+ await fake.subscribe("other", guild_id=100, handler=handler)
91
+ with pytest.raises(ModuleContractError):
92
+ await fake.subscribe("reports", guild_id=0, handler=handler)
93
+ await fake.subscribe("reports", guild_id=200, handler=handler)
94
+ with pytest.raises(ScheduledResultAccessError):
95
+ await fake.deliver(replace(result(), guild_id=200))
96
+ await fake.subscribe("reports", guild_id=100, handler=handler)
97
+ await fake.unsubscribe("reports", guild_id=100)
98
+ with pytest.raises(ScheduledResultAccessError):
99
+ await fake.deliver(result())