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.
- diploid_plugins-0.1.0/LICENSE +21 -0
- diploid_plugins-0.1.0/PKG-INFO +101 -0
- diploid_plugins-0.1.0/README.md +76 -0
- diploid_plugins-0.1.0/pyproject.toml +46 -0
- diploid_plugins-0.1.0/setup.cfg +4 -0
- diploid_plugins-0.1.0/src/diploid_plugins/__init__.py +25 -0
- diploid_plugins-0.1.0/src/diploid_plugins/auto_continue/__init__.py +5 -0
- diploid_plugins-0.1.0/src/diploid_plugins/auto_continue/auto_continue.py +222 -0
- diploid_plugins-0.1.0/src/diploid_plugins/body/__init__.py +8 -0
- diploid_plugins-0.1.0/src/diploid_plugins/body/body.py +186 -0
- diploid_plugins-0.1.0/src/diploid_plugins/body/body_mcp.py +288 -0
- diploid_plugins-0.1.0/src/diploid_plugins/body/config.py +21 -0
- diploid_plugins-0.1.0/src/diploid_plugins/body/manager.py +364 -0
- diploid_plugins-0.1.0/src/diploid_plugins/continuity/__init__.py +5 -0
- diploid_plugins-0.1.0/src/diploid_plugins/continuity/continuity.py +463 -0
- diploid_plugins-0.1.0/src/diploid_plugins/curriculum/__init__.py +5 -0
- diploid_plugins-0.1.0/src/diploid_plugins/curriculum/curriculum.py +131 -0
- diploid_plugins-0.1.0/src/diploid_plugins/curriculum/curriculum_mcp.py +198 -0
- diploid_plugins-0.1.0/src/diploid_plugins/harness/__init__.py +1 -0
- diploid_plugins-0.1.0/src/diploid_plugins/harness/harness_mcp.py +161 -0
- diploid_plugins-0.1.0/src/diploid_plugins/identity/__init__.py +5 -0
- diploid_plugins-0.1.0/src/diploid_plugins/identity/identity.py +160 -0
- diploid_plugins-0.1.0/src/diploid_plugins/identity/identity_mcp.py +193 -0
- diploid_plugins-0.1.0/src/diploid_plugins/persistent_memory/__init__.py +5 -0
- diploid_plugins-0.1.0/src/diploid_plugins/persistent_memory/persistent_memory.py +322 -0
- diploid_plugins-0.1.0/src/diploid_plugins/planner/__init__.py +7 -0
- diploid_plugins-0.1.0/src/diploid_plugins/planner/planner.py +240 -0
- diploid_plugins-0.1.0/src/diploid_plugins/self_management/__init__.py +5 -0
- diploid_plugins-0.1.0/src/diploid_plugins/self_management/approvals.py +68 -0
- diploid_plugins-0.1.0/src/diploid_plugins/self_management/self_management.py +13 -0
- diploid_plugins-0.1.0/src/diploid_plugins/self_management/self_management_mcp.py +343 -0
- diploid_plugins-0.1.0/src/diploid_plugins/self_state/__init__.py +5 -0
- diploid_plugins-0.1.0/src/diploid_plugins/self_state/self_state.py +207 -0
- diploid_plugins-0.1.0/src/diploid_plugins/working_memory/__init__.py +5 -0
- diploid_plugins-0.1.0/src/diploid_plugins/working_memory/working_memory.py +194 -0
- diploid_plugins-0.1.0/src/diploid_plugins/working_memory/working_memory_mcp.py +196 -0
- diploid_plugins-0.1.0/src/diploid_plugins.egg-info/PKG-INFO +101 -0
- diploid_plugins-0.1.0/src/diploid_plugins.egg-info/SOURCES.txt +50 -0
- diploid_plugins-0.1.0/src/diploid_plugins.egg-info/dependency_links.txt +1 -0
- diploid_plugins-0.1.0/src/diploid_plugins.egg-info/requires.txt +7 -0
- diploid_plugins-0.1.0/src/diploid_plugins.egg-info/top_level.txt +1 -0
- diploid_plugins-0.1.0/tests/test_auto_continue.py +616 -0
- diploid_plugins-0.1.0/tests/test_body.py +401 -0
- diploid_plugins-0.1.0/tests/test_continuity_partial.py +670 -0
- diploid_plugins-0.1.0/tests/test_identity.py +271 -0
- diploid_plugins-0.1.0/tests/test_persistent_memory.py +327 -0
- diploid_plugins-0.1.0/tests/test_planner_plugin.py +276 -0
- diploid_plugins-0.1.0/tests/test_plugins.py +58 -0
- diploid_plugins-0.1.0/tests/test_self_management_approval.py +11 -0
- diploid_plugins-0.1.0/tests/test_self_management_mcp.py +26 -0
- diploid_plugins-0.1.0/tests/test_self_state.py +492 -0
- 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,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,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
|
+
)
|