Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e885395
feat: deliver proactive sub-session results to opted-in rooms
overcuriousity Mar 25, 2026
de44f9b
fix: address Copilot review comments on proactive room delivery
overcuriousity Mar 25, 2026
72b262f
deps(python): bump requests from 2.32.5 to 2.33.0
dependabot[bot] Mar 26, 2026
774cf14
deps(python): bump aiohttp from 3.13.3 to 3.13.4
dependabot[bot] Mar 29, 2026
fefa1fb
deps(python): bump json-repair from 0.58.6 to 0.58.7
dependabot[bot] Mar 29, 2026
523a489
deps(python): bump ruff from 0.15.7 to 0.15.8
dependabot[bot] Mar 29, 2026
6de65d9
deps(python): bump openai from 2.29.0 to 2.30.0
dependabot[bot] Mar 29, 2026
4612ffa
deps(python): bump anthropic from 0.86.0 to 0.87.0
dependabot[bot] Apr 1, 2026
3068596
deps(actions): bump googleapis/release-please-action from 4 to 5
dependabot[bot] Apr 26, 2026
8a12347
deps(python): bump urllib3 from 2.6.3 to 2.7.0
dependabot[bot] May 11, 2026
984007e
deps(python): bump idna from 3.11 to 3.15
dependabot[bot] May 19, 2026
365cccd
fix: address Copilot PR review — deduplicate web broadcast, handle we…
overcuriousity May 21, 2026
d09eba6
fix: address Copilot PR review — propagate is_proactive in all SubSes…
overcuriousity May 21, 2026
e802f97
chore: update uv.lock
overcuriousity May 21, 2026
d2c2d8d
fix: propagate is_proactive in nested aggregation delivery path
overcuriousity May 21, 2026
bc9cb55
Merge pull request #232 from overcuriousity/feat/proactive-room-delivery
overcuriousity May 21, 2026
d519a0e
Merge pull request #260 from overcuriousity/dependabot/uv/idna-3.15
overcuriousity May 21, 2026
c6175c4
Merge pull request #246 from overcuriousity/dependabot/uv/urllib3-2.7.0
overcuriousity May 21, 2026
c39166c
Merge pull request #245 from overcuriousity/dependabot/github_actions…
overcuriousity May 21, 2026
0f14809
Merge pull request #244 from overcuriousity/dependabot/uv/anthropic-0…
overcuriousity May 21, 2026
5f31a29
Merge pull request #243 from overcuriousity/dependabot/uv/openai-2.30.0
overcuriousity May 21, 2026
2278239
Merge pull request #242 from overcuriousity/dependabot/uv/ruff-0.15.8
overcuriousity May 21, 2026
bccf1f0
Merge pull request #241 from overcuriousity/dependabot/uv/json-repair…
overcuriousity May 21, 2026
c365e79
Merge pull request #240 from overcuriousity/dependabot/uv/aiohttp-3.13.4
overcuriousity May 21, 2026
bd69eef
Merge pull request #238 from overcuriousity/dependabot/uv/requests-2.…
overcuriousity May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
- uses: googleapis/release-please-action@v5
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
6 changes: 5 additions & 1 deletion config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,11 @@ memory:
prediction_inject_prompt: true # Inject predictions into the system prompt
prediction_proactive_scheduling: true # Spawn proactive sessions at predicted active times
prediction_proactive_cooldown_hours: 4 # Min hours between proactive fires per prediction
# proactive_target_thread_id: "default" # Thread to deliver proactive results to (default: main thread)
# proactive_target_thread_id: "default" # Keep "default" to use /proactive on room opt-in (recommended); set a specific thread ID to force delivery there instead.
# Proactive delivery (experimental): use the /proactive on slash command in any Matrix/Signal
# room to opt that room in to receiving proactive check-in results. Multiple rooms can be
# opted in independently. Use /proactive off to remove a room. No config change required for
# the recommended default behavior (proactive_target_thread_id = "default").

# ── Confidence Gating (weak-model resilience) ──
# Programmatic validators reject garbage dreaming outputs before memory writes.
Expand Down
4 changes: 3 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,9 @@ Controls the dreaming pipeline parameters.
| `prediction_inject_prompt` | no | `true` | Inject predictions into the main-thread system prompt |
| `prediction_proactive_scheduling` | no | `true` | Spawn proactive sub-sessions at predicted active times |
| `prediction_proactive_cooldown_hours` | no | `4` | Minimum hours between proactive fires per prediction |
| `proactive_target_thread_id` | no | `"default"` | Thread ID to deliver proactive sub-session results to (e.g. a Matrix room thread); defaults to the main LLM thread |
| `proactive_target_thread_id` | no | `"default"` | When `"default"` (recommended), proactive results are eligible for broadcast to rooms opted in via `/proactive on`. When set to any other thread ID, proactive sub-sessions deliver only to that thread and room opt-in broadcast is bypassed. |

