forked from Rockachopa/Timmy-time-dashboard
Compare commits
1 Commits
fix/loop-g
...
kimi/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3de7db770f |
@@ -257,6 +257,28 @@ class ThinkingEngine:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
content, seed_type = await self._generate_thought(prompt)
|
||||||
|
if not content:
|
||||||
|
return None
|
||||||
|
|
||||||
|
thought = self._store_thought(content, seed_type)
|
||||||
|
self._last_thought_id = thought.id
|
||||||
|
|
||||||
|
await self._finalize_thought(thought)
|
||||||
|
return thought
|
||||||
|
|
||||||
|
async def _generate_thought(self, prompt: str | None = None) -> tuple[str | None, str]:
|
||||||
|
"""Generate novel thought content via the dedup retry loop.
|
||||||
|
|
||||||
|
Gathers context, builds the LLM prompt, calls the agent, and
|
||||||
|
retries with a fresh seed if the result is too similar to recent
|
||||||
|
thoughts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A (content, seed_type) tuple. *content* is ``None`` when the
|
||||||
|
cycle should be skipped (agent failure, empty response, or
|
||||||
|
all retries exhausted).
|
||||||
|
"""
|
||||||
memory_context = self._load_memory_context()
|
memory_context = self._load_memory_context()
|
||||||
system_context = self._gather_system_snapshot()
|
system_context = self._gather_system_snapshot()
|
||||||
recent_thoughts = self.get_recent_thoughts(limit=5)
|
recent_thoughts = self.get_recent_thoughts(limit=5)
|
||||||
@@ -284,11 +306,11 @@ class ThinkingEngine:
|
|||||||
raw = await self._call_agent(full_prompt)
|
raw = await self._call_agent(full_prompt)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Thinking cycle failed (Ollama likely down): %s", exc)
|
logger.warning("Thinking cycle failed (Ollama likely down): %s", exc)
|
||||||
return None
|
return None, seed_type
|
||||||
|
|
||||||
if not raw or not raw.strip():
|
if not raw or not raw.strip():
|
||||||
logger.debug("Thinking cycle produced empty response, skipping")
|
logger.debug("Thinking cycle produced empty response, skipping")
|
||||||
return None
|
return None, seed_type
|
||||||
|
|
||||||
content = raw.strip()
|
content = raw.strip()
|
||||||
|
|
||||||
@@ -308,48 +330,28 @@ class ThinkingEngine:
|
|||||||
"Thought still repetitive after %d retries, discarding",
|
"Thought still repetitive after %d retries, discarding",
|
||||||
self._MAX_DEDUP_RETRIES + 1,
|
self._MAX_DEDUP_RETRIES + 1,
|
||||||
)
|
)
|
||||||
return None
|
return None, seed_type
|
||||||
|
|
||||||
if not content:
|
return content, seed_type
|
||||||
return None
|
|
||||||
|
|
||||||
thought = self._store_thought(content, seed_type)
|
async def _finalize_thought(self, thought: Thought) -> None:
|
||||||
self._last_thought_id = thought.id
|
"""Run post-hooks, log, journal, and broadcast a stored thought."""
|
||||||
|
|
||||||
# Post-hook: check memory status periodically
|
|
||||||
self._maybe_check_memory()
|
self._maybe_check_memory()
|
||||||
|
|
||||||
# Post-hook: distill facts from recent thoughts periodically
|
|
||||||
await self._maybe_distill()
|
await self._maybe_distill()
|
||||||
|
|
||||||
# Post-hook: file Gitea issues for actionable observations
|
|
||||||
await self._maybe_file_issues()
|
await self._maybe_file_issues()
|
||||||
|
|
||||||
# Post-hook: check workspace for new messages from Hermes
|
|
||||||
await self._check_workspace()
|
await self._check_workspace()
|
||||||
|
|
||||||
# Post-hook: proactive memory status audit
|
|
||||||
self._maybe_check_memory_status()
|
self._maybe_check_memory_status()
|
||||||
|
|
||||||
# Post-hook: update MEMORY.md with latest reflection
|
|
||||||
self._update_memory(thought)
|
self._update_memory(thought)
|
||||||
|
|
||||||
# Log to swarm event system
|
|
||||||
self._log_event(thought)
|
self._log_event(thought)
|
||||||
|
|
||||||
# Append to daily journal file
|
|
||||||
self._write_journal(thought)
|
self._write_journal(thought)
|
||||||
|
|
||||||
# Broadcast to WebSocket clients
|
|
||||||
await self._broadcast(thought)
|
await self._broadcast(thought)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Thought [%s] (%s): %s",
|
"Thought [%s] (%s): %s",
|
||||||
thought.id[:8],
|
thought.id[:8],
|
||||||
seed_type,
|
thought.seed_type,
|
||||||
thought.content[:80],
|
thought.content[:80],
|
||||||
)
|
)
|
||||||
return thought
|
|
||||||
|
|
||||||
def get_recent_thoughts(self, limit: int = 20) -> list[Thought]:
|
def get_recent_thoughts(self, limit: int = 20) -> list[Thought]:
|
||||||
"""Retrieve the most recent thoughts."""
|
"""Retrieve the most recent thoughts."""
|
||||||
|
|||||||
@@ -250,6 +250,99 @@ def test_continuity_includes_recent(tmp_path):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _generate_thought helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_thought_returns_content_and_seed_type(tmp_path):
|
||||||
|
"""_generate_thought should return (content, seed_type) on success."""
|
||||||
|
from timmy.thinking import SEED_TYPES
|
||||||
|
|
||||||
|
engine = _make_engine(tmp_path)
|
||||||
|
|
||||||
|
with patch.object(engine, "_call_agent", return_value="A novel idea."):
|
||||||
|
content, seed_type = await engine._generate_thought()
|
||||||
|
|
||||||
|
assert content == "A novel idea."
|
||||||
|
assert seed_type in SEED_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_thought_with_prompt(tmp_path):
|
||||||
|
"""_generate_thought(prompt=...) should use 'prompted' seed type."""
|
||||||
|
engine = _make_engine(tmp_path)
|
||||||
|
|
||||||
|
with patch.object(engine, "_call_agent", return_value="A prompted idea."):
|
||||||
|
content, seed_type = await engine._generate_thought(prompt="Reflect on joy")
|
||||||
|
|
||||||
|
assert content == "A prompted idea."
|
||||||
|
assert seed_type == "prompted"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_thought_returns_none_on_agent_failure(tmp_path):
|
||||||
|
"""_generate_thought should return (None, ...) when the agent fails."""
|
||||||
|
engine = _make_engine(tmp_path)
|
||||||
|
|
||||||
|
with patch.object(engine, "_call_agent", side_effect=Exception("Ollama down")):
|
||||||
|
content, seed_type = await engine._generate_thought()
|
||||||
|
|
||||||
|
assert content is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_thought_returns_none_on_empty(tmp_path):
|
||||||
|
"""_generate_thought should return (None, ...) when agent returns empty."""
|
||||||
|
engine = _make_engine(tmp_path)
|
||||||
|
|
||||||
|
with patch.object(engine, "_call_agent", return_value=" "):
|
||||||
|
content, seed_type = await engine._generate_thought()
|
||||||
|
|
||||||
|
assert content is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _finalize_thought helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finalize_thought_calls_all_hooks(tmp_path):
|
||||||
|
"""_finalize_thought should call all post-hooks, log, journal, and broadcast."""
|
||||||
|
engine = _make_engine(tmp_path)
|
||||||
|
thought = engine._store_thought("Test finalize.", "freeform")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(engine, "_maybe_check_memory") as m_mem,
|
||||||
|
patch.object(engine, "_maybe_distill", new_callable=AsyncMock) as m_distill,
|
||||||
|
patch.object(engine, "_maybe_file_issues", new_callable=AsyncMock) as m_issues,
|
||||||
|
patch.object(engine, "_check_workspace", new_callable=AsyncMock) as m_ws,
|
||||||
|
patch.object(engine, "_maybe_check_memory_status") as m_status,
|
||||||
|
patch.object(engine, "_update_memory") as m_update,
|
||||||
|
patch.object(engine, "_log_event") as m_log,
|
||||||
|
patch.object(engine, "_write_journal") as m_journal,
|
||||||
|
patch.object(engine, "_broadcast", new_callable=AsyncMock) as m_broadcast,
|
||||||
|
):
|
||||||
|
await engine._finalize_thought(thought)
|
||||||
|
|
||||||
|
m_mem.assert_called_once()
|
||||||
|
m_distill.assert_awaited_once()
|
||||||
|
m_issues.assert_awaited_once()
|
||||||
|
m_ws.assert_awaited_once()
|
||||||
|
m_status.assert_called_once()
|
||||||
|
m_update.assert_called_once_with(thought)
|
||||||
|
m_log.assert_called_once_with(thought)
|
||||||
|
m_journal.assert_called_once_with(thought)
|
||||||
|
m_broadcast.assert_awaited_once_with(thought)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# think_once (async)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_think_once_stores_thought(tmp_path):
|
async def test_think_once_stores_thought(tmp_path):
|
||||||
"""think_once should store a thought in the DB."""
|
"""think_once should store a thought in the DB."""
|
||||||
|
|||||||
Reference in New Issue
Block a user