diploid-plugins 0.1.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 (52) hide show
  1. diploid_plugins-0.1.0/LICENSE +21 -0
  2. diploid_plugins-0.1.0/PKG-INFO +101 -0
  3. diploid_plugins-0.1.0/README.md +76 -0
  4. diploid_plugins-0.1.0/pyproject.toml +46 -0
  5. diploid_plugins-0.1.0/setup.cfg +4 -0
  6. diploid_plugins-0.1.0/src/diploid_plugins/__init__.py +25 -0
  7. diploid_plugins-0.1.0/src/diploid_plugins/auto_continue/__init__.py +5 -0
  8. diploid_plugins-0.1.0/src/diploid_plugins/auto_continue/auto_continue.py +222 -0
  9. diploid_plugins-0.1.0/src/diploid_plugins/body/__init__.py +8 -0
  10. diploid_plugins-0.1.0/src/diploid_plugins/body/body.py +186 -0
  11. diploid_plugins-0.1.0/src/diploid_plugins/body/body_mcp.py +288 -0
  12. diploid_plugins-0.1.0/src/diploid_plugins/body/config.py +21 -0
  13. diploid_plugins-0.1.0/src/diploid_plugins/body/manager.py +364 -0
  14. diploid_plugins-0.1.0/src/diploid_plugins/continuity/__init__.py +5 -0
  15. diploid_plugins-0.1.0/src/diploid_plugins/continuity/continuity.py +463 -0
  16. diploid_plugins-0.1.0/src/diploid_plugins/curriculum/__init__.py +5 -0
  17. diploid_plugins-0.1.0/src/diploid_plugins/curriculum/curriculum.py +131 -0
  18. diploid_plugins-0.1.0/src/diploid_plugins/curriculum/curriculum_mcp.py +198 -0
  19. diploid_plugins-0.1.0/src/diploid_plugins/harness/__init__.py +1 -0
  20. diploid_plugins-0.1.0/src/diploid_plugins/harness/harness_mcp.py +161 -0
  21. diploid_plugins-0.1.0/src/diploid_plugins/identity/__init__.py +5 -0
  22. diploid_plugins-0.1.0/src/diploid_plugins/identity/identity.py +160 -0
  23. diploid_plugins-0.1.0/src/diploid_plugins/identity/identity_mcp.py +193 -0
  24. diploid_plugins-0.1.0/src/diploid_plugins/persistent_memory/__init__.py +5 -0
  25. diploid_plugins-0.1.0/src/diploid_plugins/persistent_memory/persistent_memory.py +322 -0
  26. diploid_plugins-0.1.0/src/diploid_plugins/planner/__init__.py +7 -0
  27. diploid_plugins-0.1.0/src/diploid_plugins/planner/planner.py +240 -0
  28. diploid_plugins-0.1.0/src/diploid_plugins/self_management/__init__.py +5 -0
  29. diploid_plugins-0.1.0/src/diploid_plugins/self_management/approvals.py +68 -0
  30. diploid_plugins-0.1.0/src/diploid_plugins/self_management/self_management.py +13 -0
  31. diploid_plugins-0.1.0/src/diploid_plugins/self_management/self_management_mcp.py +343 -0
  32. diploid_plugins-0.1.0/src/diploid_plugins/self_state/__init__.py +5 -0
  33. diploid_plugins-0.1.0/src/diploid_plugins/self_state/self_state.py +207 -0
  34. diploid_plugins-0.1.0/src/diploid_plugins/working_memory/__init__.py +5 -0
  35. diploid_plugins-0.1.0/src/diploid_plugins/working_memory/working_memory.py +194 -0
  36. diploid_plugins-0.1.0/src/diploid_plugins/working_memory/working_memory_mcp.py +196 -0
  37. diploid_plugins-0.1.0/src/diploid_plugins.egg-info/PKG-INFO +101 -0
  38. diploid_plugins-0.1.0/src/diploid_plugins.egg-info/SOURCES.txt +50 -0
  39. diploid_plugins-0.1.0/src/diploid_plugins.egg-info/dependency_links.txt +1 -0
  40. diploid_plugins-0.1.0/src/diploid_plugins.egg-info/requires.txt +7 -0
  41. diploid_plugins-0.1.0/src/diploid_plugins.egg-info/top_level.txt +1 -0
  42. diploid_plugins-0.1.0/tests/test_auto_continue.py +616 -0
  43. diploid_plugins-0.1.0/tests/test_body.py +401 -0
  44. diploid_plugins-0.1.0/tests/test_continuity_partial.py +670 -0
  45. diploid_plugins-0.1.0/tests/test_identity.py +271 -0
  46. diploid_plugins-0.1.0/tests/test_persistent_memory.py +327 -0
  47. diploid_plugins-0.1.0/tests/test_planner_plugin.py +276 -0
  48. diploid_plugins-0.1.0/tests/test_plugins.py +58 -0
  49. diploid_plugins-0.1.0/tests/test_self_management_approval.py +11 -0
  50. diploid_plugins-0.1.0/tests/test_self_management_mcp.py +26 -0
  51. diploid_plugins-0.1.0/tests/test_self_state.py +492 -0
  52. diploid_plugins-0.1.0/tests/test_working_memory.py +211 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emil Tsoi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: diploid-plugins