> **Experimental — Proactive delivery to rooms:** Proactive sub-sessions can deliver their results to specific Matrix/Signal rooms when `proactive_target_thread_id` is `"default"` (or unset). Each room must opt in via the `/proactive on` slash command. Results are delivered to all opted-in rooms simultaneously. Disable delivery for a room with `/proactive off`. If `proactive_target_thread_id` is set to a non-`"default"` thread, proactive results go only to that thread and room opt-in is bypassed. Because proactive sessions run with full tool access (including the ability to spawn sub-workers), they can take autonomous actions — not just summarise. Users will see the final LLM-processed response in their opted-in room.

**Access tracking:** Every `search()` call updates `last_accessed` and `access_count` for returned entries. This metadata drives stale pruning during dreaming.

Expand Down
226 changes: 115 additions & 111 deletions uv.lock

Large diffs are not rendered by default.

14 changes: 9 additions & 5 deletions wintermute/core/llm_thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class _QueueItem:
# dequeued, the correction is stale and will be dropped.
correction_for_seq: Optional[int] = None
ephemeral: bool = False # skip loading history (group mode: single-turn)
is_proactive: bool = False # result of a proactive sub-session (routes to opted-in rooms)


class LLMThread:
Expand Down Expand Up @@ -228,9 +229,11 @@ async def enqueue_user_message(
))
return await fut

async def enqueue_system_event(self, text: str, thread_id: str = "default") -> None:
async def enqueue_system_event(self, text: str, thread_id: str = "default", *,
is_proactive: bool = False) -> None:
"""Submit an autonomous system event (heartbeat, scheduled task, etc.)."""
await self._dispatch(_QueueItem(text=text, thread_id=thread_id, is_system_event=True))
await self._dispatch(_QueueItem(text=text, thread_id=thread_id, is_system_event=True,
is_proactive=is_proactive))

async def reset_session(self, thread_id: str = "default") -> None:
await self._session_mgr.reset_session(thread_id)
Expand Down Expand Up @@ -444,12 +447,13 @@ async def _thread_worker(self, thread_id: str) -> None:
elif item.is_system_event and not item.future and item.convergence_depth == 0:
text_to_send = reply.text or item.text
logger.info(
"Broadcasting system-event reply for thread %s (%d chars, reply_empty=%s)",
thread_id, len(text_to_send), not reply.text,
"Broadcasting system-event reply for thread %s (%d chars, reply_empty=%s, proactive=%s)",
thread_id, len(text_to_send), not reply.text, item.is_proactive,
)
try:
await self._broadcast(text_to_send, thread_id,
reasoning=reply.reasoning)
reasoning=reply.reasoning,
is_proactive=item.is_proactive)
except Exception: # noqa: BLE001
logger.exception("Failed to broadcast system-event reply for thread %s",
thread_id)
Expand Down
15 changes: 13 additions & 2 deletions wintermute/core/sub_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ class SubSessionState:
timeout_value: int = DEFAULT_TIMEOUT # configured timeout for this session
cp_verdict: str = "skipped" # CP verdict: 'pass' | 'fail' | 'skipped'
task_id: Optional[str] = None # originating scheduled task id (for reflection)
is_proactive: bool = False # set by scheduler for proactive sessions


@dataclass
Expand All @@ -230,6 +231,7 @@ class TaskNode:
max_rounds: Optional[int] = None # None = unlimited inference rounds
skip_cp_on_exit: bool = False # skip CP post_inference on terminal response
task_id: Optional[str] = None # originating scheduled task id (for reflection)
is_proactive: bool = False # set by scheduler for proactive sessions


