diff --git a/pyproject.toml b/pyproject.toml index 4897d0b3..8e2a9fc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "anthropic", "openai", # For embeddings "tiktoken", # Token counting + "python-toon", # Token-efficient LLM serialization # MCP (Model Context Protocol) "mcp", @@ -120,11 +121,23 @@ select = [ "SIM", # flake8-simplify "TCH", # flake8-type-checking "PTH", # flake8-use-pathlib - "ERA", # eradicate (commented code) "PL", # Pylint "RUF", # Ruff-specific ] +# MCP servers: Tool functions require explicit parameters for schema generation. +# PLR0913 - MCP tools need many params (no dataclass workaround) +# PLR0915 - Factory functions define all tools (unavoidable nesting) +# PLC0415 - Lazy imports for circular import avoidance +# E501 - Docstrings and guidance messages +# +# Services: Internal methods often need multiple related parameters for DB ops. +# PLR0913 - Service methods wrapping DB/external calls need multiple params +# PLC0415 - Lazy imports for circular import avoidance +[tool.ruff.lint.per-file-ignores] +"roboco/mcp/*.py" = ["PLR0913", "PLR0915", "PLC0415", "E501"] +"roboco/services/*.py" = ["PLC0415", "PLR0913"] + # ============================================================================= # MyPy Configuration # ============================================================================= diff --git a/roboco/agents/base.py b/roboco/agents/base.py index 362cf770..0096c523 100644 --- a/roboco/agents/base.py +++ b/roboco/agents/base.py @@ -21,6 +21,7 @@ from pydantic import BaseModel, Field from roboco.api.websocket import broadcast_agent_chunk from roboco.config import settings +from roboco.llm import ToonAdapter from roboco.models import AgentRole, AgentStatus, Team logger = structlog.get_logger() @@ -113,6 +114,7 @@ class Agent(ABC): self._running = False self._task: asyncio.Task | None = None self._llm_client: AsyncAnthropic | None = None + self._toon = ToonAdapter() self.log = logger.bind( agent_id=str(config.id), @@ -157,6 +159,52 @@ class Agent(ABC): self._llm_client = AsyncAnthropic(api_key=settings.anthropic_api_key) return self._llm_client + # ========================================================================= + # TOON SERIALIZATION (for token-efficient LLM communication) + # ========================================================================= + + def format_context(self, data: dict[str, Any]) -> str: + """ + Format context data for LLM using TOON. + + TOON (Token-Oriented Object Notation) reduces token consumption + by 30-60% compared to JSON while maintaining semantic clarity. + + Args: + data: Dictionary to encode for LLM prompt. + + Returns: + TOON-formatted string. + """ + return self._toon.encode(data) + + def format_context_labeled(self, label: str, data: dict[str, Any]) -> str: + """ + Format labeled context data for embedding in prompts. + + Args: + label: Section label (e.g., "Task Context"). + data: Dictionary to encode. + + Returns: + Labeled TOON-formatted string. + """ + return self._toon.format_for_prompt(label, data) + + def parse_llm_response(self, response: str) -> dict[str, Any] | list[Any]: + """ + Parse structured data from LLM response. + + Attempts TOON parsing first, falls back to JSON. + + Args: + response: Raw LLM response text. + + Returns: + Parsed Python dict or list. + """ + return self._toon.decode(response) + # ========================================================================= # LIFECYCLE METHODS # ========================================================================= diff --git a/roboco/agents/board.py b/roboco/agents/board.py index 4aee30e5..1053f066 100644 --- a/roboco/agents/board.py +++ b/roboco/agents/board.py @@ -153,17 +153,20 @@ class ProductOwnerAgent(Agent): result = await self._api_call("GET", f"/tasks/{task_id}") acceptance_criteria = result.get("acceptance_criteria", []) - # Use LLM to check if criteria are met - prompt = f""" -Review this completed feature against its acceptance criteria: + # Use TOON for token-efficient context encoding + task_context = self.format_context_labeled( + "Feature Review", + { + "title": result.get("title", "Unknown"), + "description": result.get("description", "No description"), + "acceptance_criteria": acceptance_criteria, + "dev_notes": result.get("dev_notes", "None"), + }, + ) -Task: {result.get("title", "Unknown")} -Description: {result.get("description", "No description")} + prompt = f"""Review this completed feature against its acceptance criteria: -Acceptance Criteria: -{chr(10).join(f"- {c}" for c in acceptance_criteria)} - -Dev Notes: {result.get("dev_notes", "None")} +{task_context} Determine if all criteria are met. Respond with: ACCEPTED: [reason] or NEEDS_CHANGES: [what's missing] @@ -465,11 +468,15 @@ class AuditorAgent(Agent): if not self._observations: return - prompt = f""" -Analyze these observations for quality and efficiency issues: + # Use TOON for token-efficient context encoding + observations_context = self.format_context_labeled( + "Observations", + {"recent": self._observations[-50:]}, + ) -Observations: -{chr(10).join(str(o) for o in self._observations[-50:])} + prompt = f"""Analyze these observations for quality and efficiency issues: + +{observations_context} Look for: 1. Efficiency issues - wasted effort, unclear processes @@ -478,14 +485,9 @@ Look for: 4. Process violations - skipping QA, missing documentation 5. Team health - frustration, conflicts -For each issue found, provide: -- Category -- Severity (info/warning/concern/critical) -- Description -- Evidence -- Recommendation - -Be thorough but fair. +Format response as TOON tabular: +[N,]{{category,severity,description,evidence,recommendation}}: +efficiency,warning,Unclear handoff process,3 tasks delayed,Document handoff steps """ analysis = await self.think(prompt) self.log.info("Analysis complete", analysis_length=len(analysis)) diff --git a/roboco/agents/developer.py b/roboco/agents/developer.py index 80c655ac..fb95520f 100644 --- a/roboco/agents/developer.py +++ b/roboco/agents/developer.py @@ -249,13 +249,16 @@ class DeveloperAgent(Agent): # Read task requirements requirements = await self._read_task_requirements(ctx.task_id) - # Use LLM to understand and identify gaps - prompt = f""" -You are analyzing a task before beginning work. + # Format context using TOON for token efficiency + task_context = self.format_context_labeled( + "Task Context", + {"title": ctx.title, "requirements": requirements}, + ) -Task: {ctx.title} -Requirements: -{requirements} + # Use LLM to understand and identify gaps + prompt = f"""You are analyzing a task before beginning work. + +{task_context} Analyze: 1. What exactly needs to be done? @@ -293,28 +296,48 @@ If clarification needed, respond with: "QUESTION: [your question]" """ self.log.info("PLAN phase", task_id=str(ctx.task_id)) - # Use LLM to create plan - prompt = f""" -Create an implementation plan for this task: + # Format context using TOON + plan_context = self.format_context_labeled( + "Task", + { + "title": ctx.title, + "understanding": ctx.journal_entries[-1] + if ctx.journal_entries + else "No context", + }, + ) -Task: {ctx.title} -Understanding: {ctx.journal_entries[-1] if ctx.journal_entries else "No context"} + # Use LLM to create plan - request TOON tabular response + prompt = f"""Create an implementation plan for this task: -Break this into ordered subtasks. For each subtask: +{plan_context} + +Break this into ordered subtasks. For each subtask provide: - Clear description - Files to modify - Estimated complexity (small/medium/large) -Format as JSON array: -[ - {{"description": "...", "files": ["..."], "complexity": "small|medium|large"}}, - ... -] +Format response as TOON tabular: +[N,]{{description,files,complexity}}: +Implement the main logic,src/main.py|src/utils.py,medium +Add unit tests,tests/test_main.py,small """ response = await self.think(prompt) - # Parse subtasks (simplified - would use proper JSON parsing) - ctx.subtasks = [{"description": response, "files": [], "complexity": "medium"}] + # Parse subtasks using TOON (falls back to JSON) + try: + subtasks = self.parse_llm_response(response) + if isinstance(subtasks, list): + ctx.subtasks = subtasks + else: + ctx.subtasks = [ + {"description": response, "files": [], "complexity": "medium"} + ] + except ValueError: + # Fallback if parsing fails + ctx.subtasks = [ + {"description": response, "files": [], "complexity": "medium"} + ] # Journal entry ts = datetime.now(UTC).isoformat() @@ -349,12 +372,22 @@ Format as JSON array: subtask = ctx.subtasks[ctx.current_subtask] - # Use LLM to work on subtask - prompt = f""" -Execute this subtask: + # Format context using TOON + execute_context = self.format_context_labeled( + "Execution Context", + { + "task": ctx.title, + "subtask_number": ctx.current_subtask + 1, + "total_subtasks": len(ctx.subtasks), + "description": subtask.get("description", ""), + "files": subtask.get("files", []), + }, + ) -Task: {ctx.title} -Subtask {ctx.current_subtask + 1}/{len(ctx.subtasks)}: {subtask.get("description", "")} + # Use LLM to work on subtask + prompt = f"""Execute this subtask: + +{execute_context} Provide: 1. Code changes needed diff --git a/roboco/agents/documenter.py b/roboco/agents/documenter.py index b49edfa2..371ee4c8 100644 --- a/roboco/agents/documenter.py +++ b/roboco/agents/documenter.py @@ -321,19 +321,21 @@ Respond with structured analysis. doc_spec = ctx.documents_needed[ctx.current_doc] - # Use LLM to write documentation - prompt = f""" -Write documentation for this task. + # Use TOON for token-efficient context encoding + doc_context = self.format_context_labeled( + "Documentation Task", + { + "title": ctx.title, + "doc_type": doc_spec.doc_type.value, + "target_path": doc_spec.path, + "summary": ctx.summary, + "dev_notes": ctx.dev_notes or "None", + }, + ) -Task: {ctx.title} -Document Type: {doc_spec.doc_type.value} -Target Path: {doc_spec.path} + prompt = f"""Write documentation for this task. -Summary: -{ctx.summary} - -Developer Notes: -{ctx.dev_notes or "None"} +{doc_context} Write professional, clear documentation following best practices. Include: @@ -373,14 +375,19 @@ Format appropriately for the document type. if not doc_spec.content: continue - prompt = f""" -Review this documentation for quality: + # Use TOON for token-efficient context encoding + review_context = self.format_context_labeled( + "Document Review", + { + "title": doc_spec.title, + "doc_type": doc_spec.doc_type.value, + "content": doc_spec.content, + }, + ) -Document: {doc_spec.title} -Type: {doc_spec.doc_type.value} + prompt = f"""Review this documentation for quality: -Content: -{doc_spec.content} +{review_context} Check: 1. Accuracy - Does it correctly describe the feature? @@ -388,7 +395,9 @@ Check: 3. Clarity - Is it easy to understand? 4. Examples - Are examples helpful and correct? -If issues found, provide suggestions. +Format response as TOON: +{{accuracy,completeness,clarity,examples,suggestions}}: +good,complete,clear,helpful,None """ review = await self.think(prompt) ts = datetime.now(UTC).isoformat() diff --git a/roboco/agents/pm.py b/roboco/agents/pm.py index ee6c1958..48f9b66a 100644 --- a/roboco/agents/pm.py +++ b/roboco/agents/pm.py @@ -199,12 +199,15 @@ class CellPMAgent(Agent): new_tasks = await self._get_unassigned_tasks() for task_id in new_tasks: - # Assess complexity and priority - prompt = f""" -Assess this task for prioritization: + # Use TOON for token-efficient context encoding + triage_context = self.format_context_labeled( + "Task Triage", + {"task_id": str(task_id), "cell": self.cell_name}, + ) -Task ID: {task_id} -Cell: {self.cell_name} + prompt = f"""Assess this task for prioritization: + +{triage_context} Consider: 1. Complexity (low/medium/high) @@ -212,7 +215,9 @@ Consider: 3. Priority (P0-P3) 4. Best dev fit based on skills -Provide assessment. +Format response as TOON: +{{complexity,dependencies,priority,dev_fit}}: +medium,TASK-abc123,P1,backend-dev-1 """ assessment = await self.think(prompt) self.log.info( @@ -249,11 +254,15 @@ Provide assessment. questions = await self._get_pending_questions() for question in questions: - # Try to answer or route appropriately - prompt = f""" -A cell member has a question: + # Use TOON for token-efficient context encoding + question_context = self.format_context_labeled( + "Cell Question", + {"question": question, "cell": self.cell_name}, + ) -{question} + prompt = f"""A cell member needs help: + +{question_context} As the Cell PM, provide: 1. Answer if you can @@ -610,23 +619,29 @@ class MainPMAgent(Agent): self.log.debug("PRIORITIZE phase") if self._board_directives: - directives = chr(10).join(f"- {d}" for d in self._board_directives) - status_lines = [] - for k, v in self._cell_statuses.items(): - active = v.active_tasks - blocked = v.blocked_tasks - status_lines.append(f"- {k}: {active} active, {blocked} blocked") - cell_status = chr(10).join(status_lines) - prompt = f""" -Translate these Board directives into cell priorities: + # Build status data for TOON encoding + cell_status_data = { + name: {"active": s.active_tasks, "blocked": s.blocked_tasks} + for name, s in self._cell_statuses.items() + } -Directives: -{directives} + # Use TOON for token-efficient context encoding + priority_context = self.format_context_labeled( + "Prioritization Context", + { + "directives": self._board_directives, + "cell_status": cell_status_data, + }, + ) -Current Cell Status: -{cell_status} + prompt = f"""Translate these Board directives into cell priorities: -Provide prioritized task list for each cell. +{priority_context} + +Format response as TOON tabular: +[N,]{{cell,priority,task_description}}: +backend-cell,P0,Implement critical auth fix +frontend-cell,P1,Update dashboard layout """ priorities = await self.think(prompt) self.log.info("Priorities set", priorities=priorities[:200]) @@ -636,11 +651,19 @@ Provide prioritized task list for each cell. self.log.debug("COORDINATE phase") for issue in self._cross_cell_issues: - prompt = f""" -Resolve this cross-cell issue: + # Use TOON for token-efficient context encoding + issue_context = self.format_context_labeled( + "Cross-Cell Issue", + { + "description": issue.get("description"), + "cells": issue.get("cells"), + "task_id": issue.get("task_id"), + }, + ) -Issue: {issue.get("description")} -Cells Involved: {issue.get("cells")} + prompt = f"""Resolve this cross-cell issue: + +{issue_context} Propose a resolution that unblocks all parties. """ diff --git a/roboco/agents/qa.py b/roboco/agents/qa.py index 711a9ea9..be526dbd 100644 --- a/roboco/agents/qa.py +++ b/roboco/agents/qa.py @@ -211,27 +211,22 @@ class QAAgent(Agent): dev_notes = await self._read_dev_notes(ctx.task_id) commits = await self._get_task_commits(ctx.task_id) - # Use LLM to understand and create test plan - prompt = f""" -You are a QA engineer reviewing a completed task. + # Use TOON for token-efficient context encoding + task_context = self.format_context_labeled( + "QA Review Context", + { + "title": ctx.title, + "requirements": requirements, + "dev_notes": dev_notes, + "commits": commits, + }, + ) -Task: {ctx.title} + prompt = f"""You are a QA engineer reviewing a completed task. -Requirements: -{requirements} - -Developer Notes: -{dev_notes} - -Commits: -{commits} +{task_context} Based on this, create test cases to verify the implementation. -For each test case provide: -1. Name -2. What to test -3. Steps to execute -4. Expected outcome Focus on: - Acceptance criteria verification @@ -239,8 +234,10 @@ Focus on: - Integration points - Error handling -Format as JSON array. -""" +Format response as TOON tabular: +[N,]{{name,description,steps,expected}}: +Acceptance Criteria,Verify all criteria met,Review implementation|Check each criterion,All criteria satisfied +""" # noqa: E501 _response = await self.think(prompt) # Response informs test case structure # Create test cases (simplified parsing) @@ -289,24 +286,26 @@ Format as JSON array. test_case = ctx.test_cases[ctx.current_test] - # Use LLM to execute test - prompt = f""" -Execute this test case: + # Use TOON for token-efficient context encoding + test_context = self.format_context_labeled( + "Test Case", + { + "name": test_case.name, + "description": test_case.description, + "steps": test_case.steps, + "expected": test_case.expected, + }, + ) -Test: {test_case.name} -Description: {test_case.description} -Steps: {", ".join(test_case.steps)} -Expected: {test_case.expected} + prompt = f"""Execute this test case: -Simulate executing this test and provide: -1. RESULT: PASS or FAIL -2. ACTUAL: What was observed -3. NOTES: Any additional findings +{test_context} -Format: -RESULT: [PASS|FAIL] -ACTUAL: [observation] -NOTES: [notes] +Simulate executing this test and provide results. + +Format response as TOON: +{{result,actual,notes}}: +PASS,All criteria verified successfully,No issues found """ response = await self.think(prompt) diff --git a/roboco/llm/__init__.py b/roboco/llm/__init__.py new file mode 100644 index 00000000..6045c292 --- /dev/null +++ b/roboco/llm/__init__.py @@ -0,0 +1,15 @@ +""" +LLM Communication Layer + +Provides utilities for efficient communication with Large Language Models, +including TOON serialization for token-efficient data transfer. +""" + +from roboco.llm.metrics import ToonMetrics +from roboco.llm.toon_adapter import ToonAdapter, ToonConfig + +__all__ = [ + "ToonAdapter", + "ToonConfig", + "ToonMetrics", +] diff --git a/roboco/llm/metrics.py b/roboco/llm/metrics.py new file mode 100644 index 00000000..745bb755 --- /dev/null +++ b/roboco/llm/metrics.py @@ -0,0 +1,86 @@ +""" +TOON Metrics + +Tracks token savings and usage statistics for TOON vs JSON serialization. +""" + +from dataclasses import dataclass, field +from datetime import UTC, datetime + + +@dataclass +class ToonMetrics: + """ + Metrics for tracking TOON serialization efficiency. + + Tracks character counts (as proxy for tokens) for JSON vs TOON + to measure actual savings in production. + """ + + json_chars: int = 0 + toon_chars: int = 0 + encode_count: int = 0 + decode_count: int = 0 + decode_fallback_count: int = 0 + started_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + @property + def savings_percent(self) -> float: + """Calculate percentage of characters saved using TOON.""" + if self.json_chars == 0: + return 0.0 + return (1 - self.toon_chars / self.json_chars) * 100 + + @property + def fallback_rate(self) -> float: + """Calculate rate of fallback to JSON decoding.""" + if self.decode_count == 0: + return 0.0 + return (self.decode_fallback_count / self.decode_count) * 100 + + def record_encode(self, json_chars: int, toon_chars: int) -> None: + """Record an encode operation with character counts.""" + self.json_chars += json_chars + self.toon_chars += toon_chars + self.encode_count += 1 + + def record_decode(self, used_fallback: bool = False) -> None: + """Record a decode operation.""" + self.decode_count += 1 + if used_fallback: + self.decode_fallback_count += 1 + + def to_dict(self) -> dict: + """Convert metrics to dictionary for logging/reporting.""" + return { + "json_chars": self.json_chars, + "toon_chars": self.toon_chars, + "savings_percent": round(self.savings_percent, 2), + "encode_count": self.encode_count, + "decode_count": self.decode_count, + "fallback_rate": round(self.fallback_rate, 2), + "started_at": self.started_at.isoformat(), + } + + def reset(self) -> None: + """Reset all metrics.""" + self.json_chars = 0 + self.toon_chars = 0 + self.encode_count = 0 + self.decode_count = 0 + self.decode_fallback_count = 0 + self.started_at = datetime.now(UTC) + + +# Global metrics holder +class _MetricsHolder: + """Holder for singleton ToonMetrics instance.""" + + instance: ToonMetrics | None = None + + +def get_toon_metrics() -> ToonMetrics: + """Get the global TOON metrics instance.""" + if _MetricsHolder.instance is None: + _MetricsHolder.instance = ToonMetrics() + return _MetricsHolder.instance diff --git a/roboco/llm/toon_adapter.py b/roboco/llm/toon_adapter.py new file mode 100644 index 00000000..ab87ebb2 --- /dev/null +++ b/roboco/llm/toon_adapter.py @@ -0,0 +1,191 @@ +""" +TOON Adapter + +Provides serialization/deserialization between Python objects and TOON format +for token-efficient LLM communication. TOON (Token-Oriented Object Notation) +achieves 30-60% fewer tokens than JSON while maintaining semantic clarity. + +Usage: + adapter = ToonAdapter() + + # Encode data for LLM prompt + toon_str = adapter.encode({"name": "Alice", "age": 30}) + + # Decode LLM response (falls back to JSON if TOON fails) + data = adapter.decode(response_text) + + # Format for embedding in prompt + prompt_section = adapter.format_for_prompt("Task Context", task_data) +""" + +import json +from dataclasses import dataclass +from typing import Any + +import structlog +import toon +from pydantic import BaseModel + +logger = structlog.get_logger() + + +@dataclass +class ToonConfig: + """Configuration for TOON encoding.""" + + delimiter: str = "," + indent: int = 2 + include_length: bool = True + + +class ToonAdapter: + """ + Adapter for TOON serialization at LLM boundaries. + + Converts Python dicts/Pydantic models to TOON for sending to LLMs, + and parses TOON responses back to Python objects. Falls back to + JSON parsing if TOON decode fails. + """ + + def __init__(self, config: ToonConfig | None = None) -> None: + """ + Initialize the TOON adapter. + + Args: + config: Optional configuration for TOON encoding. + """ + self.config = config or ToonConfig() + self.log = logger.bind(component="toon_adapter") + + def encode(self, data: dict[str, Any] | list[Any] | BaseModel) -> str: + """ + Convert Python object to TOON for LLM consumption. + + Args: + data: Dictionary, list, or Pydantic model to encode. + + Returns: + TOON-formatted string. + """ + if isinstance(data, BaseModel): + data = data.model_dump() + + return toon.encode(data, indent=self.config.indent) + + def decode(self, toon_str: str) -> dict[str, Any] | list[Any]: + """ + Parse TOON response from LLM. + + Falls back to JSON parsing if TOON decode fails, logging a warning. + + Args: + toon_str: TOON-formatted string from LLM response. + + Returns: + Parsed Python dict or list. + + Raises: + ValueError: If neither TOON nor JSON parsing succeeds. + """ + # Try TOON first + toon_err_msg = "" + try: + return toon.decode(toon_str) + except Exception as toon_error: + toon_err_msg = str(toon_error) + self.log.warning( + "TOON decode failed, trying JSON fallback", + error=toon_err_msg, + ) + + # Fallback to JSON + try: + return json.loads(toon_str) + except json.JSONDecodeError as json_error: + self.log.error( + "Both TOON and JSON decode failed", + toon_error=toon_err_msg, + json_error=str(json_error), + ) + raise ValueError( + f"Failed to decode response as TOON or JSON: {toon_str[:100]}..." + ) from json_error + + def format_for_prompt(self, label: str, data: dict[str, Any]) -> str: + """ + Format data with label for embedding in LLM prompt. + + Args: + label: Section label (e.g., "Task Context", "Requirements"). + data: Data to encode. + + Returns: + Formatted string suitable for prompt inclusion. + """ + encoded = self.encode(data) + return f"{label}:\n{encoded}" + + def format_tabular_request( + self, + fields: list[str], + description: str, + example_rows: list[list[str]] | None = None, + ) -> str: + """ + Format a request for tabular TOON response. + + Args: + fields: Column names for the table. + description: What the LLM should return. + example_rows: Optional example data rows. + + Returns: + Formatted instruction for LLM to return TOON tabular data. + """ + fields_str = ",".join(fields) + header = f"[N,]{{{fields_str}}}:" + instruction = f"{description}\n\nFormat response as TOON tabular:\n{header}" + + if example_rows: + instruction += "\n" + for row in example_rows: + instruction += f"{self.config.delimiter.join(row)}\n" + + return instruction + + def estimate_token_savings( + self, + data: dict[str, Any] | list[Any], + ) -> tuple[int, int, float]: + """ + Estimate token savings of TOON vs JSON for given data. + + Args: + data: Data to compare. + + Returns: + Tuple of (json_chars, toon_chars, savings_percent). + """ + json_str = json.dumps(data, separators=(",", ":")) + toon_str = self.encode(data) + + json_chars = len(json_str) + toon_chars = len(toon_str) + + savings = (1 - toon_chars / json_chars) * 100 if json_chars > 0 else 0.0 + + return json_chars, toon_chars, savings + + +# Module-level singleton holder +class _AdapterHolder: + """Holder for singleton ToonAdapter instance.""" + + instance: ToonAdapter | None = None + + +def get_toon_adapter() -> ToonAdapter: + """Get the default TOON adapter singleton.""" + if _AdapterHolder.instance is None: + _AdapterHolder.instance = ToonAdapter() + return _AdapterHolder.instance diff --git a/roboco/mcp/journal_server.py b/roboco/mcp/journal_server.py index 61ea8d51..33c4170f 100644 --- a/roboco/mcp/journal_server.py +++ b/roboco/mcp/journal_server.py @@ -21,6 +21,10 @@ from fastapi import status from mcp.server.fastmcp import FastMCP from roboco.config import settings +from roboco.llm import ToonAdapter + +# Global TOON adapter for encoding journal data +_toon = ToonAdapter() # ============================================================================= # HELPER FUNCTIONS @@ -141,6 +145,7 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP: return { "status": "created", "entry": entry, + "entry_toon": _toon.encode(entry), # TOON-encoded for LLM token efficiency "guidance": "Journal entry saved. Use roboco_journal_search to find past entries.", } diff --git a/roboco/mcp/message_server.py b/roboco/mcp/message_server.py index 891b5f8b..720538d6 100644 --- a/roboco/mcp/message_server.py +++ b/roboco/mcp/message_server.py @@ -21,6 +21,10 @@ from mcp.server.fastmcp import FastMCP from roboco.agents_config import CHANNEL_ACCESS from roboco.config import settings +from roboco.llm import ToonAdapter + +# Global TOON adapter for encoding message data +_toon = ToonAdapter() def _check_channel_access(agent_id: str, channel_slug: str, action: str) -> bool: diff --git a/roboco/mcp/task_server.py b/roboco/mcp/task_server.py index 1ac6e874..618595f8 100644 --- a/roboco/mcp/task_server.py +++ b/roboco/mcp/task_server.py @@ -27,6 +27,10 @@ from fastapi import status from mcp.server.fastmcp import FastMCP from roboco.config import settings +from roboco.llm import ToonAdapter + +# Global TOON adapter for encoding task data +_toon = ToonAdapter() # ============================================================================= # VALID STATE TRANSITIONS @@ -63,15 +67,25 @@ def _format_task_response( guidance: str, project: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Format a standardized task response with guidance.""" + """ + Format a standardized task response with guidance. + + Includes both JSON task data and TOON-encoded version for + token-efficient LLM consumption. + """ + # Encode task data as TOON for token efficiency when LLM processes response + task_toon = _toon.encode(task) + response = { "status": task.get("status"), "task": task, + "task_toon": task_toon, # TOON-encoded for LLM token efficiency "next_step": next_step, "guidance": guidance, } if project: response["project"] = project + response["project_toon"] = _toon.encode(project) return response diff --git a/roboco/models/handoff.py b/roboco/models/handoff.py index 4a42504d..ccf8c7d9 100644 --- a/roboco/models/handoff.py +++ b/roboco/models/handoff.py @@ -5,6 +5,7 @@ Documenter handoffs contain all the information needed for a Documenter to create production documentation from developer work. """ +from dataclasses import dataclass, field from datetime import UTC, datetime from uuid import UUID, uuid4 @@ -225,24 +226,29 @@ class DocumenterHandoff(TimestampMixin): # ============================================================================= -def create_handoff( - task_id: UUID, - summary: str, - commits: list[dict[str, str]], - dev_notes_location: str, - new_functionality: list[str] | None = None, - modified_behavior: list[str] | None = None, - breaking_changes: list[str] | None = None, -) -> DocumenterHandoff: +@dataclass +class HandoffParams: + """Parameters for creating a handoff document.""" + + task_id: UUID + summary: str + commits: list[dict[str, str]] + dev_notes_location: str + new_functionality: list[str] = field(default_factory=list) + modified_behavior: list[str] = field(default_factory=list) + breaking_changes: list[str] = field(default_factory=list) + + +def create_handoff(params: HandoffParams) -> DocumenterHandoff: """Create a basic handoff document.""" handoff = DocumenterHandoff( - task_id=task_id, - summary=summary, - commits=commits, - dev_notes_location=dev_notes_location, - new_functionality=new_functionality or [], - modified_behavior=modified_behavior or [], - breaking_changes=breaking_changes or [], + task_id=params.task_id, + summary=params.summary, + commits=params.commits, + dev_notes_location=params.dev_notes_location, + new_functionality=params.new_functionality, + modified_behavior=params.modified_behavior, + breaking_changes=params.breaking_changes, ) # Always add changelog as required diff --git a/roboco/models/journal.py b/roboco/models/journal.py index 663ce107..75b072b7 100644 --- a/roboco/models/journal.py +++ b/roboco/models/journal.py @@ -5,6 +5,7 @@ Personal agent journals for reflection, growth tracking, and debugging. Each agent maintains their own journal with entries tied to tasks and sessions. """ +from dataclasses import dataclass, field from datetime import UTC, datetime from uuid import UUID, uuid4 @@ -114,170 +115,195 @@ class Journal(TimestampMixin): # ============================================================================= -def create_task_reflection( - journal_id: UUID, - task_id: UUID, - title: str, - what_done: str, - what_learned: str, - what_struggled: str, - next_steps: list[str], - tags: list[str] | None = None, -) -> JournalEntry: +@dataclass +class TaskReflectionParams: + """Parameters for creating a task reflection entry.""" + + journal_id: UUID + task_id: UUID + title: str + what_done: str + what_learned: str + what_struggled: str + next_steps: list[str] + tags: list[str] = field(default_factory=list) + + +@dataclass +class DecisionLogParams: + """Parameters for creating a decision log entry.""" + + journal_id: UUID + title: str + context: str + options: list[dict[str, str]] + chosen: str + rationale: str + consequences: list[str] + task_id: UUID | None = None + tags: list[str] = field(default_factory=list) + + +@dataclass +class LearningEntryParams: + """Parameters for creating a learning entry.""" + + journal_id: UUID + title: str + what_learned: str + how_applied: str | None = None + source: str | None = None + task_id: UUID | None = None + tags: list[str] = field(default_factory=list) + + +@dataclass +class StruggleEntryParams: + """Parameters for creating a struggle entry.""" + + journal_id: UUID + title: str + what_struggled: str + attempted_solutions: list[str] + resolution: str | None = None + help_needed: str | None = None + task_id: UUID | None = None + tags: list[str] = field(default_factory=list) + + +@dataclass +class GeneralEntryParams: + """Parameters for creating a general journal entry.""" + + journal_id: UUID + title: str + content: str + task_id: UUID | None = None + session_id: UUID | None = None + tags: list[str] = field(default_factory=list) + is_private: bool = False + + +def create_task_reflection(params: TaskReflectionParams) -> JournalEntry: """Create a task reflection entry.""" content = f"""## What I Did -{what_done} +{params.what_done} ## What I Learned -{what_learned} +{params.what_learned} ## What I Struggled With -{what_struggled} +{params.what_struggled} ## Next Steps -{chr(10).join(f"- [ ] {step}" for step in next_steps)} +{chr(10).join(f"- [ ] {step}" for step in params.next_steps)} """ return JournalEntry( - journal_id=journal_id, + journal_id=params.journal_id, type=JournalEntryType.TASK_REFLECTION, - title=title, + title=params.title, content=content, - task_id=task_id, - tags=tags or [], + task_id=params.task_id, + tags=params.tags, ) -def create_decision_log( - journal_id: UUID, - title: str, - context: str, - options: list[dict[str, str]], - chosen: str, - rationale: str, - consequences: list[str], - task_id: UUID | None = None, - tags: list[str] | None = None, -) -> JournalEntry: +def create_decision_log(params: DecisionLogParams) -> JournalEntry: """Create a decision log entry.""" options_text = "" - for i, opt in enumerate(options, 1): + for i, opt in enumerate(params.options, 1): options_text += f"\n**Option {i}: {opt.get('name', f'Option {i}')}**\n" options_text += f"- Pros: {opt.get('pros', 'N/A')}\n" options_text += f"- Cons: {opt.get('cons', 'N/A')}\n" content = f"""## Context -{context} +{params.context} ## Options Considered {options_text} ## Decision -Chose **{chosen}** because {rationale} +Chose **{params.chosen}** because {params.rationale} ## Consequences -{chr(10).join(f"- {c}" for c in consequences)} +{chr(10).join(f"- {c}" for c in params.consequences)} """ return JournalEntry( - journal_id=journal_id, + journal_id=params.journal_id, type=JournalEntryType.DECISION_LOG, - title=title, + title=params.title, content=content, - task_id=task_id, - tags=tags or [], + task_id=params.task_id, + tags=params.tags, ) -def create_learning_entry( - journal_id: UUID, - title: str, - what_learned: str, - how_applied: str | None = None, - source: str | None = None, - task_id: UUID | None = None, - tags: list[str] | None = None, -) -> JournalEntry: +def create_learning_entry(params: LearningEntryParams) -> JournalEntry: """Create a learning entry.""" content = f"""## What I Learned -{what_learned} +{params.what_learned} """ - if how_applied: + if params.how_applied: content += f""" ## How I Applied It -{how_applied} +{params.how_applied} """ - if source: + if params.source: content += f""" ## Source -{source} +{params.source} """ return JournalEntry( - journal_id=journal_id, + journal_id=params.journal_id, type=JournalEntryType.LEARNING, - title=title, + title=params.title, content=content, - task_id=task_id, - tags=tags or [], + task_id=params.task_id, + tags=params.tags, sentiment="positive", ) -def create_struggle_entry( - journal_id: UUID, - title: str, - what_struggled: str, - attempted_solutions: list[str], - resolution: str | None = None, - help_needed: str | None = None, - task_id: UUID | None = None, - tags: list[str] | None = None, -) -> JournalEntry: +def create_struggle_entry(params: StruggleEntryParams) -> JournalEntry: """Create a struggle/difficulty entry.""" content = f"""## What I Struggled With -{what_struggled} +{params.what_struggled} ## What I Tried -{chr(10).join(f"- {s}" for s in attempted_solutions)} +{chr(10).join(f"- {s}" for s in params.attempted_solutions)} """ - if resolution: + if params.resolution: content += f""" ## Resolution -{resolution} +{params.resolution} """ - if help_needed: + if params.help_needed: content += f""" ## Help Needed -{help_needed} +{params.help_needed} """ return JournalEntry( - journal_id=journal_id, + journal_id=params.journal_id, type=JournalEntryType.STRUGGLE, - title=title, + title=params.title, content=content, - task_id=task_id, - tags=tags or [], + task_id=params.task_id, + tags=params.tags, sentiment="frustrated", ) -def create_general_entry( - journal_id: UUID, - title: str, - content: str, - task_id: UUID | None = None, - session_id: UUID | None = None, - tags: list[str] | None = None, - is_private: bool = False, -) -> JournalEntry: +def create_general_entry(params: GeneralEntryParams) -> JournalEntry: """Create a general journal entry.""" return JournalEntry( - journal_id=journal_id, + journal_id=params.journal_id, type=JournalEntryType.GENERAL, - title=title, - content=content, - task_id=task_id, - session_id=session_id, - tags=tags or [], - is_private=is_private, + title=params.title, + content=params.content, + task_id=params.task_id, + session_id=params.session_id, + tags=params.tags, + is_private=params.is_private, ) diff --git a/roboco/services/extraction.py b/roboco/services/extraction.py index 73cc0375..1c5ecfe7 100644 --- a/roboco/services/extraction.py +++ b/roboco/services/extraction.py @@ -29,6 +29,19 @@ logger = structlog.get_logger() # Maximum length for raw excerpt storage MAX_EXCERPT_LENGTH = 200 + +@dataclass +class ExtractionContext: + """Context for message extraction.""" + + content: str + agent_id: UUID + channel_id: UUID + session_id: UUID + group_id: UUID + task_id: UUID | None = None + + # ============================================================================= # EXTRACTION PATTERNS # ============================================================================= @@ -202,40 +215,27 @@ class ExtractionService: # Mention pattern self._mention_pattern = re.compile(r"@(\w+)") - async def extract( - self, - content: str, - agent_id: UUID, - channel_id: UUID, - session_id: UUID, - group_id: UUID, - task_id: UUID | None = None, - ) -> ExtractionResult: + async def extract(self, ctx: ExtractionContext) -> ExtractionResult: """ Extract messages from raw content. Args: - content: Raw LLM output text - agent_id: Agent who produced the content - channel_id: Target channel - session_id: Current session - group_id: Group within channel - task_id: Optional related task + ctx: Extraction context with content and metadata Returns: ExtractionResult with extracted messages """ - if len(content) < self.config.min_content_length: + if len(ctx.content) < self.config.min_content_length: return ExtractionResult( messages=[], - raw_content=content, - agent_id=agent_id, - channel_id=channel_id, - session_id=session_id, + raw_content=ctx.content, + agent_id=ctx.agent_id, + channel_id=ctx.channel_id, + session_id=ctx.session_id, ) # Segment the content - segments = self._segment_content(content) + segments = self._segment_content(ctx.content) messages: list[ExtractedMessage] = [] pattern_matches: dict[str, list[str]] = {} @@ -264,15 +264,15 @@ class ExtractionService: # Create message message = ExtractedMessage( id=uuid4(), - agent_id=agent_id, - channel_id=channel_id, - group_id=group_id, - session_id=session_id, + agent_id=ctx.agent_id, + channel_id=ctx.channel_id, + group_id=ctx.group_id, + session_id=ctx.session_id, type=msg_type, content=segment.strip(), content_length=len(segment.strip()), mentions=mentions, - task_id=task_id, + task_id=ctx.task_id, confidence=confidence, raw_excerpt=segment[:MAX_EXCERPT_LENGTH] if len(segment) > MAX_EXCERPT_LENGTH @@ -284,17 +284,17 @@ class ExtractionService: result = ExtractionResult( messages=messages, - raw_content=content, - agent_id=agent_id, - channel_id=channel_id, - session_id=session_id, + raw_content=ctx.content, + agent_id=ctx.agent_id, + channel_id=ctx.channel_id, + session_id=ctx.session_id, pattern_matches=pattern_matches, confidence_scores=confidence_scores, ) self.log.info( "Extraction complete", - agent_id=str(agent_id), + agent_id=str(ctx.agent_id), message_count=result.message_count, types=result.types_extracted, ) @@ -365,46 +365,41 @@ class ExtractionService: return best_type, confidence, matches - async def extract_with_llm( - self, - content: str, - agent_id: UUID, - channel_id: UUID, - session_id: UUID, - group_id: UUID, - task_id: UUID | None = None, - ) -> ExtractionResult: + async def extract_with_llm(self, ctx: ExtractionContext) -> ExtractionResult: """ Extract messages using LLM classification. This is more accurate but slower and more expensive. Falls back to pattern matching if LLM unavailable. + Uses TOON format for token-efficient communication. """ from anthropic import AsyncAnthropic from roboco.config import settings + from roboco.llm import ToonAdapter + + toon = ToonAdapter() try: client = AsyncAnthropic(api_key=settings.anthropic_api_key) - # Build prompt for LLM classification + # Build prompt for LLM classification using TOON prompt = f"""Analyze this agent output and classify each distinct segment. Agent output: -{content} +{ctx.content} For each segment, identify: - type: one of [reasoning, dialogue, decision, action, blocker, technical] - content: the segment text - confidence: 0.0 to 1.0 -Return as JSON array of objects. Example: -[ - {{"type": "reasoning", "content": "Analyzing the problem...", "confidence": 0.9}}, - {{"type": "action", "content": "Creating file utils.py", "confidence": 0.95}} -] +Return as TOON tabular format: +[N,]{{type,content,confidence}}: +reasoning,Analyzing the problem...,0.9 +action,Creating file utils.py,0.95 -Output only valid JSON, no other text.""" +Output only valid TOON, no other text.""" response = await client.messages.create( model="claude-3-haiku-20240307", # Fast, cheap for classification @@ -412,11 +407,9 @@ Output only valid JSON, no other text.""" messages=[{"role": "user", "content": prompt}], ) - # Parse response - import json - + # Parse response using TOON (falls back to JSON) response_text = response.content[0].text - segments = json.loads(response_text) + segments = toon.decode(response_text) messages: list[ExtractedMessage] = [] for segment in segments: @@ -430,11 +423,11 @@ Output only valid JSON, no other text.""" id=uuid4(), content=msg_content, message_type=msg_type, - agent_id=agent_id, - channel_id=channel_id, - session_id=session_id, - group_id=group_id, - task_id=task_id, + agent_id=ctx.agent_id, + channel_id=ctx.channel_id, + session_id=ctx.session_id, + group_id=ctx.group_id, + task_id=ctx.task_id, confidence=confidence, metadata={"extraction_method": "llm"}, ) @@ -442,7 +435,7 @@ Output only valid JSON, no other text.""" return ExtractionResult( messages=messages, - raw_content=content, + raw_content=ctx.content, extraction_time=0.0, # Could measure actual time confidence=sum(m.confidence for m in messages) / max(1, len(messages)), ) @@ -450,14 +443,7 @@ Output only valid JSON, no other text.""" except Exception as e: # Fall back to pattern matching self.log.warning("LLM extraction failed, using patterns", error=str(e)) - return await self.extract( - content=content, - agent_id=agent_id, - channel_id=channel_id, - session_id=session_id, - group_id=group_id, - task_id=task_id, - ) + return await self.extract(ctx) # ============================================================================= @@ -496,26 +482,11 @@ class ExtractionPipeline: """Register a callback for extracted messages.""" self._message_callbacks.append(callback) - async def process_buffer( - self, - content: str, - agent_id: UUID, - channel_id: UUID, - session_id: UUID, - group_id: UUID, - task_id: UUID | None = None, - ) -> ExtractionResult: + async def process_buffer(self, ctx: ExtractionContext) -> ExtractionResult: """ Process a buffer and invoke callbacks for each message. """ - result = await self.extraction.extract( - content=content, - agent_id=agent_id, - channel_id=channel_id, - session_id=session_id, - group_id=group_id, - task_id=task_id, - ) + result = await self.extraction.extract(ctx) # Invoke callbacks for each message for message in result.messages: diff --git a/uv.lock b/uv.lock index 643355d5..efe8384e 100644 --- a/uv.lock +++ b/uv.lock @@ -1796,7 +1796,7 @@ name = "nvidia-cudnn-cu12" version = "9.10.2.21" source = { registry = "https://pkgs.safetycli.com/repository/renzof/pypi/simple/" } dependencies = [ - { name = "nvidia-cublas-cu12" }, + { name = "nvidia-cublas-cu12", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, @@ -1807,7 +1807,7 @@ name = "nvidia-cufft-cu12" version = "11.3.3.83" source = { registry = "https://pkgs.safetycli.com/repository/renzof/pypi/simple/" } dependencies = [ - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, @@ -1834,9 +1834,9 @@ name = "nvidia-cusolver-cu12" version = "11.7.3.90" source = { registry = "https://pkgs.safetycli.com/repository/renzof/pypi/simple/" } dependencies = [ - { name = "nvidia-cublas-cu12" }, - { name = "nvidia-cusparse-cu12" }, - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-cublas-cu12", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cusparse-cu12", marker = "sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, @@ -1847,7 +1847,7 @@ name = "nvidia-cusparse-cu12" version = "12.5.8.93" source = { registry = "https://pkgs.safetycli.com/repository/renzof/pypi/simple/" } dependencies = [ - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, @@ -2062,7 +2062,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pkgs.safetycli.com/repository/renzof/pypi/simple/" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -2577,6 +2577,15 @@ wheels = [ { url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" }, ] +[[package]] +name = "python-toon" +version = "0.1.3" +source = { registry = "https://pkgs.safetycli.com/repository/renzof/pypi/simple/" } +sdist = { url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/4e/92/640c83ca46d5fe9c49895449a8932f55252537dd13dd22186cbac3a1ce59/python_toon-0.1.3.tar.gz", hash = "sha256:ca348b214c4f1cdad3579fd83dd60032d9eb87eb349c2d430ad9eb6371f174bf", size = 31280, upload-time = "2025-11-04T09:12:20.949Z" } +wheels = [ + { url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/26/a4/2f2def0378b44f913d2d6cb3bc5b1a15267b363937ab1cb9afb07ce2313c/python_toon-0.1.3-py3-none-any.whl", hash = "sha256:a27b0ee4a729e730d1037d0a63eb8b344b3e5a26e3dc9a173067b6c31a868ee6", size = 21797, upload-time = "2025-11-04T09:12:19.443Z" }, +] + [[package]] name = "pytz" version = "2025.2" @@ -2781,6 +2790,7 @@ dependencies = [ { name = "pydantic-settings" }, { name = "python-jose", extra = ["cryptography"] }, { name = "python-multipart" }, + { name = "python-toon" }, { name = "redis" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "structlog" }, @@ -2842,6 +2852,7 @@ requires-dist = [ { name = "pytest-xdist", marker = "extra == 'dev'" }, { name = "python-jose", extras = ["cryptography"] }, { name = "python-multipart" }, + { name = "python-toon" }, { name = "redis" }, { name = "rich", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" },