Compare commits

...

6 Commits

Author SHA1 Message Date
kimi
bf57da87b3 refactor: break up delegate_to_kimi into helpers
Extract _find_kimi_cli, _resolve_workdir, and _run_kimi from the 68-line
delegate_to_kimi function, reducing it to ~15 lines.

Fixes #635

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 16:54:41 -04:00
a3f61c67d3 refactor: break up post_morning_ritual into helpers (#631)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 16:43:14 -04:00
32dbdc68c8 refactor: break up should_use_tools into helpers (#624)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 16:31:34 -04:00
84302aedac fix: pass max_tokens to Ollama provider in cascade router (#622)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 16:27:24 -04:00
2c217104db feat: real-time Spark visualization in Mission Control (#615)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 16:22:15 -04:00
7452e8a4f0 fix: add missing tests for Tower route /tower (#621)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 16:22:13 -04:00
6 changed files with 407 additions and 110 deletions

View File

@@ -38,6 +38,56 @@ def get_later_tasks(db: Session) -> list[Task]:
) )
def _create_mit_tasks(db: Session, titles: list[str | None]) -> list[int]:
"""Create MIT tasks from a list of titles, return their IDs."""
task_ids: list[int] = []
for title in titles:
if title:
task = Task(
title=title,
is_mit=True,
state=TaskState.LATER,
certainty=TaskCertainty.SOFT,
)
db.add(task)
db.commit()
db.refresh(task)
task_ids.append(task.id)
return task_ids
def _create_other_tasks(db: Session, other_tasks: str):
"""Create non-MIT tasks from newline-separated text."""
for line in other_tasks.split("\n"):
line = line.strip()
if line:
task = Task(
title=line,
state=TaskState.LATER,
certainty=TaskCertainty.FUZZY,
)
db.add(task)
def _seed_now_next(db: Session):
"""Set initial NOW/NEXT states when both slots are empty."""
if get_now_task(db) or get_next_task(db):
return
later_tasks = (
db.query(Task)
.filter(Task.state == TaskState.LATER)
.order_by(Task.is_mit.desc(), Task.sort_order)
.all()
)
if later_tasks:
later_tasks[0].state = TaskState.NOW
db.add(later_tasks[0])
db.flush()
if len(later_tasks) > 1:
later_tasks[1].state = TaskState.NEXT
db.add(later_tasks[1])
def promote_tasks(db: Session): def promote_tasks(db: Session):
"""Enforce the NOW/NEXT/LATER state machine invariants. """Enforce the NOW/NEXT/LATER state machine invariants.
@@ -114,63 +164,19 @@ async def post_morning_ritual(
other_tasks: str = Form(""), other_tasks: str = Form(""),
): ):
"""Process morning ritual: create MITs, other tasks, and set initial states.""" """Process morning ritual: create MITs, other tasks, and set initial states."""
# Create Journal Entry
mit_task_ids = []
journal_entry = JournalEntry(entry_date=date.today()) journal_entry = JournalEntry(entry_date=date.today())
db.add(journal_entry) db.add(journal_entry)
db.commit() db.commit()
db.refresh(journal_entry) db.refresh(journal_entry)
# Create MIT tasks journal_entry.mit_task_ids = _create_mit_tasks(db, [mit1_title, mit2_title, mit3_title])
for mit_title in [mit1_title, mit2_title, mit3_title]:
if mit_title:
task = Task(
title=mit_title,
is_mit=True,
state=TaskState.LATER, # Initially LATER, will be promoted
certainty=TaskCertainty.SOFT,
)
db.add(task)
db.commit()
db.refresh(task)
mit_task_ids.append(task.id)
journal_entry.mit_task_ids = mit_task_ids
db.add(journal_entry) db.add(journal_entry)
# Create other tasks _create_other_tasks(db, other_tasks)
for task_title in other_tasks.split("\n"):
task_title = task_title.strip()
if task_title:
task = Task(
title=task_title,
state=TaskState.LATER,
certainty=TaskCertainty.FUZZY,
)
db.add(task)
db.commit() db.commit()
# Set initial NOW/NEXT states _seed_now_next(db)
# Set initial NOW/NEXT states after all tasks are created db.commit()
if not get_now_task(db) and not get_next_task(db):
later_tasks = (
db.query(Task)
.filter(Task.state == TaskState.LATER)
.order_by(Task.is_mit.desc(), Task.sort_order)
.all()
)
if later_tasks:
# Set the highest priority LATER task to NOW
later_tasks[0].state = TaskState.NOW
db.add(later_tasks[0])
db.flush() # Flush to make the change visible for the next query
# Set the next highest priority LATER task to NEXT
if len(later_tasks) > 1:
later_tasks[1].state = TaskState.NEXT
db.add(later_tasks[1])
db.commit() # Commit changes after initial NOW/NEXT setup
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,

View File

@@ -138,6 +138,47 @@
</div> </div>
</div> </div>
<!-- Spark Intelligence -->
{% from "macros.html" import panel %}
<div class="mc-card-spaced">
<div class="card">
<div class="card-header">
<h2 class="card-title">Spark Intelligence</h2>
<div>
<span class="badge" id="spark-status-badge">Loading...</span>
</div>
</div>
<div class="grid grid-3">
<div class="stat">
<div class="stat-value" id="spark-events">-</div>
<div class="stat-label">Events</div>
</div>
<div class="stat">
<div class="stat-value" id="spark-memories">-</div>
<div class="stat-label">Memories</div>
</div>
<div class="stat">
<div class="stat-value" id="spark-predictions">-</div>
<div class="stat-label">Predictions</div>
</div>
</div>
</div>
<div class="grid grid-2 mc-section-gap">
{% call panel("SPARK TIMELINE", id="spark-timeline-panel",
hx_get="/spark/timeline",
hx_trigger="load, every 10s") %}
<div class="spark-timeline-scroll">
<p class="chat-history-placeholder">Loading timeline...</p>
</div>
{% endcall %}
{% call panel("SPARK INSIGHTS", id="spark-insights-panel",
hx_get="/spark/insights",
hx_trigger="load, every 30s") %}
<p class="chat-history-placeholder">Loading insights...</p>
{% endcall %}
</div>
</div>
<!-- Chat History --> <!-- Chat History -->
<div class="card mc-card-spaced"> <div class="card mc-card-spaced">
<div class="card-header"> <div class="card-header">
@@ -428,7 +469,34 @@ async function loadGrokStats() {
} }
} }
// Load Spark status
async function loadSparkStatus() {
try {
var response = await fetch('/spark');
var data = await response.json();
var st = data.status || {};
document.getElementById('spark-events').textContent = st.total_events || 0;
document.getElementById('spark-memories').textContent = st.total_memories || 0;
document.getElementById('spark-predictions').textContent = st.total_predictions || 0;
var badge = document.getElementById('spark-status-badge');
if (st.total_events > 0) {
badge.textContent = 'Active';
badge.className = 'badge badge-success';
} else {
badge.textContent = 'Idle';
badge.className = 'badge badge-warning';
}
} catch (error) {
var badge = document.getElementById('spark-status-badge');
badge.textContent = 'Offline';
badge.className = 'badge badge-danger';
}
}
// Initial load // Initial load
loadSparkStatus();
loadSovereignty(); loadSovereignty();
loadHealth(); loadHealth();
loadSwarmStats(); loadSwarmStats();
@@ -442,5 +510,6 @@ setInterval(loadHealth, 10000);
setInterval(loadSwarmStats, 5000); setInterval(loadSwarmStats, 5000);
setInterval(updateHeartbeat, 5000); setInterval(updateHeartbeat, 5000);
setInterval(loadGrokStats, 10000); setInterval(loadGrokStats, 10000);
setInterval(loadSparkStatus, 15000);
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -564,6 +564,7 @@ class CascadeRouter:
messages=messages, messages=messages,
model=model or provider.get_default_model(), model=model or provider.get_default_model(),
temperature=temperature, temperature=temperature,
max_tokens=max_tokens,
content_type=content_type, content_type=content_type,
) )
elif provider.type == "openai": elif provider.type == "openai":
@@ -604,6 +605,7 @@ class CascadeRouter:
messages: list[dict], messages: list[dict],
model: str, model: str,
temperature: float, temperature: float,
max_tokens: int | None = None,
content_type: ContentType = ContentType.TEXT, content_type: ContentType = ContentType.TEXT,
) -> dict: ) -> dict:
"""Call Ollama API with multi-modal support.""" """Call Ollama API with multi-modal support."""
@@ -614,13 +616,15 @@ class CascadeRouter:
# 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)
options = {"temperature": temperature}
if max_tokens:
options["num_predict"] = max_tokens
payload = { payload = {
"model": model, "model": model,
"messages": transformed_messages, "messages": transformed_messages,
"stream": False, "stream": False,
"options": { "options": options,
"temperature": temperature,
},
} }
timeout = aiohttp.ClientTimeout(total=self.config.timeout_seconds) timeout = aiohttp.ClientTimeout(total=self.config.timeout_seconds)