@dataclass
Expand Down Expand Up @@ -375,6 +377,7 @@ def spawn(
profile: Optional[str] = None,
task_id: Optional[str] = None,
spawn_batch_id: Optional[str] = None,
is_proactive: bool = False,
) -> str:
"""
Register a sub-session and start it immediately (or defer if deps pending).
Expand Down Expand Up @@ -464,6 +467,7 @@ def spawn(
max_rounds=max_rounds,
skip_cp_on_exit=skip_cp_on_exit,
task_id=task_id,
is_proactive=is_proactive,
)

if deps:
Expand Down Expand Up @@ -493,6 +497,7 @@ def spawn(
system_prompt_mode=system_prompt_mode,
status="failed", created_at=now,
error=node.error,
is_proactive=is_proactive,
)
self._states[session_id] = state
report_coro = self._report(state, f"[SUB-SESSION {session_id} FAILED] dependency failed")
Expand All @@ -508,6 +513,7 @@ def spawn(
system_prompt_mode=system_prompt_mode,
status="pending", created_at=now,
nesting_depth=nesting_depth,
is_proactive=is_proactive,
)
self._states[session_id] = state
logger.info(
Expand Down Expand Up @@ -540,6 +546,7 @@ def spawn(
system_prompt_mode=system_prompt_mode,
status="pending", created_at=now,
nesting_depth=nesting_depth,
is_proactive=is_proactive,
)
self._states[session_id] = state
self._schedule_time_gate(session_id, node.not_before)
Expand Down Expand Up @@ -848,6 +855,7 @@ def _start_node(
skip_cp_on_exit=node.skip_cp_on_exit,
timeout_value=node.timeout,
task_id=node.task_id,
is_proactive=node.is_proactive,
)
self._states[session_id] = state

Expand Down Expand Up @@ -950,6 +958,7 @@ async def _resolve_dependents(self, session_id: str) -> None:
system_prompt_mode=n.system_prompt_mode,
status="failed", created_at="",
error=n.error,
is_proactive=n.is_proactive,
),
f"[SUB-SESSION {n.node_id} FAILED] dependency failed",
)
Expand Down Expand Up @@ -1564,7 +1573,8 @@ async def _report(self, state: SubSessionState, text: str) -> None:

if state.parent_thread_id:
try:
await self._enqueue(text, state.parent_thread_id)
is_proactive = state.is_proactive
await self._enqueue(text, state.parent_thread_id, is_proactive=is_proactive)
except Exception as exc: # noqa: BLE001
logger.error("Sub-session %s failed to report back: %s", state.session_id, exc)
else:
Expand Down Expand Up @@ -1650,7 +1660,8 @@ async def _check_nested_aggregation(self, completed_session_id: str) -> None:
len(siblings), parent_sid, root_tid,
)
try:
await self._enqueue(text, root_tid)
is_proactive = parent_state.is_proactive if parent_state else False
await self._enqueue(text, root_tid, is_proactive=is_proactive)
except Exception as exc: # noqa: BLE001
logger.error(
"Failed to deliver aggregated nested results to %s: %s",
Expand Down
14 changes: 14 additions & 0 deletions wintermute/infra/thread_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- ``memory_score_threshold`` — minimum similarity score for memory retrieval
- ``compaction_keep_recent`` — messages kept untouched during compaction
- ``max_inline_tool_rounds`` — inline tool call limit before CP enforcement
- ``proactive_enabled`` — opt this room/thread into proactive message delivery [experimental]

The ``ThreadConfigManager`` caches configs in memory and persists to SQLite.
All mutations are logged to the interaction_log for auditability.
Expand Down Expand Up @@ -49,6 +50,7 @@ class ThreadConfig:
memory_score_threshold: Optional[float] = None
compaction_keep_recent: Optional[int] = None
max_inline_tool_rounds: Optional[int] = None
proactive_enabled: Optional[bool] = None # opt-in for proactive message delivery [experimental]

def to_json(self) -> str:
return json.dumps(asdict(self))
Expand Down Expand Up @@ -76,6 +78,7 @@ class ResolvedThreadConfig:
memory_score_threshold: float
compaction_keep_recent: int
max_inline_tool_rounds: int
proactive_enabled: bool # opt-in for proactive message delivery [experimental]


# Hardcoded fallback defaults (baseline when no per-thread override exists).
Expand All @@ -91,6 +94,7 @@ class ResolvedThreadConfig:
"memory_score_threshold": 0.3,
"compaction_keep_recent": 10,
"max_inline_tool_rounds": 3,
"proactive_enabled": False,
}

# Valid values for system_prompt_mode.
Expand Down Expand Up @@ -218,6 +222,7 @@ def _pick(key: str):
memory_score_threshold=float(_pick("memory_score_threshold")),
compaction_keep_recent=int(_pick("compaction_keep_recent")),
max_inline_tool_rounds=int(_pick("max_inline_tool_rounds")),
proactive_enabled=bool(_pick("proactive_enabled")),
)

def resolve_as_dict(self, thread_id: str) -> dict:
Expand Down Expand Up @@ -305,6 +310,13 @@ def get_available_backends(self) -> list[str]:
"""Return the list of configured backend names."""
return list(self._available_backends)

def get_proactive_thread_ids(self) -> list[str]:
"""Return thread_ids that have opted in to proactive message delivery."""
return [
tid for tid, tc in self._cache.items()
if tc.proactive_enabled is True
]

def get_all_overrides(self) -> dict[str, dict]:
"""Return all thread_ids that have custom config (for SSE/debug)."""
result = {}
Expand Down Expand Up @@ -428,6 +440,8 @@ def set(self, thread_id: str, key: str, value: Any,
value = int(value)
if value < 0:
raise ValueError("max_inline_tool_rounds must be >= 0 (or null to use default)")
elif key == "proactive_enabled":
value = _parse_bool(value)

old_value = getattr(tc, key, None)
setattr(tc, key, value)
Expand Down
45 changes: 44 additions & 1 deletion wintermute/interfaces/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ async def dispatch(self, text: str, thread_id: str, send_fn: SendFn) -> bool:
if text == "/commands":
await self._cmd_commands(send_fn)
return True
if text.split(maxsplit=1)[0] == "/proactive":
await self._cmd_proactive(text, thread_id, send_fn)
return True

return False

Expand Down Expand Up @@ -493,6 +496,45 @@ async def _cmd_memory_stats(self, send_fn: SendFn) -> None:
except Exception as exc:
await send_fn(f"Failed to get memory stats: {exc}")

async def _cmd_proactive(self, text: str, thread_id: str, send_fn: SendFn) -> None:
"""[experimental] Opt this room in or out of proactive message delivery."""
mgr = self._thread_config_manager
if not mgr:
await send_fn("Thread config manager not available.")
return

parts = text.strip().split()
if len(parts) == 1:
# Show current status.
resolved = mgr.resolve(thread_id)
state = "on" if resolved.proactive_enabled else "off"
await send_fn(
f"Proactive delivery for this room is currently **{state}**.\n"
f"Use `/proactive on` or `/proactive off` to change it.\n"
f"*(experimental)*"
)
return

arg = parts[1].lower()
if arg not in ("on", "off"):
await send_fn("Usage: `/proactive on` or `/proactive off`")
return

try:
mgr.set(thread_id, "proactive_enabled", arg, source="slash")
except ValueError as exc:
await send_fn(f"Error: {exc}")
return

if arg == "on":
await send_fn(
"Proactive delivery **enabled** for this room. "
"Scheduled proactive check-ins will be delivered here.\n"
"*(experimental — use `/proactive off` to disable)*"
)
else:
await send_fn("Proactive delivery **disabled** for this room.")

async def _cmd_commands(self, send_fn: SendFn) -> None:
await send_fn(
"**Wintermute — Slash Commands**\n\n"
Expand All @@ -502,7 +544,8 @@ async def _cmd_commands(self, send_fn: SendFn) -> None:
"**Autonomy**\n"
"- `/tasks` — List all active tasks\n"
"- `/dream` — Run a dream cycle (memory consolidation + task pruning)\n"
"- `/reflect` — Trigger a reflection cycle; shows findings and self-model update\n\n"
"- `/reflect` — Trigger a reflection cycle; shows findings and self-model update\n"
"- `/proactive [on|off]` — Opt this room in/out of proactive check-ins *(experimental)*\n\n"
"**Memory**\n"
"- `/memory-stats` — Show memory store backend, entry count, and status\n"
"- `/rebuild-index` — Rebuild the vector memory index\n\n"
Expand Down
21 changes: 20 additions & 1 deletion wintermute/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,8 @@ def _tuning_int(key: str, default: int, minimum: int = 0) -> int:
_signal_ref: list[Optional[SignalThread]] = [None]

async def broadcast(text: str, thread_id: Optional[str] = None, *,
reasoning: Optional[str] = None) -> None:
reasoning: Optional[str] = None,
is_proactive: bool = False) -> None:
mx = _matrix_ref[0]
wi = _web_ref[0]
si = _signal_ref[0]
Expand All @@ -587,6 +588,24 @@ async def broadcast(text: str, thread_id: Optional[str] = None, *,
await mx.send_message(text, thread_id)
if wi and thread_id:
await wi.broadcast(text, thread_id, reasoning=reasoning)
# Proactive results: deliver to all rooms that have opted in via /proactive on.
# This fires only when thread_id == "default" (proactive sessions have no specific
# target room), ensuring regular messages are never duplicated.
if is_proactive and thread_id == "default":
opted_in = thread_config_mgr.get_proactive_thread_ids()
if opted_in:
async def _send_to_room(tid: str) -> None:
try:
if si and tid.startswith("sig_"):
await si.send_message(text, tid)
elif wi and tid.startswith("web_"):
await wi.broadcast(text, tid, reasoning=reasoning)
elif mx and not tid.startswith("web_") and not tid.startswith("sig_"):
await mx.send_message(text, tid)
except Exception as exc: # noqa: BLE001
Comment on lines +594 to +605
logger.warning("Failed to deliver proactive message to %s: %s", tid, exc, exc_info=True)

await asyncio.gather(*(_send_to_room(tid) for tid in opted_in))

# 2. LLMThread — uses a lazy getter for SubSessionManager (breaks cycle).
seed_language = cfg.get("seed", {}).get("language", "en") if cfg.get("seed") else "en"
Expand Down
1 change: 1 addition & 0 deletions wintermute/workers/scheduler_thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,7 @@ async def _check_predictions(self) -> None:
),
system_prompt_mode="full",
parent_thread_id=self._cfg.proactive_target_thread_id,
is_proactive=True,
)

if self._event_bus:
Expand Down
Loading