Compare commits
1 Commits
kimi/issue
...
kimi/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9acc0150f1 |
@@ -94,17 +94,12 @@ def extract_cycle_number(title: str) -> int | None:
|
|||||||
return int(m.group(1)) if m else None
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
def extract_issue_number(title: str, body: str, pr_number: int | None = None) -> int | None:
|
def extract_issue_number(title: str, body: str) -> int | None:
|
||||||
"""Extract the issue number from PR body/title, ignoring the PR number itself.
|
# Try body first (usually has "closes #N")
|
||||||
|
|
||||||
Gitea appends "(#N)" to PR titles where N is the PR number — skip that
|
|
||||||
so we don't confuse it with the linked issue.
|
|
||||||
"""
|
|
||||||
for text in [body or "", title]:
|
for text in [body or "", title]:
|
||||||
for m in ISSUE_RE.finditer(text):
|
m = ISSUE_RE.search(text)
|
||||||
num = int(m.group(1))
|
if m:
|
||||||
if num != pr_number:
|
return int(m.group(1))
|
||||||
return num
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -145,7 +140,7 @@ def main():
|
|||||||
else:
|
else:
|
||||||
cycle_counter = max(cycle_counter, cycle)
|
cycle_counter = max(cycle_counter, cycle)
|
||||||
|
|
||||||
issue = extract_issue_number(title, body, pr_number=pr_num)
|
issue = extract_issue_number(title, body)
|
||||||
issue_type = classify_pr(title, body)
|
issue_type = classify_pr(title, body)
|
||||||
duration = estimate_duration(pr)
|
duration = estimate_duration(pr)
|
||||||
diff = get_pr_diff_stats(token, pr_num)
|
diff = get_pr_diff_stats(token, pr_num)
|
||||||
|
|||||||
@@ -58,23 +58,6 @@ EPOCH_COUNTER_FILE = REPO_ROOT / ".loop" / "retro" / ".epoch_counter"
|
|||||||
# How many recent entries to include in rolling summary
|
# How many recent entries to include in rolling summary
|
||||||
SUMMARY_WINDOW = 50
|
SUMMARY_WINDOW = 50
|
||||||
|
|
||||||
# Branch patterns that encode an issue number, e.g. kimi/issue-492
|
|
||||||
BRANCH_ISSUE_RE = re.compile(r"issue[/-](\d+)", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def detect_issue_from_branch() -> int | None:
|
|
||||||
"""Try to extract an issue number from the current git branch name."""
|
|
||||||
try:
|
|
||||||
branch = subprocess.check_output(
|
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
text=True,
|
|
||||||
).strip()
|
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
||||||
return None
|
|
||||||
m = BRANCH_ISSUE_RE.search(branch)
|
|
||||||
return int(m.group(1)) if m else None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Epoch turnover ────────────────────────────────────────────────────────
|
# ── Epoch turnover ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -116,6 +99,27 @@ def _epoch_tag(now: datetime | None = None) -> tuple[str, dict]:
|
|||||||
return tag, parts
|
return tag, parts
|
||||||
|
|
||||||
|
|
||||||
|
BRANCH_ISSUE_RE = re.compile(r"issue-(\d+)")
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_issue_from_branch() -> int | None:
|
||||||
|
"""Try to extract an issue number from the current git branch name.
|
||||||
|
|
||||||
|
Matches branch patterns like ``kimi/issue-492`` or ``fix/issue-17``.
|
||||||
|
Returns ``None`` when not on a matching branch or git is unavailable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
branch = subprocess.check_output(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
).strip()
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
return None
|
||||||
|
m = BRANCH_ISSUE_RE.search(branch)
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
p = argparse.ArgumentParser(description="Log a cycle retrospective")
|
p = argparse.ArgumentParser(description="Log a cycle retrospective")
|
||||||
p.add_argument("--cycle", type=int, required=True)
|
p.add_argument("--cycle", type=int, required=True)
|
||||||
@@ -249,9 +253,12 @@ def update_summary() -> None:
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
# Auto-detect issue from branch when not explicitly provided
|
# Auto-detect issue from branch name when not explicitly provided
|
||||||
if args.issue is None:
|
if args.issue is None:
|
||||||
args.issue = detect_issue_from_branch()
|
detected = _detect_issue_from_branch()
|
||||||
|
if detected is not None:
|
||||||
|
args.issue = detected
|
||||||
|
print(f"[retro] Auto-detected issue #{detected} from branch name")
|
||||||
|
|
||||||
# Reject idle cycles — no issue and no duration means nothing happened
|
# Reject idle cycles — no issue and no duration means nothing happened
|
||||||
if not args.issue and args.duration == 0:
|
if not args.issue and args.duration == 0:
|
||||||
|
|||||||
@@ -10,11 +10,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
APP_START_TIME: _datetime = _datetime.now(UTC)
|
APP_START_TIME: _datetime = _datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
def normalize_ollama_url(url: str) -> str:
|
|
||||||
"""Replace localhost with 127.0.0.1 to avoid IPv6 resolution delays."""
|
|
||||||
return url.replace("localhost", "127.0.0.1")
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""Central configuration — all env-var access goes through this class."""
|
"""Central configuration — all env-var access goes through this class."""
|
||||||
|
|
||||||
@@ -24,11 +19,6 @@ class Settings(BaseSettings):
|
|||||||
# Ollama host — override with OLLAMA_URL env var or .env file
|
# Ollama host — override with OLLAMA_URL env var or .env file
|
||||||
ollama_url: str = "http://localhost:11434"
|
ollama_url: str = "http://localhost:11434"
|
||||||
|
|
||||||
@property
|
|
||||||
def normalized_ollama_url(self) -> str:
|
|
||||||
"""Return ollama_url with localhost replaced by 127.0.0.1."""
|
|
||||||
return normalize_ollama_url(self.ollama_url)
|
|
||||||
|
|
||||||
# LLM model passed to Agno/Ollama — override with OLLAMA_MODEL
|
# LLM model passed to Agno/Ollama — override with OLLAMA_MODEL
|
||||||
# qwen3:30b is the primary model — better reasoning and tool calling
|
# qwen3:30b is the primary model — better reasoning and tool calling
|
||||||
# than llama3.1:8b-instruct while still running locally on modest hardware.
|
# than llama3.1:8b-instruct while still running locally on modest hardware.
|
||||||
@@ -402,7 +392,7 @@ def check_ollama_model_available(model_name: str) -> bool:
|
|||||||
import json
|
import json
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
url = settings.normalized_ollama_url
|
url = settings.ollama_url.replace("localhost", "127.0.0.1")
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{url}/api/tags",
|
f"{url}/api/tags",
|
||||||
method="GET",
|
method="GET",
|
||||||
|
|||||||
@@ -329,35 +329,33 @@ async def _discord_token_watcher() -> None:
|
|||||||
logger.warning("Discord auto-start failed: %s", exc)
|
logger.warning("Discord auto-start failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
def _startup_init() -> None:
|
@asynccontextmanager
|
||||||
"""Validate config and enable event persistence."""
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Application lifespan manager with non-blocking startup."""
|
||||||
|
|
||||||
|
# Validate security config (no-op in test mode)
|
||||||
from config import validate_startup
|
from config import validate_startup
|
||||||
|
|
||||||
validate_startup()
|
validate_startup()
|
||||||
|
|
||||||
|
# Enable event persistence (unified EventBus + swarm event_log)
|
||||||
from infrastructure.events.bus import init_event_bus_persistence
|
from infrastructure.events.bus import init_event_bus_persistence
|
||||||
|
|
||||||
init_event_bus_persistence()
|
init_event_bus_persistence()
|
||||||
|
|
||||||
|
# Create all background tasks without waiting for them
|
||||||
|
briefing_task = asyncio.create_task(_briefing_scheduler())
|
||||||
|
thinking_task = asyncio.create_task(_thinking_scheduler())
|
||||||
|
loop_qa_task = asyncio.create_task(_loop_qa_scheduler())
|
||||||
|
presence_task = asyncio.create_task(_presence_watcher())
|
||||||
|
|
||||||
|
# Initialize Spark Intelligence engine
|
||||||
from spark.engine import get_spark_engine
|
from spark.engine import get_spark_engine
|
||||||
|
|
||||||
if get_spark_engine().enabled:
|
if get_spark_engine().enabled:
|
||||||
logger.info("Spark Intelligence active — event capture enabled")
|
logger.info("Spark Intelligence active — event capture enabled")
|
||||||
|
|
||||||
|
# Auto-prune old vector store memories on startup
|
||||||
def _startup_background_tasks() -> list[asyncio.Task]:
|
|
||||||
"""Spawn all recurring background tasks (non-blocking)."""
|
|
||||||
return [
|
|
||||||
asyncio.create_task(_briefing_scheduler()),
|
|
||||||
asyncio.create_task(_thinking_scheduler()),
|
|
||||||
asyncio.create_task(_loop_qa_scheduler()),
|
|
||||||
asyncio.create_task(_presence_watcher()),
|
|
||||||
asyncio.create_task(_start_chat_integrations_background()),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _startup_pruning() -> None:
|
|
||||||
"""Auto-prune old memories, thoughts, and events on startup."""
|
|
||||||
if settings.memory_prune_days > 0:
|
if settings.memory_prune_days > 0:
|
||||||
try:
|
try:
|
||||||
from timmy.memory_system import prune_memories
|
from timmy.memory_system import prune_memories
|
||||||
@@ -375,6 +373,7 @@ def _startup_pruning() -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Memory auto-prune skipped: %s", exc)
|
logger.debug("Memory auto-prune skipped: %s", exc)
|
||||||
|
|
||||||
|
# Auto-prune old thoughts on startup
|
||||||
if settings.thoughts_prune_days > 0:
|
if settings.thoughts_prune_days > 0:
|
||||||
try:
|
try:
|
||||||
from timmy.thinking import thinking_engine
|
from timmy.thinking import thinking_engine
|
||||||
@@ -392,6 +391,7 @@ def _startup_pruning() -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Thought auto-prune skipped: %s", exc)
|
logger.debug("Thought auto-prune skipped: %s", exc)
|
||||||
|
|
||||||
|
# Auto-prune old system events on startup
|
||||||
if settings.events_prune_days > 0:
|
if settings.events_prune_days > 0:
|
||||||
try:
|
try:
|
||||||
from swarm.event_log import prune_old_events
|
from swarm.event_log import prune_old_events
|
||||||
@@ -409,6 +409,7 @@ def _startup_pruning() -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Event auto-prune skipped: %s", exc)
|
logger.debug("Event auto-prune skipped: %s", exc)
|
||||||
|
|
||||||
|
# Warn if memory vault exceeds size limit
|
||||||
if settings.memory_vault_max_mb > 0:
|
if settings.memory_vault_max_mb > 0:
|
||||||
try:
|
try:
|
||||||
vault_path = Path(settings.repo_root) / "memory" / "notes"
|
vault_path = Path(settings.repo_root) / "memory" / "notes"
|
||||||
@@ -424,18 +425,37 @@ def _startup_pruning() -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Vault size check skipped: %s", exc)
|
logger.debug("Vault size check skipped: %s", exc)
|
||||||
|
|
||||||
|
# Start Workshop presence heartbeat with WS relay
|
||||||
|
from dashboard.routes.world import broadcast_world_state
|
||||||
|
from timmy.workshop_state import WorkshopHeartbeat
|
||||||
|
|
||||||
async def _shutdown_cleanup(
|
workshop_heartbeat = WorkshopHeartbeat(on_change=broadcast_world_state)
|
||||||
bg_tasks: list[asyncio.Task],
|
await workshop_heartbeat.start()
|
||||||
workshop_heartbeat,
|
|
||||||
) -> None:
|
# Start chat integrations in background
|
||||||
"""Stop chat bots, MCP sessions, heartbeat, and cancel background tasks."""
|
chat_task = asyncio.create_task(_start_chat_integrations_background())
|
||||||
|
|
||||||
|
# Register session logger with error capture (breaks infrastructure → timmy circular dep)
|
||||||
|
try:
|
||||||
|
from infrastructure.error_capture import register_error_recorder
|
||||||
|
from timmy.session_logger import get_session_logger
|
||||||
|
|
||||||
|
register_error_recorder(get_session_logger().record_error)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info("✓ Dashboard ready for requests")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Cleanup on shutdown
|
||||||
from integrations.chat_bridge.vendors.discord import discord_bot
|
from integrations.chat_bridge.vendors.discord import discord_bot
|
||||||
from integrations.telegram_bot.bot import telegram_bot
|
from integrations.telegram_bot.bot import telegram_bot
|
||||||
|
|
||||||
await discord_bot.stop()
|
await discord_bot.stop()
|
||||||
await telegram_bot.stop()
|
await telegram_bot.stop()
|
||||||
|
|
||||||
|
# Close MCP tool server sessions
|
||||||
try:
|
try:
|
||||||
from timmy.mcp_tools import close_mcp_sessions
|
from timmy.mcp_tools import close_mcp_sessions
|
||||||
|
|
||||||
@@ -445,42 +465,13 @@ async def _shutdown_cleanup(
|
|||||||
|
|
||||||
await workshop_heartbeat.stop()
|
await workshop_heartbeat.stop()
|
||||||
|
|
||||||
for task in bg_tasks:
|
for task in [briefing_task, thinking_task, chat_task, loop_qa_task, presence_task]:
|
||||||
task.cancel()
|
if task:
|
||||||
try:
|
task.cancel()
|
||||||
await task
|
try:
|
||||||
except asyncio.CancelledError:
|
await task
|
||||||
pass
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
"""Application lifespan manager with non-blocking startup."""
|
|
||||||
_startup_init()
|
|
||||||
bg_tasks = _startup_background_tasks()
|
|
||||||
_startup_pruning()
|
|
||||||
|
|
||||||
# Start Workshop presence heartbeat with WS relay
|
|
||||||
from dashboard.routes.world import broadcast_world_state
|
|
||||||
from timmy.workshop_state import WorkshopHeartbeat
|
|
||||||
|
|
||||||
workshop_heartbeat = WorkshopHeartbeat(on_change=broadcast_world_state)
|
|
||||||
await workshop_heartbeat.start()
|
|
||||||
|
|
||||||
# Register session logger with error capture
|
|
||||||
try:
|
|
||||||
from infrastructure.error_capture import register_error_recorder
|
|
||||||
from timmy.session_logger import get_session_logger
|
|
||||||
|
|
||||||
register_error_recorder(get_session_logger().record_error)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Failed to register error recorder")
|
|
||||||
|
|
||||||
logger.info("✓ Dashboard ready for requests")
|
|
||||||
|
|
||||||
yield
|
|
||||||
|
|
||||||
await _shutdown_cleanup(bg_tasks, workshop_heartbeat)
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ async def chat_agent(request: Request, message: str = Form(...)):
|
|||||||
|
|
||||||
thinking_engine.record_user_input()
|
thinking_engine.record_user_input()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to record user input for thinking engine")
|
pass
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||||
response_text = None
|
response_text = None
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ async def api_chat(request: Request):
|
|||||||
|
|
||||||
thinking_engine.record_user_input()
|
thinking_engine.record_user_input()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to record user input for thinking engine")
|
pass
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ def _check_ollama_sync() -> DependencyStatus:
|
|||||||
try:
|
try:
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
url = settings.normalized_ollama_url
|
url = settings.ollama_url.replace("localhost", "127.0.0.1")
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{url}/api/tags",
|
f"{url}/api/tags",
|
||||||
method="GET",
|
method="GET",
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ async def api_briefing_status():
|
|||||||
if cached:
|
if cached:
|
||||||
last_generated = cached.generated_at.isoformat()
|
last_generated = cached.generated_at.isoformat()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to read briefing cache")
|
pass
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{
|
{
|
||||||
@@ -190,7 +190,6 @@ async def api_memory_status():
|
|||||||
stats = get_memory_stats()
|
stats = get_memory_stats()
|
||||||
indexed_files = stats.get("total_entries", 0)
|
indexed_files = stats.get("total_entries", 0)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to get memory stats")
|
|
||||||
indexed_files = 0
|
indexed_files = 0
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -216,7 +215,7 @@ async def api_swarm_status():
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
pending_tasks = row["cnt"] if row else 0
|
pending_tasks = row["cnt"] if row else 0
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to count pending tasks")
|
pass
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ async def _heartbeat(websocket: WebSocket) -> None:
|
|||||||
await asyncio.sleep(_HEARTBEAT_INTERVAL)
|
await asyncio.sleep(_HEARTBEAT_INTERVAL)
|
||||||
await websocket.send_text(json.dumps({"type": "ping"}))
|
await websocket.send_text(json.dumps({"type": "ping"}))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Heartbeat stopped — connection gone")
|
pass # connection gone — receive loop will clean up
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws")
|
@router.websocket("/ws")
|
||||||
@@ -250,7 +250,7 @@ async def world_ws(websocket: WebSocket) -> None:
|
|||||||
raw = await websocket.receive_text()
|
raw = await websocket.receive_text()
|
||||||
await _handle_client_message(raw)
|
await _handle_client_message(raw)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("WebSocket receive loop ended")
|
pass
|
||||||
finally:
|
finally:
|
||||||
ping_task.cancel()
|
ping_task.cancel()
|
||||||
if websocket in _ws_clients:
|
if websocket in _ws_clients:
|
||||||
@@ -265,7 +265,6 @@ async def _broadcast(message: str) -> None:
|
|||||||
try:
|
try:
|
||||||
await ws.send_text(message)
|
await ws.send_text(message)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Pruning dead WebSocket client")
|
|
||||||
dead.append(ws)
|
dead.append(ws)
|
||||||
for ws in dead:
|
for ws in dead:
|
||||||
if ws in _ws_clients:
|
if ws in _ws_clients:
|
||||||
@@ -341,7 +340,7 @@ async def _bark_and_broadcast(visitor_text: str) -> None:
|
|||||||
|
|
||||||
pip_familiar.on_event("visitor_spoke")
|
pip_familiar.on_event("visitor_spoke")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Pip familiar notification failed (optional)")
|
pass # Pip is optional
|
||||||
|
|
||||||
_refresh_ground(visitor_text)
|
_refresh_ground(visitor_text)
|
||||||
_tick_commitments()
|
_tick_commitments()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import logging
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
|
|
||||||
from config import normalize_ollama_url, settings
|
from config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -307,7 +307,7 @@ class MultiModalManager:
|
|||||||
import json
|
import json
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
url = normalize_ollama_url(self.ollama_url)
|
url = self.ollama_url.replace("localhost", "127.0.0.1")
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{url}/api/tags",
|
f"{url}/api/tags",
|
||||||
method="GET",
|
method="GET",
|
||||||
@@ -462,7 +462,7 @@ class MultiModalManager:
|
|||||||
|
|
||||||
logger.info("Pulling model: %s", model_name)
|
logger.info("Pulling model: %s", model_name)
|
||||||
|
|
||||||
url = normalize_ollama_url(self.ollama_url)
|
url = self.ollama_url.replace("localhost", "127.0.0.1")
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{url}/api/pull",
|
f"{url}/api/pull",
|
||||||
method="POST",
|
method="POST",
|
||||||
|
|||||||
@@ -388,101 +388,6 @@ class CascadeRouter:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _select_model(
|
|
||||||
self, provider: Provider, model: str | None, content_type: ContentType
|
|
||||||
) -> tuple[str | None, bool]:
|
|
||||||
"""Select the best model for the request, with vision fallback.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (selected_model, is_fallback_model).
|
|
||||||
"""
|
|
||||||
selected_model = model or provider.get_default_model()
|
|
||||||
is_fallback = False
|
|
||||||
|
|
||||||
if content_type != ContentType.TEXT and selected_model:
|
|
||||||
if provider.type == "ollama" and self._mm_manager:
|
|
||||||
from infrastructure.models.multimodal import ModelCapability
|
|
||||||
|
|
||||||
if content_type == ContentType.VISION:
|
|
||||||
supports = self._mm_manager.model_supports(
|
|
||||||
selected_model, ModelCapability.VISION
|
|
||||||
)
|
|
||||||
if not supports:
|
|
||||||
fallback = self._get_fallback_model(provider, selected_model, content_type)
|
|
||||||
if fallback:
|
|
||||||
logger.info(
|
|
||||||
"Model %s doesn't support vision, falling back to %s",
|
|
||||||
selected_model,
|
|
||||||
fallback,
|
|
||||||
)
|
|
||||||
selected_model = fallback
|
|
||||||
is_fallback = True
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"No vision-capable model found on %s, trying anyway",
|
|
||||||
provider.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
return selected_model, is_fallback
|
|
||||||
|
|
||||||
async def _attempt_with_retry(
|
|
||||||
self,
|
|
||||||
provider: Provider,
|
|
||||||
messages: list[dict],
|
|
||||||
model: str | None,
|
|
||||||
temperature: float,
|
|
||||||
max_tokens: int | None,
|
|
||||||
content_type: ContentType,
|
|
||||||
) -> dict:
|
|
||||||
"""Try a provider with retries, returning the result dict.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: If all retry attempts fail.
|
|
||||||
Returns error strings collected during retries via the exception message.
|
|
||||||
"""
|
|
||||||
errors: list[str] = []
|
|
||||||
for attempt in range(self.config.max_retries_per_provider):
|
|
||||||
try:
|
|
||||||
return await self._try_provider(
|
|
||||||
provider=provider,
|
|
||||||
messages=messages,
|
|
||||||
model=model,
|
|
||||||
temperature=temperature,
|
|
||||||
max_tokens=max_tokens,
|
|
||||||
content_type=content_type,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
error_msg = str(exc)
|
|
||||||
logger.warning(
|
|
||||||
"Provider %s attempt %d failed: %s",
|
|
||||||
provider.name,
|
|
||||||
attempt + 1,
|
|
||||||
error_msg,
|
|
||||||
)
|
|
||||||
errors.append(f"{provider.name}: {error_msg}")
|
|
||||||
|
|
||||||
if attempt < self.config.max_retries_per_provider - 1:
|
|
||||||
await asyncio.sleep(self.config.retry_delay_seconds)
|
|
||||||
|
|
||||||
raise RuntimeError("; ".join(errors))
|
|
||||||
|
|
||||||
def _is_provider_available(self, provider: Provider) -> bool:
|
|
||||||
"""Check if a provider should be tried (enabled + circuit breaker)."""
|
|
||||||
if not provider.enabled:
|
|
||||||
logger.debug("Skipping %s (disabled)", provider.name)
|
|
||||||
return False
|
|
||||||
|
|
||||||
if provider.status == ProviderStatus.UNHEALTHY:
|
|
||||||
if self._can_close_circuit(provider):
|
|
||||||
provider.circuit_state = CircuitState.HALF_OPEN
|
|
||||||
provider.half_open_calls = 0
|
|
||||||
logger.info("Circuit breaker half-open for %s", provider.name)
|
|
||||||
else:
|
|
||||||
logger.debug("Skipping %s (circuit open)", provider.name)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def complete(
|
async def complete(
|
||||||
self,
|
self,
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
@@ -509,6 +414,7 @@ class CascadeRouter:
|
|||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If all providers fail
|
RuntimeError: If all providers fail
|
||||||
"""
|
"""
|
||||||
|
# Detect content type for multi-modal routing
|
||||||
content_type = self._detect_content_type(messages)
|
content_type = self._detect_content_type(messages)
|
||||||
if content_type != ContentType.TEXT:
|
if content_type != ContentType.TEXT:
|
||||||
logger.debug("Detected %s content, selecting appropriate model", content_type.value)
|
logger.debug("Detected %s content, selecting appropriate model", content_type.value)
|
||||||
@@ -516,34 +422,93 @@ class CascadeRouter:
|
|||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
for provider in self.providers:
|
for provider in self.providers:
|
||||||
if not self._is_provider_available(provider):
|
# Skip disabled providers
|
||||||
|
if not provider.enabled:
|
||||||
|
logger.debug("Skipping %s (disabled)", provider.name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
selected_model, is_fallback_model = self._select_model(provider, model, content_type)
|
# Skip unhealthy providers (circuit breaker)
|
||||||
|
if provider.status == ProviderStatus.UNHEALTHY:
|
||||||
|
# Check if circuit breaker can close
|
||||||
|
if self._can_close_circuit(provider):
|
||||||
|
provider.circuit_state = CircuitState.HALF_OPEN
|
||||||
|
provider.half_open_calls = 0
|
||||||
|
logger.info("Circuit breaker half-open for %s", provider.name)
|
||||||
|
else:
|
||||||
|
logger.debug("Skipping %s (circuit open)", provider.name)
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
# Determine which model to use
|
||||||
result = await self._attempt_with_retry(
|
selected_model = model or provider.get_default_model()
|
||||||
provider,
|
is_fallback_model = False
|
||||||
messages,
|
|
||||||
selected_model,
|
|
||||||
temperature,
|
|
||||||
max_tokens,
|
|
||||||
content_type,
|
|
||||||
)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
errors.append(str(exc))
|
|
||||||
self._record_failure(provider)
|
|
||||||
continue
|
|
||||||
|
|
||||||
self._record_success(provider, result.get("latency_ms", 0))
|
# For non-text content, check if model supports it
|
||||||
return {
|
if content_type != ContentType.TEXT and selected_model:
|
||||||
"content": result["content"],
|
if provider.type == "ollama" and self._mm_manager:
|
||||||
"provider": provider.name,
|
from infrastructure.models.multimodal import ModelCapability
|
||||||
"model": result.get("model", selected_model or provider.get_default_model()),
|
|
||||||
"latency_ms": result.get("latency_ms", 0),
|
|
||||||
"is_fallback_model": is_fallback_model,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
# Check if selected model supports the required capability
|
||||||
|
if content_type == ContentType.VISION:
|
||||||
|
supports = self._mm_manager.model_supports(
|
||||||
|
selected_model, ModelCapability.VISION
|
||||||
|
)
|
||||||
|
if not supports:
|
||||||
|
# Find fallback model
|
||||||
|
fallback = self._get_fallback_model(
|
||||||
|
provider, selected_model, content_type
|
||||||
|
)
|
||||||
|
if fallback:
|
||||||
|
logger.info(
|
||||||
|
"Model %s doesn't support vision, falling back to %s",
|
||||||
|
selected_model,
|
||||||
|
fallback,
|
||||||
|
)
|
||||||
|
selected_model = fallback
|
||||||
|
is_fallback_model = True
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"No vision-capable model found on %s, trying anyway",
|
||||||
|
provider.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try this provider
|
||||||
|
for attempt in range(self.config.max_retries_per_provider):
|
||||||
|
try:
|
||||||
|
result = await self._try_provider(
|
||||||
|
provider=provider,
|
||||||
|
messages=messages,
|
||||||
|
model=selected_model,
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Success! Update metrics and return
|
||||||
|
self._record_success(provider, result.get("latency_ms", 0))
|
||||||
|
return {
|
||||||
|
"content": result["content"],
|
||||||
|
"provider": provider.name,
|
||||||
|
"model": result.get(
|
||||||
|
"model", selected_model or provider.get_default_model()
|
||||||
|
),
|
||||||
|
"latency_ms": result.get("latency_ms", 0),
|
||||||
|
"is_fallback_model": is_fallback_model,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
error_msg = str(exc)
|
||||||
|
logger.warning(
|
||||||
|
"Provider %s attempt %d failed: %s", provider.name, attempt + 1, error_msg
|
||||||
|
)
|
||||||
|
errors.append(f"{provider.name}: {error_msg}")
|
||||||
|
|
||||||
|
if attempt < self.config.max_retries_per_provider - 1:
|
||||||
|
await asyncio.sleep(self.config.retry_delay_seconds)
|
||||||
|
|
||||||
|
# All retries failed for this provider
|
||||||
|
self._record_failure(provider)
|
||||||
|
|
||||||
|
# All providers failed
|
||||||
raise RuntimeError(f"All providers failed: {'; '.join(errors)}")
|
raise RuntimeError(f"All providers failed: {'; '.join(errors)}")
|
||||||
|
|
||||||
async def _try_provider(
|
async def _try_provider(
|
||||||
@@ -609,7 +574,7 @@ class CascadeRouter:
|
|||||||
"""Call Ollama API with multi-modal support."""
|
"""Call Ollama API with multi-modal support."""
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
url = f"{provider.url or settings.ollama_url}/api/chat"
|
url = f"{provider.url}/api/chat"
|
||||||
|
|
||||||
# Transform messages for Ollama format (including images)
|
# Transform messages for Ollama format (including images)
|
||||||
transformed_messages = self._transform_messages_for_ollama(messages)
|
transformed_messages = self._transform_messages_for_ollama(messages)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ def _pull_model(model_name: str) -> bool:
|
|||||||
|
|
||||||
logger.info("Pulling model: %s", model_name)
|
logger.info("Pulling model: %s", model_name)
|
||||||
|
|
||||||
url = settings.normalized_ollama_url
|
url = settings.ollama_url.replace("localhost", "127.0.0.1")
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{url}/api/pull",
|
f"{url}/api/pull",
|
||||||
method="POST",
|
method="POST",
|
||||||
|
|||||||
@@ -95,126 +95,6 @@ def _parse_steps(plan_text: str) -> list[str]:
|
|||||||
return [line.strip() for line in plan_text.strip().splitlines() if line.strip()]
|
return [line.strip() for line in plan_text.strip().splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Extracted helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_content(run_result) -> str:
|
|
||||||
"""Extract text content from an agent run result."""
|
|
||||||
return run_result.content if hasattr(run_result, "content") else str(run_result)
|
|
||||||
|
|
||||||
|
|
||||||
def _clean(text: str) -> str:
|
|
||||||
"""Clean a model response using session's response cleaner."""
|
|
||||||
from timmy.session import _clean_response
|
|
||||||
|
|
||||||
return _clean_response(text)
|
|
||||||
|
|
||||||
|
|
||||||
async def _plan_task(
|
|
||||||
agent, task: str, session_id: str, max_steps: int
|
|
||||||
) -> tuple[list[str], bool] | str:
|
|
||||||
"""Run the planning phase — returns (steps, was_truncated) or error string."""
|
|
||||||
plan_prompt = (
|
|
||||||
f"Break this task into numbered steps (max {max_steps}). "
|
|
||||||
f"Return ONLY a numbered list, nothing else.\n\n"
|
|
||||||
f"Task: {task}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
plan_run = await asyncio.to_thread(
|
|
||||||
agent.run, plan_prompt, stream=False, session_id=f"{session_id}_plan"
|
|
||||||
)
|
|
||||||
plan_text = _extract_content(plan_run)
|
|
||||||
except Exception as exc: # broad catch intentional: agent.run can raise any error
|
|
||||||
logger.error("Agentic loop: planning failed: %s", exc)
|
|
||||||
return f"Planning failed: {exc}"
|
|
||||||
|
|
||||||
steps = _parse_steps(plan_text)
|
|
||||||
if not steps:
|
|
||||||
return "Planning produced no steps."
|
|
||||||
|
|
||||||
planned_count = len(steps)
|
|
||||||
steps = steps[:max_steps]
|
|
||||||
return steps, planned_count > len(steps)
|
|
||||||
|
|
||||||
|
|
||||||
async def _execute_step(
|
|
||||||
agent,
|
|
||||||
task: str,
|
|
||||||
step_desc: str,
|
|
||||||
step_num: int,
|
|
||||||
total_steps: int,
|
|
||||||
recent_results: list[str],
|
|
||||||
session_id: str,
|
|
||||||
) -> AgenticStep:
|
|
||||||
"""Execute a single step, returning an AgenticStep."""
|
|
||||||
step_start = time.monotonic()
|
|
||||||
context = (
|
|
||||||
f"Task: {task}\n"
|
|
||||||
f"Step {step_num}/{total_steps}: {step_desc}\n"
|
|
||||||
f"Recent progress: {recent_results[-2:] if recent_results else []}\n\n"
|
|
||||||
f"Execute this step and report what you did."
|
|
||||||
)
|
|
||||||
step_run = await asyncio.to_thread(
|
|
||||||
agent.run, context, stream=False, session_id=f"{session_id}_step{step_num}"
|
|
||||||
)
|
|
||||||
step_result = _clean(_extract_content(step_run))
|
|
||||||
return AgenticStep(
|
|
||||||
step_num=step_num,
|
|
||||||
description=step_desc,
|
|
||||||
result=step_result,
|
|
||||||
status="completed",
|
|
||||||
duration_ms=int((time.monotonic() - step_start) * 1000),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _adapt_step(
|
|
||||||
agent,
|
|
||||||
step_desc: str,
|
|
||||||
step_num: int,
|
|
||||||
error: Exception,
|
|
||||||
step_start: float,
|
|
||||||
session_id: str,
|
|
||||||
) -> AgenticStep:
|
|
||||||
"""Attempt adaptation after a step failure."""
|
|
||||||
adapt_prompt = (
|
|
||||||
f"Step {step_num} failed with error: {error}\n"
|
|
||||||
f"Original step was: {step_desc}\n"
|
|
||||||
f"Adapt the plan and try an alternative approach for this step."
|
|
||||||
)
|
|
||||||
adapt_run = await asyncio.to_thread(
|
|
||||||
agent.run, adapt_prompt, stream=False, session_id=f"{session_id}_adapt{step_num}"
|
|
||||||
)
|
|
||||||
adapt_result = _clean(_extract_content(adapt_run))
|
|
||||||
return AgenticStep(
|
|
||||||
step_num=step_num,
|
|
||||||
description=f"[Adapted] {step_desc}",
|
|
||||||
result=adapt_result,
|
|
||||||
status="adapted",
|
|
||||||
duration_ms=int((time.monotonic() - step_start) * 1000),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _summarize(result: AgenticResult, total_steps: int, was_truncated: bool) -> None:
|
|
||||||
"""Fill in summary and final status on the result object (mutates in place)."""
|
|
||||||
completed = sum(1 for s in result.steps if s.status == "completed")
|
|
||||||
adapted = sum(1 for s in result.steps if s.status == "adapted")
|
|
||||||
failed = sum(1 for s in result.steps if s.status == "failed")
|
|
||||||
|
|
||||||
parts = [f"Completed {completed}/{total_steps} steps"]
|
|
||||||
if adapted:
|
|
||||||
parts.append(f"{adapted} adapted")
|
|
||||||
if failed:
|
|
||||||
parts.append(f"{failed} failed")
|
|
||||||
result.summary = f"{result.task}: {', '.join(parts)}."
|
|
||||||
|
|
||||||
if was_truncated or len(result.steps) < total_steps or failed:
|
|
||||||
result.status = "partial"
|
|
||||||
else:
|
|
||||||
result.status = "completed"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Core loop
|
# Core loop
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -245,41 +125,88 @@ async def run_agentic_loop(
|
|||||||
|
|
||||||
task_id = str(uuid.uuid4())[:8]
|
task_id = str(uuid.uuid4())[:8]
|
||||||
start_time = time.monotonic()
|
start_time = time.monotonic()
|
||||||
|
|
||||||
agent = _get_loop_agent()
|
agent = _get_loop_agent()
|
||||||
result = AgenticResult(task_id=task_id, task=task, summary="")
|
result = AgenticResult(task_id=task_id, task=task, summary="")
|
||||||
|
|
||||||
# Phase 1: Planning
|
# ── Phase 1: Planning ──────────────────────────────────────────────────
|
||||||
plan = await _plan_task(agent, task, session_id, max_steps)
|
plan_prompt = (
|
||||||
if isinstance(plan, str):
|
f"Break this task into numbered steps (max {max_steps}). "
|
||||||
|
f"Return ONLY a numbered list, nothing else.\n\n"
|
||||||
|
f"Task: {task}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
plan_run = await asyncio.to_thread(
|
||||||
|
agent.run, plan_prompt, stream=False, session_id=f"{session_id}_plan"
|
||||||
|
)
|
||||||
|
plan_text = plan_run.content if hasattr(plan_run, "content") else str(plan_run)
|
||||||
|
except Exception as exc: # broad catch intentional: agent.run can raise any error
|
||||||
|
logger.error("Agentic loop: planning failed: %s", exc)
|
||||||
result.status = "failed"
|
result.status = "failed"
|
||||||
result.summary = plan
|
result.summary = f"Planning failed: {exc}"
|
||||||
result.total_duration_ms = int((time.monotonic() - start_time) * 1000)
|
result.total_duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
steps, was_truncated = plan
|
steps = _parse_steps(plan_text)
|
||||||
total_steps = len(steps)
|
if not steps:
|
||||||
|
result.status = "failed"
|
||||||
|
result.summary = "Planning produced no steps."
|
||||||
|
result.total_duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Enforce max_steps — track if we truncated
|
||||||
|
planned_steps = len(steps)
|
||||||
|
steps = steps[:max_steps]
|
||||||
|
total_steps = len(steps)
|
||||||
|
was_truncated = planned_steps > total_steps
|
||||||
|
|
||||||
|
# Broadcast plan
|
||||||
await _broadcast_progress(
|
await _broadcast_progress(
|
||||||
"agentic.plan_ready",
|
"agentic.plan_ready",
|
||||||
{"task_id": task_id, "task": task, "steps": steps, "total": total_steps},
|
{
|
||||||
|
"task_id": task_id,
|
||||||
|
"task": task,
|
||||||
|
"steps": steps,
|
||||||
|
"total": total_steps,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phase 2: Execution
|
# ── Phase 2: Execution ─────────────────────────────────────────────────
|
||||||
completed_results: list[str] = []
|
completed_results: list[str] = []
|
||||||
|
|
||||||
for i, step_desc in enumerate(steps, 1):
|
for i, step_desc in enumerate(steps, 1):
|
||||||
step_start = time.monotonic()
|
step_start = time.monotonic()
|
||||||
|
|
||||||
|
recent = completed_results[-2:] if completed_results else []
|
||||||
|
context = (
|
||||||
|
f"Task: {task}\n"
|
||||||
|
f"Step {i}/{total_steps}: {step_desc}\n"
|
||||||
|
f"Recent progress: {recent}\n\n"
|
||||||
|
f"Execute this step and report what you did."
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
step = await _execute_step(
|
step_run = await asyncio.to_thread(
|
||||||
agent,
|
agent.run, context, stream=False, session_id=f"{session_id}_step{i}"
|
||||||
task,
|
)
|
||||||
step_desc,
|
step_result = step_run.content if hasattr(step_run, "content") else str(step_run)
|
||||||
i,
|
|
||||||
total_steps,
|
# Clean the response
|
||||||
completed_results,
|
from timmy.session import _clean_response
|
||||||
session_id,
|
|
||||||
|
step_result = _clean_response(step_result)
|
||||||
|
|
||||||
|
step = AgenticStep(
|
||||||
|
step_num=i,
|
||||||
|
description=step_desc,
|
||||||
|
result=step_result,
|
||||||
|
status="completed",
|
||||||
|
duration_ms=int((time.monotonic() - step_start) * 1000),
|
||||||
)
|
)
|
||||||
result.steps.append(step)
|
result.steps.append(step)
|
||||||
completed_results.append(f"Step {i}: {step.result[:200]}")
|
completed_results.append(f"Step {i}: {step_result[:200]}")
|
||||||
|
|
||||||
|
# Broadcast progress
|
||||||
await _broadcast_progress(
|
await _broadcast_progress(
|
||||||
"agentic.step_complete",
|
"agentic.step_complete",
|
||||||
{
|
{
|
||||||
@@ -287,18 +214,46 @@ async def run_agentic_loop(
|
|||||||
"step": i,
|
"step": i,
|
||||||
"total": total_steps,
|
"total": total_steps,
|
||||||
"description": step_desc,
|
"description": step_desc,
|
||||||
"result": step.result[:200],
|
"result": step_result[:200],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if on_progress:
|
if on_progress:
|
||||||
await on_progress(step_desc, i, total_steps)
|
await on_progress(step_desc, i, total_steps)
|
||||||
|
|
||||||
except Exception as exc: # broad catch intentional: agent.run can raise any error
|
except Exception as exc: # broad catch intentional: agent.run can raise any error
|
||||||
logger.warning("Agentic loop step %d failed: %s", i, exc)
|
logger.warning("Agentic loop step %d failed: %s", i, exc)
|
||||||
|
|
||||||
|
# ── Adaptation: ask model to adapt ─────────────────────────────
|
||||||
|
adapt_prompt = (
|
||||||
|
f"Step {i} failed with error: {exc}\n"
|
||||||
|
f"Original step was: {step_desc}\n"
|
||||||
|
f"Adapt the plan and try an alternative approach for this step."
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
step = await _adapt_step(agent, step_desc, i, exc, step_start, session_id)
|
adapt_run = await asyncio.to_thread(
|
||||||
|
agent.run,
|
||||||
|
adapt_prompt,
|
||||||
|
stream=False,
|
||||||
|
session_id=f"{session_id}_adapt{i}",
|
||||||
|
)
|
||||||
|
adapt_result = (
|
||||||
|
adapt_run.content if hasattr(adapt_run, "content") else str(adapt_run)
|
||||||
|
)
|
||||||
|
from timmy.session import _clean_response
|
||||||
|
|
||||||
|
adapt_result = _clean_response(adapt_result)
|
||||||
|
|
||||||
|
step = AgenticStep(
|
||||||
|
step_num=i,
|
||||||
|
description=f"[Adapted] {step_desc}",
|
||||||
|
result=adapt_result,
|
||||||
|
status="adapted",
|
||||||
|
duration_ms=int((time.monotonic() - step_start) * 1000),
|
||||||
|
)
|
||||||
result.steps.append(step)
|
result.steps.append(step)
|
||||||
completed_results.append(f"Step {i} (adapted): {step.result[:200]}")
|
completed_results.append(f"Step {i} (adapted): {adapt_result[:200]}")
|
||||||
|
|
||||||
await _broadcast_progress(
|
await _broadcast_progress(
|
||||||
"agentic.step_adapted",
|
"agentic.step_adapted",
|
||||||
{
|
{
|
||||||
@@ -307,26 +262,46 @@ async def run_agentic_loop(
|
|||||||
"total": total_steps,
|
"total": total_steps,
|
||||||
"description": step_desc,
|
"description": step_desc,
|
||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
"adaptation": step.result[:200],
|
"adaptation": adapt_result[:200],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if on_progress:
|
if on_progress:
|
||||||
await on_progress(f"[Adapted] {step_desc}", i, total_steps)
|
await on_progress(f"[Adapted] {step_desc}", i, total_steps)
|
||||||
except Exception as adapt_exc: # broad catch intentional
|
|
||||||
|
except Exception as adapt_exc: # broad catch intentional: agent.run can raise any error
|
||||||
logger.error("Agentic loop adaptation also failed: %s", adapt_exc)
|
logger.error("Agentic loop adaptation also failed: %s", adapt_exc)
|
||||||
result.steps.append(
|
step = AgenticStep(
|
||||||
AgenticStep(
|
step_num=i,
|
||||||
step_num=i,
|
description=step_desc,
|
||||||
description=step_desc,
|
result=f"Failed: {exc}; Adaptation also failed: {adapt_exc}",
|
||||||
result=f"Failed: {exc}; Adaptation also failed: {adapt_exc}",
|
status="failed",
|
||||||
status="failed",
|
duration_ms=int((time.monotonic() - step_start) * 1000),
|
||||||
duration_ms=int((time.monotonic() - step_start) * 1000),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
result.steps.append(step)
|
||||||
completed_results.append(f"Step {i}: FAILED")
|
completed_results.append(f"Step {i}: FAILED")
|
||||||
|
|
||||||
# Phase 3: Summary
|
# ── Phase 3: Summary ───────────────────────────────────────────────────
|
||||||
_summarize(result, total_steps, was_truncated)
|
completed_count = sum(1 for s in result.steps if s.status == "completed")
|
||||||
|
adapted_count = sum(1 for s in result.steps if s.status == "adapted")
|
||||||
|
failed_count = sum(1 for s in result.steps if s.status == "failed")
|
||||||
|
parts = [f"Completed {completed_count}/{total_steps} steps"]
|
||||||
|
if adapted_count:
|
||||||
|
parts.append(f"{adapted_count} adapted")
|
||||||
|
if failed_count:
|
||||||
|
parts.append(f"{failed_count} failed")
|
||||||
|
result.summary = f"{task}: {', '.join(parts)}."
|
||||||
|
|
||||||
|
# Determine final status
|
||||||
|
if was_truncated:
|
||||||
|
result.status = "partial"
|
||||||
|
elif len(result.steps) < total_steps:
|
||||||
|
result.status = "partial"
|
||||||
|
elif any(s.status == "failed" for s in result.steps):
|
||||||
|
result.status = "partial"
|
||||||
|
else:
|
||||||
|
result.status = "completed"
|
||||||
|
|
||||||
result.total_duration_ms = int((time.monotonic() - start_time) * 1000)
|
result.total_duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||||
|
|
||||||
await _broadcast_progress(
|
await _broadcast_progress(
|
||||||
|
|||||||
@@ -636,7 +636,7 @@ class HotMemory:
|
|||||||
if len(lines) > 1:
|
if len(lines) > 1:
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("DB context read failed, falling back to file")
|
pass
|
||||||
|
|
||||||
# Fallback to file if DB unavailable
|
# Fallback to file if DB unavailable
|
||||||
if self.path.exists():
|
if self.path.exists():
|
||||||
|
|||||||
@@ -257,28 +257,6 @@ 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)
|
||||||
@@ -306,11 +284,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, seed_type
|
return None
|
||||||
|
|
||||||
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, seed_type
|
return None
|
||||||
|
|
||||||
content = raw.strip()
|
content = raw.strip()
|
||||||
|
|
||||||
@@ -330,28 +308,48 @@ 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, seed_type
|
return None
|
||||||
|
|
||||||
return content, seed_type
|
if not content:
|
||||||
|
return None
|
||||||
|
|
||||||
async def _finalize_thought(self, thought: Thought) -> None:
|
thought = self._store_thought(content, seed_type)
|
||||||
"""Run post-hooks, log, journal, and broadcast a stored thought."""
|
self._last_thought_id = thought.id
|
||||||
|
|
||||||
|
# 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],
|
||||||
thought.seed_type,
|
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."""
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
"""Tests for scripts/cycle_retro.py issue auto-detection."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
# Import the module under test — it's a script so we import the helpers directly
|
|
||||||
import importlib
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
SCRIPTS_DIR = Path(__file__).resolve().parent.parent.parent / "scripts"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _add_scripts_to_path(monkeypatch):
|
|
||||||
monkeypatch.syspath_prepend(str(SCRIPTS_DIR))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def mod():
|
|
||||||
"""Import cycle_retro as a module."""
|
|
||||||
return importlib.import_module("cycle_retro")
|
|
||||||
|
|
||||||
|
|
||||||
class TestDetectIssueFromBranch:
|
|
||||||
def test_kimi_issue_branch(self, mod):
|
|
||||||
with patch.object(subprocess, "check_output", return_value="kimi/issue-492\n"):
|
|
||||||
assert mod.detect_issue_from_branch() == 492
|
|
||||||
|
|
||||||
def test_plain_issue_branch(self, mod):
|
|
||||||
with patch.object(subprocess, "check_output", return_value="issue-123\n"):
|
|
||||||
assert mod.detect_issue_from_branch() == 123
|
|
||||||
|
|
||||||
def test_issue_slash_number(self, mod):
|
|
||||||
with patch.object(subprocess, "check_output", return_value="fix/issue/55\n"):
|
|
||||||
assert mod.detect_issue_from_branch() == 55
|
|
||||||
|
|
||||||
def test_no_issue_in_branch(self, mod):
|
|
||||||
with patch.object(subprocess, "check_output", return_value="main\n"):
|
|
||||||
assert mod.detect_issue_from_branch() is None
|
|
||||||
|
|
||||||
def test_feature_branch(self, mod):
|
|
||||||
with patch.object(subprocess, "check_output", return_value="feature/add-widget\n"):
|
|
||||||
assert mod.detect_issue_from_branch() is None
|
|
||||||
|
|
||||||
def test_git_not_available(self, mod):
|
|
||||||
with patch.object(subprocess, "check_output", side_effect=FileNotFoundError):
|
|
||||||
assert mod.detect_issue_from_branch() is None
|
|
||||||
|
|
||||||
def test_git_fails(self, mod):
|
|
||||||
with patch.object(
|
|
||||||
subprocess,
|
|
||||||
"check_output",
|
|
||||||
side_effect=subprocess.CalledProcessError(1, "git"),
|
|
||||||
):
|
|
||||||
assert mod.detect_issue_from_branch() is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestBackfillExtractIssueNumber:
|
|
||||||
"""Tests for backfill_retro.extract_issue_number PR-number filtering."""
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def backfill(self):
|
|
||||||
return importlib.import_module("backfill_retro")
|
|
||||||
|
|
||||||
def test_body_has_issue(self, backfill):
|
|
||||||
assert backfill.extract_issue_number("fix: foo (#491)", "Fixes #490", pr_number=491) == 490
|
|
||||||
|
|
||||||
def test_title_skips_pr_number(self, backfill):
|
|
||||||
assert backfill.extract_issue_number("fix: foo (#491)", "", pr_number=491) is None
|
|
||||||
|
|
||||||
def test_title_with_issue_and_pr(self, backfill):
|
|
||||||
# [loop-cycle-538] refactor: ... (#459) (#481)
|
|
||||||
assert (
|
|
||||||
backfill.extract_issue_number(
|
|
||||||
"[loop-cycle-538] refactor: remove dead airllm (#459) (#481)",
|
|
||||||
"",
|
|
||||||
pr_number=481,
|
|
||||||
)
|
|
||||||
== 459
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_no_pr_number_provided(self, backfill):
|
|
||||||
assert backfill.extract_issue_number("fix: foo (#491)", "") == 491
|
|
||||||
@@ -250,99 +250,6 @@ 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