3
+ Version: 0.1.0
4
+ Summary: Built-in state plugins for diploid-agent
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/emiltsoi/diploid-plugins
7
+ Project-URL: Repository, https://github.com/emiltsoi/diploid-plugins.git
8
+ Project-URL: Issues, https://github.com/emiltsoi/diploid-plugins/issues
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: diploid-agent>=0.5.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8.0; extra == "dev"
21
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
22
+ Requires-Dist: ruff>=0.5; extra == "dev"
23
+ Requires-Dist: respx>=0.20; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # diploid-plugins
27
+
28
+ Built-in state plugins for [`diploid-agent`](https://github.com/emiltsoi/diploid-agent).
29
+
30
+ These plugins implement the `diploid-agent` plugin contract (`StatePlugin` lifecycle hooks) and are loaded by the harness through the `harness.plugins` config.
31
+
32
+ ## Included plugins
33
+
34
+ - `body` — physical/emotional body state that persists across sessions and transport restarts. Includes a felt layer: `felt_warmth` (an ember that fades per wake/turn, not per wall-clock minute — a restart is not an experience) and `felt_summary` (one agent-authored line of texture via the `body_felt` MCP tool; never synthesized from telemetry). Reads are pure — `state_for_prompt` never mutates.
35
+ - `continuity` — wake state, time asleep, last stop reason, pending dispatches, and active/interrupted-turn breadcrumbs (`current_intent`/`last_side_effect`).
36
+ - `working_memory` — a chat-scoped scratchpad for the current turn.
37
+ - `persistent_memory` — auto-recall and auto-promote of ` ```memory ` blocks, including mid-stream promotion while the reply is still being written.
38
+ - `planner` — turn a user request into an executable plan.
39
+ - `auto_continue` — resume a turn automatically after a configured stop reason.
40
+ - `self_state` — a first-person self-state note across sessions, with a first-person guard that keeps the previous note if a new block is not written in `I am`/`We are`/`My` form.
41
+ - `self_management` — in-chat plugin enable/disable and approval tools.
42
+ - `curriculum` — language-learning target, unit, and vocabulary tracking.
43
+ - `identity` — self-narrative / identity prompt block.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install diploid-agent[plugins]
49
+ # or explicitly
50
+ pip install diploid-agent diploid-plugins
51
+ ```
52
+
53
+ ## Usage
54
+
55
+ In `config/harness.yaml`:
56
+
57
+ ```yaml
58
+ harness:
59
+ plugins:
60
+ - name: continuity
61
+ module: diploid_plugins.continuity
62
+ prompt_slot: wake
63
+ first_prompt_only: true
64
+ prompt_order: 0
65
+ state_file: chat_wake_state.json
66
+ max_prompt_chars: 1024
67
+ - name: working_memory
68
+ module: diploid_plugins.working_memory
69
+ prompt_slot: working_memory
70
+ first_prompt_only: false
71
+ prompt_order: 40
72
+ state_file: chat_working_memory.json
73
+ max_prompt_chars: 1024
74
+ # ... add the rest as needed
75
+ ```
76
+
77
+ ## Development
78
+
79
+ ```bash
80
+ python -m venv .venv
81
+ source .venv/bin/activate
82
+ pip install -e ".[dev]"
83
+ pytest
84
+ ```
85
+
86
+ ## Hot reload
87
+
88
+ With an editable install, plugin changes go live in a running harness without
89
+ a service restart: `POST /plugin/reload` (or Telegram `/plugin reload <name>`)
90
+ deep-reloads the plugin's whole package subtree — every already-imported
91
+ submodule, then the package itself — and recycles its instances.
92
+
93
+ Keep module-level code side-effect free: reload re-executes it. Do real work in
94
+ `start()`, release resources in `stop()`. A module that fails to import makes
95
+ the reload raise and leaves the running instances untouched. Changes in code
96
+ outside the plugin's own subtree (e.g. a shared helper package) are not picked
97
+ up — reload each dependent plugin or restart the service.
98
+
99
+ ## License
100
+
101
+ [MIT](LICENSE)
@@ -0,0 +1,76 @@
1
+ # diploid-plugins
2
+
3
+ Built-in state plugins for [`diploid-agent`](https://github.com/emiltsoi/diploid-agent).
4
+
5
+ These plugins implement the `diploid-agent` plugin contract (`StatePlugin` lifecycle hooks) and are loaded by the harness through the `harness.plugins` config.
6
+
7
+ ## Included plugins
8
+
9
+ - `body` — physical/emotional body state that persists across sessions and transport restarts. Includes a felt layer: `felt_warmth` (an ember that fades per wake/turn, not per wall-clock minute — a restart is not an experience) and `felt_summary` (one agent-authored line of texture via the `body_felt` MCP tool; never synthesized from telemetry). Reads are pure — `state_for_prompt` never mutates.
10
+ - `continuity` — wake state, time asleep, last stop reason, pending dispatches, and active/interrupted-turn breadcrumbs (`current_intent`/`last_side_effect`).
11
+ - `working_memory` — a chat-scoped scratchpad for the current turn.
12
+ - `persistent_memory` — auto-recall and auto-promote of ` ```memory ` blocks, including mid-stream promotion while the reply is still being written.
13
+ - `planner` — turn a user request into an executable plan.
14
+ - `auto_continue` — resume a turn automatically after a configured stop reason.
15
+ - `self_state` — a first-person self-state note across sessions, with a first-person guard that keeps the previous note if a new block is not written in `I am`/`We are`/`My` form.
16
+ - `self_management` — in-chat plugin enable/disable and approval tools.
17
+ - `curriculum` — language-learning target, unit, and vocabulary tracking.
18
+ - `identity` — self-narrative / identity prompt block.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install diploid-agent[plugins]
24
+ # or explicitly
25
+ pip install diploid-agent diploid-plugins
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ In `config/harness.yaml`:
31
+
32
+ ```yaml
33
+ harness:
34
+ plugins:
35
+ - name: continuity
36
+ module: diploid_plugins.continuity
37
+ prompt_slot: wake
38
+ first_prompt_only: true
39
+ prompt_order: 0
40
+ state_file: chat_wake_state.json
41
+ max_prompt_chars: 1024
42
+ - name: working_memory
43
+ module: diploid_plugins.working_memory
44
+ prompt_slot: working_memory
45
+ first_prompt_only: false
46
+ prompt_order: 40
47
+ state_file: chat_working_memory.json
48
+ max_prompt_chars: 1024
49
+ # ... add the rest as needed
50
+ ```
51
+
52
+ ## Development
53
+
54
+ ```bash
55
+ python -m venv .venv
56
+ source .venv/bin/activate
57
+ pip install -e ".[dev]"
58
+ pytest
59
+ ```
60
+
61
+ ## Hot reload
62
+
63
+ With an editable install, plugin changes go live in a running harness without
64
+ a service restart: `POST /plugin/reload` (or Telegram `/plugin reload <name>`)
65
+ deep-reloads the plugin's whole package subtree — every already-imported
66
+ submodule, then the package itself — and recycles its instances.
67
+
68
+ Keep module-level code side-effect free: reload re-executes it. Do real work in
69
+ `start()`, release resources in `stop()`. A module that fails to import makes
70
+ the reload raise and leaves the running instances untouched. Changes in code
71
+ outside the plugin's own subtree (e.g. a shared helper package) are not picked
72
+ up — reload each dependent plugin or restart the service.
73
+
74
+ ## License
75
+
76
+ [MIT](LICENSE)
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=70.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "diploid-plugins"
7
+ version = "0.1.0"
8
+ description = "Built-in state plugins for diploid-agent"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = {text = "MIT"}
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ ]
20
+ dependencies = [
21
+ "diploid-agent>=0.5.0",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "pytest>=8.0",
27
+ "pytest-asyncio>=0.23",
28
+ "ruff>=0.5",
29
+ "respx>=0.20",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/emiltsoi/diploid-plugins"
34
+ Repository = "https://github.com/emiltsoi/diploid-plugins.git"
35
+ Issues = "https://github.com/emiltsoi/diploid-plugins/issues"
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ asyncio_mode = "auto"
43
+
44
+ [tool.ruff]
45
+ line-length = 100
46
+ target-version = "py312"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,25 @@
1
+ """Built-in state plugins for diploid-agent."""
2
+
3
+ from diploid_plugins.auto_continue import AutoContinuePlugin
4
+ from diploid_plugins.body import BodyPlugin
5
+ from diploid_plugins.continuity import ContinuityPlugin
6
+ from diploid_plugins.curriculum import CurriculumPlugin
7
+ from diploid_plugins.identity import IdentityPlugin
8
+ from diploid_plugins.persistent_memory import PersistentMemoryPlugin
9
+ from diploid_plugins.planner import PlannerPlugin
10
+ from diploid_plugins.self_management import SelfManagementPlugin
11
+ from diploid_plugins.self_state import SelfStatePlugin
12
+ from diploid_plugins.working_memory import WorkingMemoryPlugin
13
+
14
+ __all__ = [
15
+ "AutoContinuePlugin",
16
+ "BodyPlugin",
17
+ "ContinuityPlugin",
18
+ "CurriculumPlugin",
19
+ "IdentityPlugin",
20
+ "PersistentMemoryPlugin",
21
+ "PlannerPlugin",
22
+ "SelfManagementPlugin",
23
+ "SelfStatePlugin",
24
+ "WorkingMemoryPlugin",
25
+ ]
@@ -0,0 +1,5 @@
1
+ """Auto-continue plugin."""
2
+
3
+ from diploid_plugins.auto_continue.auto_continue import AutoContinuePlugin
4
+
5
+ Plugin = AutoContinuePlugin
@@ -0,0 +1,222 @@
1
+ """Auto-continue plugin: resume a chat turn automatically after a stop reason."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from diploid_agent.config import PluginConfig
12
+ from diploid_agent.models import WakeEvent
13
+ from diploid_agent.plugins.base import StatePlugin, TurnInfo
14
+ from diploid_agent.plugins.contexts import TurnStartContext
15
+ from diploid_agent.runtime.plugin_runtime import PluginRuntime
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Cap the continuation payload so the wake queue and downstream Telegram edits
20
+ # do not balloon with a huge partial thought stream.
21
+ _MAX_CONTINUATION_TEXT_CHARS = 2000
22
+
23
+
24
+ def _join_notices(*parts: str | None) -> str | None:
25
+ """Concatenate non-empty notice strings with a blank line between them."""
26
+ joined = "\n\n".join(p for p in parts if p)
27
+ return joined or None
28
+
29
+
30
+ class AutoContinuePlugin(StatePlugin):
31
+ """Resume a chat turn automatically after a configured stop reason."""
32
+
33
+ def __init__(
34
+ self,
35
+ config: PluginConfig,
36
+ chat_id: str,
37
+ sessions_root: Path,
38
+ runtime: PluginRuntime | None = None,
39
+ ) -> None:
40
+ super().__init__(config, chat_id, sessions_root, runtime=runtime)
41
+ self._state = self._load_state()
42
+ cfg = self.config.config or {}
43
+ self._delay_seconds = float(cfg.get("delay_seconds", 2.0))
44
+ self._max_attempts = int(cfg.get("max_attempts", 3))
45
+ self._delay_cap_seconds = float(cfg.get("delay_cap_seconds", 60.0))
46
+ self._stop_reasons = set(cfg.get("stop_reasons", ["timeout", "cancelled"]))
47
+ self._per_reason = dict(cfg.get("per_reason", {}))
48
+ self._reason = cfg.get("reason", "auto_continue")
49
+
50
+ def _settings(self, stop_reason: str | None) -> tuple[int, float, float]:
51
+ """Return (max_attempts, delay_seconds, delay_cap) for a stop reason."""
52
+ reason_cfg = self._per_reason.get(stop_reason, {})
53
+ max_attempts = int(reason_cfg.get("max_attempts", self._max_attempts))
54
+ delay = float(reason_cfg.get("delay_seconds", self._delay_seconds))
55
+ cap = float(reason_cfg.get("delay_cap_seconds", self._delay_cap_seconds))
56
+ return max_attempts, delay, cap
57
+
58
+ def _backoff_delay(self, delay: float, attempt: int, cap: float) -> float:
59
+ """Exponential backoff capped at delay_cap_seconds."""
60
+ return min(delay * (2 ** (attempt - 1)), cap)
61
+
62
+ def _load_state(self) -> dict[str, Any]:
63
+ path = self.state_path()
64
+ if path is None or not path.exists():
65
+ return {}
66
+ try:
67
+ return json.loads(path.read_text())
68
+ except (json.JSONDecodeError, OSError):
69
+ return {}
70
+
71
+ def _save_state(self) -> None:
72
+ path = self.state_path()
73
+ if path is None:
74
+ return
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ path.write_text(json.dumps(self._state, indent=2, default=str))
77
+
78
+ def _is_continuation(self, text: str) -> bool:
79
+ if not self._runtime:
80
+ return False
81
+ return self._runtime.is_continuation_message(text)
82
+
83
+ def _cancel_pending(self) -> None:
84
+ if not self._runtime:
85
+ return
86
+ now = time.time()
87
+ for event in self._runtime.wake_queue.pending(chat_id=self.chat_id):
88
+ if event.reason != self._reason:
89
+ continue
90
+ if event.leased_until is not None and event.leased_until > now:
91
+ continue
92
+ self._runtime.wake_queue.complete(event.id)
93
+
94
+ def before_turn(self, context: TurnStartContext) -> None:
95
+ record = context.record
96
+ if record is None or record.last_stop_reason not in self._stop_reasons:
97
+ return
98
+
99
+ if self._is_continuation(context.user_message):
100
+ # Manual continue takes over; cancel any pending auto-continue and
101
+ # drop any deferred partial notice.
102
+ self._cancel_pending()
103
+ self._state.pop("deferred_notice", None)
104
+ self._state.pop("send_deferred", None)
105
+ self._save_state()
106
+ # Attempt count stays as-is because this manual turn is the next attempt.
107
+ else:
108
+ # User changed topic; cancel the auto-continue chain and drop the
109
+ # deferred partial notice. If the user wants the partial result, they
110
+ # can still say Continue; otherwise we should not leak a soft-timeout
111
+ # / stopped notice into a normal reply.
112
+ self._cancel_pending()
113
+ self._state["attempt"] = 0
114
+ self._state.pop("deferred_notice", None)
115
+ self._state.pop("send_deferred", None)
116
+ self._save_state()
117
+
118
+ def after_turn(self, turn: TurnInfo) -> None:
119
+ # If the harness has suppressed auto-continue (e.g., a service restart
120
+ # is pending), drop the continuation chain and any deferred notice.
121
+ if self._runtime and self._runtime.is_auto_continue_suppressed(self.chat_id):
122
+ self._cancel_pending()
123
+ self._state.pop("deferred_notice", None)
124
+ self._state.pop("send_deferred", None)
125
+ self._state["attempt"] = 0
126
+ self._save_state()
127
+ return
128
+
129
+ reason = turn.last_stop_reason
130
+ if reason not in self._stop_reasons:
131
+ # The turn finished or was stopped by the user. Send any deferred
132
+ # partial notice that has been pending, then reset the chain.
133
+ if self._state.get("send_deferred") and self._state.get("deferred_notice"):
134
+ turn.notice = _join_notices(self._state.pop("deferred_notice"), turn.notice)
135
+ self._state.pop("send_deferred", None)
136
+ self._save_state()
137
+ elif self._state.get("attempt"):
138
+ self._state["attempt"] = 0
139
+ self._save_state()
140
+ return
141
+
142
+ max_attempts, base_delay, delay_cap = self._settings(reason)
143
+ attempt = self._state.get("attempt", 0) + 1
144
+ if attempt > max_attempts:
145
+ logger.info(
146
+ "Auto-continue for %s reached max %d attempts",
147
+ self.chat_id,
148
+ max_attempts,
149
+ )
150
+ # Give up: send the current partial notice along with any older one.
151
+ if turn.partial_notice or self._state.get("deferred_notice"):
152
+ turn.notice = _join_notices(
153
+ turn.partial_notice,
154
+ self._state.pop("deferred_notice", None),
155
+ turn.notice,
156
+ )
157
+ turn.partial_notice = None
158
+ self._state.pop("send_deferred", None)
159
+ self._state["attempt"] = 0
160
+ self._save_state()
161
+ return
162
+
163
+ # Show the working indicator, but keep the partial notice visible so the
164
+ # user can see what was already completed before the auto-continuation.
165
+ delay = self._backoff_delay(base_delay, attempt, delay_cap)
166
+ working = f"Working... (attempt {attempt}/{max_attempts}); continuing automatically in {delay:.0f}s."
167
+ if turn.partial_notice:
168
+ turn.notice = _join_notices(
169
+ turn.notice,
170
+ turn.partial_notice,
171
+ working,
172
+ )
173
+ turn.partial_notice = None
174
+ # Do not carry a stale deferred notice from a previous version.
175
+ self._state.pop("deferred_notice", None)
176
+ self._state.pop("send_deferred", None)
177
+ else:
178
+ turn.notice = _join_notices(turn.notice, working)
179
+
180
+ self._schedule(attempt, max_attempts, delay)
181
+
182
+ def _schedule(self, attempt: int, max_attempts: int, delay: float) -> None:
183
+ if not self._runtime:
184
+ return
185
+
186
+ payload: dict[str, Any] = {"user_message": "Continue"}
187
+ try:
188
+ chat_id_int = int(self.chat_id)
189
+ placeholder_path = self.sessions_root / ".poller-placeholders" / f"{chat_id_int}.json"
190
+ if placeholder_path.exists():
191
+ placeholder_state = json.loads(placeholder_path.read_text())
192
+ payload["message_id"] = placeholder_state.get("message_id")
193
+ payload["thought_id"] = placeholder_state.get("thought_id")
194
+
195
+ active = self._runtime._active_turns.get(self.chat_id)
196
+ if active is not None:
197
+ payload["message_text"] = active.message_text[:_MAX_CONTINUATION_TEXT_CHARS]
198
+ payload["thought_text"] = active.thought_text[:_MAX_CONTINUATION_TEXT_CHARS]
199
+ except Exception:
200
+ logger.exception("Failed to capture continuation placeholder for %s", self.chat_id)
201
+
202
+ event = WakeEvent(
203
+ id="",
204
+ chat_id=self.chat_id,
205
+ reason=self._reason,
206
+ priority=1,
207
+ scheduled_at=time.time() + delay,
208
+ payload=payload,
209
+ silent=False,
210
+ created_at=time.time(),
211
+ ready=True,
212
+ )
213
+ self._runtime.wake_queue.enqueue(event)
214
+ self._state["attempt"] = attempt
215
+ self._save_state()
216
+ logger.info(
217
+ "Auto-continue scheduled for %s in %.1fs (attempt %d/%d)",
218
+ self.chat_id,
219
+ delay,
220
+ attempt,
221
+ max_attempts,
222
+ )
@@ -0,0 +1,8 @@
1
+ """Body state plugin for diploid-agent."""
2
+
3
+ from diploid_plugins.body.body import BodyPlugin
4
+ from diploid_plugins.body.body_mcp import BodyMcpServer, main
5
+
6
+ Plugin = BodyPlugin
7
+
8
+ __all__ = ["BodyMcpServer", "BodyPlugin", "Plugin", "main"]