View File

@@ -174,15 +174,8 @@ class ConversationManager:
return None return None
def should_use_tools(self, message: str, context: ConversationContext) -> bool: _TOOL_KEYWORDS = frozenset(
"""Determine if this message likely requires tools. {
Returns True if tools are likely needed, False for simple chat.
"""
message_lower = message.lower().strip()
# Tool keywords that suggest tool usage is needed
tool_keywords = [
"search", "search",
"look up", "look up",
"find", "find",
@@ -203,10 +196,11 @@ class ConversationManager:
"shell", "shell",
"command", "command",
"install", "install",
] }
)
# Chat-only keywords that definitely don't need tools _CHAT_ONLY_KEYWORDS = frozenset(
chat_only = [ {
"hello", "hello",
"hi ", "hi ",
"hey", "hey",
@@ -221,30 +215,47 @@ class ConversationManager:
"goodbye", "goodbye",
"tell me about yourself", "tell me about yourself",
"what can you do", "what can you do",
] }
)
# Check for chat-only patterns first _SIMPLE_QUESTION_PREFIXES = ("what is", "who is", "how does", "why is", "when did", "where is")
for pattern in chat_only: _TIME_WORDS = ("today", "now", "current", "latest", "this week", "this month")
if pattern in message_lower:
return False
# Check for tool keywords def _is_chat_only(self, message_lower: str) -> bool:
for keyword in tool_keywords: """Return True if the message matches a chat-only pattern."""
if keyword in message_lower: return any(kw in message_lower for kw in self._CHAT_ONLY_KEYWORDS)
return True
# Simple questions (starting with what, who, how, why, when, where) def _has_tool_keyword(self, message_lower: str) -> bool:
# usually don't need tools unless about current/real-time info """Return True if the message contains a tool-related keyword."""
simple_question_words = ["what is", "who is", "how does", "why is", "when did", "where is"] return any(kw in message_lower for kw in self._TOOL_KEYWORDS)
for word in simple_question_words:
if message_lower.startswith(word): def _is_simple_question(self, message_lower: str) -> bool | None:
# Check if it's asking about current/real-time info """Check if message is a simple question.
time_words = ["today", "now", "current", "latest", "this week", "this month"]
if any(t in message_lower for t in time_words): Returns True if it needs tools (real-time info), False if it
return True doesn't, or None if the message isn't a simple question.
return False """
for prefix in self._SIMPLE_QUESTION_PREFIXES:
if message_lower.startswith(prefix):
return any(t in message_lower for t in self._TIME_WORDS)
return None
def should_use_tools(self, message: str, context: ConversationContext) -> bool:
"""Determine if this message likely requires tools.
Returns True if tools are likely needed, False for simple chat.
"""
message_lower = message.lower().strip()
if self._is_chat_only(message_lower):
return False
if self._has_tool_keyword(message_lower):
return True
simple = self._is_simple_question(message_lower)
if simple is not None:
return simple
# Default: don't use tools for unclear cases
return False return False

View File

@@ -89,52 +89,41 @@ def list_swarm_agents() -> dict[str, Any]:
} }
def delegate_to_kimi(task: str, working_directory: str = "") -> dict[str, Any]: def _find_kimi_cli() -> str | None:
"""Delegate a coding task to Kimi, the external coding agent. """Return the path to the kimi CLI, or None if not installed."""
Kimi has 262K context and is optimized for code tasks: writing,
debugging, refactoring, test writing. Timmy thinks and plans,
Kimi executes bulk code changes.
Args:
task: Clear, specific coding task description. Include file paths
and expected behavior. Good: "Fix the bug in src/timmy/session.py
where sessions don't persist." Bad: "Fix all bugs."
working_directory: Directory for Kimi to work in. Defaults to repo root.
Returns:
Dict with success status and Kimi's output or error.
"""
import shutil import shutil
import subprocess
return shutil.which("kimi")
def _resolve_workdir(working_directory: str) -> str | dict[str, Any]:
"""Resolve and validate the working directory.
Returns the resolved path string, or an error dict if invalid.
"""
from pathlib import Path from pathlib import Path
from config import settings from config import settings
kimi_path = shutil.which("kimi")
if not kimi_path:
return {
"success": False,
"error": "kimi CLI not found on PATH. Install with: pip install kimi-cli",
}
workdir = working_directory or settings.repo_root workdir = working_directory or settings.repo_root
if not Path(workdir).is_dir(): if not Path(workdir).is_dir():
return { return {
"success": False, "success": False,
"error": f"Working directory does not exist: {workdir}", "error": f"Working directory does not exist: {workdir}",
} }
return workdir
cmd = [kimi_path, "--print", "-p", task]
logger.info("Delegating to Kimi: %s (cwd=%s)", task[:80], workdir) def _run_kimi(cmd: list[str], workdir: str) -> dict[str, Any]:
"""Execute the kimi subprocess and return a result dict."""
import subprocess
try: try:
result = subprocess.run( result = subprocess.run(
cmd, cmd,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=300, # 5 minute timeout for coding tasks timeout=300,
cwd=workdir, cwd=workdir,
) )
@@ -157,3 +146,34 @@ def delegate_to_kimi(task: str, working_directory: str = "") -> dict[str, Any]:
"success": False, "success": False,
"error": f"Failed to run Kimi: {exc}", "error": f"Failed to run Kimi: {exc}",
} }
def delegate_to_kimi(task: str, working_directory: str = "") -> dict[str, Any]:
"""Delegate a coding task to Kimi, the external coding agent.
Kimi has 262K context and is optimized for code tasks: writing,
debugging, refactoring, test writing. Timmy thinks and plans,
Kimi executes bulk code changes.
Args:
task: Clear, specific coding task description. Include file paths
and expected behavior. Good: "Fix the bug in src/timmy/session.py
where sessions don't persist." Bad: "Fix all bugs."
working_directory: Directory for Kimi to work in. Defaults to repo root.
Returns:
Dict with success status and Kimi's output or error.
"""
kimi_path = _find_kimi_cli()
if not kimi_path:
return {
"success": False,
"error": "kimi CLI not found on PATH. Install with: pip install kimi-cli",
}
workdir = _resolve_workdir(working_directory)
if isinstance(workdir, dict):
return workdir
logger.info("Delegating to Kimi: %s (cwd=%s)", task[:80], workdir)
return _run_kimi([kimi_path, "--print", "-p", task], workdir)

View File

@@ -0,0 +1,187 @@
"""Tests for Tower dashboard route (/tower)."""
from unittest.mock import MagicMock, patch
def _mock_spark_engine():
"""Return a mock spark_engine with realistic return values."""
engine = MagicMock()
engine.status.return_value = {
"enabled": True,
"events_captured": 5,
"memories_stored": 3,
"predictions": {"total": 2, "avg_accuracy": 0.85},
"event_types": {
"task_posted": 2,
"bid_submitted": 1,
"task_assigned": 1,
"task_completed": 1,
"task_failed": 0,
"agent_joined": 0,
"tool_executed": 0,
"creative_step": 0,
},
}
event = MagicMock()
event.event_type = "task_completed"
event.description = "Task finished"
event.importance = 0.8
event.created_at = "2026-01-01T00:00:00"
event.agent_id = "agent-1234-abcd"
event.task_id = "task-5678-efgh"
event.data = '{"result": "ok"}'
engine.get_timeline.return_value = [event]
pred = MagicMock()
pred.task_id = "task-5678-efgh"
pred.accuracy = 0.9
pred.evaluated_at = "2026-01-01T01:00:00"
pred.created_at = "2026-01-01T00:30:00"
pred.predicted_value = '{"outcome": "success"}'
engine.get_predictions.return_value = [pred]
advisory = MagicMock()
advisory.category = "performance"
advisory.priority = "high"
advisory.title = "Slow tasks"
advisory.detail = "Tasks taking longer than expected"
advisory.suggested_action = "Scale up workers"
engine.get_advisories.return_value = [advisory]
return engine
class TestTowerUI:
"""Tests for GET /tower endpoint."""
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
def test_tower_returns_200(self, mock_engine, client):
response = client.get("/tower")
assert response.status_code == 200
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
def test_tower_returns_html(self, mock_engine, client):
response = client.get("/tower")
assert "text/html" in response.headers["content-type"]
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
def test_tower_contains_dashboard_content(self, mock_engine, client):
response = client.get("/tower")
body = response.text
assert "tower" in body.lower() or "spark" in body.lower()
class TestSparkSnapshot:
"""Tests for _spark_snapshot helper."""
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
def test_snapshot_structure(self, mock_engine):
from dashboard.routes.tower import _spark_snapshot
snap = _spark_snapshot()
assert snap["type"] == "spark_state"
assert "status" in snap
assert "events" in snap
assert "predictions" in snap
assert "advisories" in snap
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
def test_snapshot_events_parsed(self, mock_engine):
from dashboard.routes.tower import _spark_snapshot
snap = _spark_snapshot()
ev = snap["events"][0]
assert ev["event_type"] == "task_completed"
assert ev["importance"] == 0.8
assert ev["agent_id"] == "agent-12"
assert ev["task_id"] == "task-567"
assert ev["data"] == {"result": "ok"}
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
def test_snapshot_predictions_parsed(self, mock_engine):
from dashboard.routes.tower import _spark_snapshot
snap = _spark_snapshot()
pred = snap["predictions"][0]
assert pred["task_id"] == "task-567"
assert pred["accuracy"] == 0.9
assert pred["evaluated"] is True
assert pred["predicted"] == {"outcome": "success"}
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
def test_snapshot_advisories_parsed(self, mock_engine):
from dashboard.routes.tower import _spark_snapshot
snap = _spark_snapshot()
adv = snap["advisories"][0]
assert adv["category"] == "performance"
assert adv["priority"] == "high"
assert adv["title"] == "Slow tasks"
assert adv["suggested_action"] == "Scale up workers"
@patch("dashboard.routes.tower.spark_engine")
def test_snapshot_handles_empty_state(self, mock_engine):
mock_engine.status.return_value = {"enabled": False}
mock_engine.get_timeline.return_value = []
mock_engine.get_predictions.return_value = []
mock_engine.get_advisories.return_value = []
from dashboard.routes.tower import _spark_snapshot
snap = _spark_snapshot()
assert snap["events"] == []
assert snap["predictions"] == []
assert snap["advisories"] == []
@patch("dashboard.routes.tower.spark_engine")
def test_snapshot_handles_invalid_json_data(self, mock_engine):
mock_engine.status.return_value = {"enabled": True}
event = MagicMock()
event.event_type = "test"
event.description = "bad data"
event.importance = 0.5
event.created_at = "2026-01-01T00:00:00"
event.agent_id = None
event.task_id = None
event.data = "not-json{"
mock_engine.get_timeline.return_value = [event]
pred = MagicMock()
pred.task_id = None
pred.accuracy = None
pred.evaluated_at = None
pred.created_at = "2026-01-01T00:00:00"
pred.predicted_value = None
mock_engine.get_predictions.return_value = [pred]
mock_engine.get_advisories.return_value = []
from dashboard.routes.tower import _spark_snapshot
snap = _spark_snapshot()
ev = snap["events"][0]
assert ev["data"] == {}
assert "agent_id" not in ev
assert "task_id" not in ev
pred = snap["predictions"][0]
assert pred["task_id"] == "?"
assert pred["predicted"] == {}
class TestTowerWebSocket:
"""Tests for WS /tower/ws endpoint."""
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
@patch("dashboard.routes.tower._PUSH_INTERVAL", 0)
def test_ws_sends_initial_snapshot(self, mock_engine, client):
import json
with client.websocket_connect("/tower/ws") as ws:
data = json.loads(ws.receive_text())
assert data["type"] == "spark_state"
assert "status" in data
assert "events" in data