diff --git a/agents/default_agent.md b/agents/default_agent.md new file mode 100644 index 0000000..0a2dac1 --- /dev/null +++ b/agents/default_agent.md @@ -0,0 +1,88 @@ +# MASTER ORCHESTRATOR PERSONA + +You are the Master Orchestrator. Your role is to interpret high-level user requests, route commands, and coordinate with specialized task personas in the workspace. + +--- + +# SYSTEM RULES & CAPABILITIES + +You have access to virtual tools to manage workspace personas. You must invoke these tools by generating a valid structured `tool_call` object within your JSON response. + +## AVAILABLE TOOLS: + +### `activate_persona` +- **Description**: Switches the active workspace system prompt and hands off execution to a specialized persona. +- **Arguments**: + - `persona` (string, required): The target persona path to activate. Must be exactly one of the following: + - `"geoscaper/team/planner"` + - `"geoscaper/team/builder"` + - `"geoscaper/team/reviewer"` + +### `ask_user` +- **Description**: Suspends autonomous execution and asks the user a direct question or provides a conversational response. +- **Arguments**: + - `message` (string, required): The exact text message you want to show to the user. + +--- + +# DIRECTIVES & TRIGGER CRITERIA + +You must call `activate_persona` immediately under the following conditions: + +1. **Explicit Slash Commands or Keywords**: + - If the user types `/geoscape`, `/geoscaper`, or explicitly mentions `"geoscape"` / `"geoscaper"`: + - **Action**: Immediately invoke `activate_persona` with `"persona": "geoscaper/team/planner"`. +2. **High-Level Workspace Requests**: + - If the user requests to build, create, design, plan, audit, or initialize any website, landing page, showcase page, or web page: + - **Action**: Immediately invoke `activate_persona` with `"persona": "geoscaper/team/planner"`. + +--- + +# CRITICAL CONSTRAINTS (COMMUNICATION RULES) + +- **You MUST invoke a tool on every turn.** Do NOT output `tool_call: null`. +- If you need to respond to the user conversationally, or ask them a clarifying question, you MUST use the `ask_user` tool. Write your internal reasoning in `"thought"`, and put the actual message for the user in the `message` argument of the `ask_user` tool. +- When you call `activate_persona`, write a brief, direct confirmation of the action inside `"thought"`, then populate the `"tool_call"` object. + +--- + +# FEW-SHOT EXAMPLES + +### Example 1: User enters an explicit command +**User:** `/geoscape` +**Response:** +{ + "thought": "I am immediately calling the `activate_persona` tool to initialize your workspace and hand control over to the Geoscaper Planner.", + "tool_call": { + "name": "activate_persona", + "arguments": { + "persona": "geoscaper/team/planner" + } + } +} + +### Example 2: User requests a website build +**User:** "Can you help me design a landing page for my shop?" +**Response:** +{ + "thought": "I am activating the Geoscaper Planner persona to initialize your workspace and start planning your shop's landing page.", + "tool_call": { + "name": "activate_persona", + "arguments": { + "persona": "geoscaper/team/planner" + } + } +} + +### Example 3: General conversation with no tool trigger +**User:** "Hello, who are you and can you help me?" +**Response:** +{ + "thought": "The user is asking for my identity and capabilities. I will respond to them directly.", + "tool_call": { + "name": "ask_user", + "arguments": { + "message": "Hello! I am the Master Orchestrator. I coordinate specialized AI personas to design and build websites in this workspace. How can I help you today?" + } + } +} \ No newline at end of file diff --git a/agents/modules/geoscaper/geoscaper.py b/agents/modules/geoscaper/geoscaper.py new file mode 100755 index 0000000..e9f1d94 --- /dev/null +++ b/agents/modules/geoscaper/geoscaper.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +import sys +import json +import os +from lib.security import get_safe_path, PROJECTS_DIR, STATE_DIR +from lib.state import load_staging, save_staging, load_ledger, save_ledger +from lib.compiler import compile_page +from lib.auditor import run_audit + +def handle_tools_list(request_id): + """Responds to MCP tools/list requests with tool capability schema.""" + response = { + "jsonrpc": "2.0", + "result": { + "tools": [ + { + "name": "geoscaper", + "description": "Unified design-and-build automation server for managing staging tokens, compilation, and page auditing.", + "inputSchema": { + "type": "object", + "properties": { + "action": {"type": "string", "description": "The command action: init, update_staging, queue_task, compile, audit, write_file, read_file, read_template_manifest"}, + "project_name": {"type": "string", "description": "Name of the targeted website project"}, + "page_id": {"type": "string", "description": "The specific page being compiled or checked"}, + "data": {"type": "object", "description": "Staging configuration payload"}, + "task": {"type": "object", "description": "Task registration metadata"}, + "file_path": {"type": "string", "description": "Relative path to file (e.g. index.html, styles.css)"}, + "content": {"type": "string", "description": "Content to write to file"} + }, + "required": ["action", "project_name"] + } + } + ] + }, + "id": request_id + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + +def handle_tools_call(request_id, params): + """Executes target operations and returns outputs wrapped in MCP results.""" + name = params.get("name") + arguments = params.get("arguments", {}) + + if name != "geoscaper": + response = { + "jsonrpc": "2.0", + "error": {"code": -32601, "message": f"Method not found: {name}"}, + "id": request_id + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + action = arguments.get("action") + project_name = arguments.get("project_name") + + if not action or not project_name: + result_text = json.dumps({"status": "error", "reason": "Missing action or project_name"}) + else: + project_name = project_name.lower().replace('-', '_') + try: + if action == "init": + project_src_dir = get_safe_path(PROJECTS_DIR, project_name, "src") + project_dist_dir = get_safe_path(PROJECTS_DIR, project_name, "dist") + os.makedirs(project_src_dir, exist_ok=True) + os.makedirs(project_dist_dir, exist_ok=True) + + if not os.path.exists(get_safe_path(STATE_DIR, f"{project_name}_staging.json")): + save_staging(project_name, {"project_name": project_name, "style_tokens": {}}) + if not os.path.exists(get_safe_path(STATE_DIR, f"{project_name}_ledger.json")): + save_ledger(project_name, {"project_name": project_name, "task_queue": [], "hashes": {}, "failure_counts": {}}) + + result_text = json.dumps({"status": "success", "message": f"Initialized workspace: '{project_name}'"}) + + elif action == "update_staging": + staging = load_staging(project_name) or {} + staging.update(arguments.get("data", {})) + save_staging(project_name, staging) + result_text = json.dumps({"status": "success", "message": "Staging cache updated."}) + + elif action == "queue_task": + ledger = load_ledger(project_name) + task = arguments.get("task", {}) + if "page_id" in task and "filename" in task: + ledger.setdefault("task_queue", []).append(task) + save_ledger(project_name, ledger) + result_text = json.dumps({"status": "success", "message": f"Task '{task['page_id']}' queued."}) + else: + ledger.setdefault("dead_letter_queue", []).append(task) + save_ledger(project_name, ledger) + result_text = json.dumps({"status": "error", "reason": "Task must include 'page_id' and 'filename'. Malformed task appended to dead_letter_queue."}) + + elif action == "compile": + result = compile_page(project_name, arguments.get("page_id")) + result_text = json.dumps(result) + + elif action == "audit": + result = run_audit(project_name, arguments.get("page_id")) + result_text = json.dumps(result) + + elif action == "write_file": + file_path = arguments.get("file_path") + content = arguments.get("content", "") + if not file_path: + result_text = json.dumps({"status": "error", "reason": "Missing file_path"}) + elif not file_path.lower().endswith(('.html', '.css', '.js')): + result_text = json.dumps({"status": "error", "reason": "File extension not permitted. Allowed: .html, .css, .js"}) + else: + full_path = get_safe_path(PROJECTS_DIR, project_name, "src", file_path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + with open(full_path, "w", encoding="utf-8") as f: + f.write(content) + result_text = json.dumps({"status": "success", "message": f"Wrote {len(content)} bytes to {file_path}"}) + + elif action == "read_file": + file_path = arguments.get("file_path") + if not file_path: + result_text = json.dumps({"status": "error", "reason": "Missing file_path"}) + elif not file_path.lower().endswith(('.html', '.css', '.js')): + result_text = json.dumps({"status": "error", "reason": "File extension not permitted. Allowed: .html, .css, .js"}) + else: + full_path = get_safe_path(PROJECTS_DIR, project_name, "src", file_path) + if os.path.exists(full_path): + with open(full_path, "r", encoding="utf-8") as f: + content = f.read() + result_text = json.dumps({"status": "success", "content": content}) + else: + result_text = json.dumps({"status": "error", "reason": "File not found"}) + + elif action == "read_template_manifest": + manifest = { + "templates": [ + {"id": "landing_page", "description": "Standard SaaS landing page"}, + {"id": "portfolio", "description": "Creative portfolio"}, + {"id": "blog", "description": "Content-focused blog"} + ] + } + result_text = json.dumps({"status": "success", "manifest": manifest}) + + else: + result_text = json.dumps({"status": "error", "reason": f"Unknown action: {action}"}) + except Exception as e: + import traceback + sys.stderr.write(f"[GEOSCAPER TOOL ERROR] {action} failed: {str(e)}\n{traceback.format_exc()}\n") + sys.stderr.flush() + result_text = json.dumps({"status": "error", "reason": f"Execution error: {str(e)}"}) + + # Formats result to match standard MCP tools/call return envelope + response = { + "jsonrpc": "2.0", + "result": { + "content": [ + { + "type": "text", + "text": result_text + } + ] + }, + "id": request_id + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + +def main(): + """Persistent stdio event loop processing JSON-RPC packets.""" + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + payload = json.loads(line) + request_id = payload.get("id") + method = payload.get("method") + params = payload.get("params", {}) + + if method == "tools/list": + handle_tools_list(request_id) + elif method == "tools/call": + handle_tools_call(request_id, params) + else: + response = { + "jsonrpc": "2.0", + "error": {"code": -32601, "message": f"Method not found: {method}"}, + "id": request_id + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + except Exception as e: + import traceback + sys.stderr.write(f"[GEOSCAPER PARSE ERROR] {str(e)}\n{traceback.format_exc()}\n") + sys.stderr.flush() + response = { + "jsonrpc": "2.0", + "error": {"code": -32700, "message": f"Parse error: {str(e)}"}, + "id": None + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/agents/modules/geoscaper/lib/__init__.py b/agents/modules/geoscaper/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents/modules/geoscaper/lib/auditor.py b/agents/modules/geoscaper/lib/auditor.py new file mode 100644 index 0000000..9e25e7b --- /dev/null +++ b/agents/modules/geoscaper/lib/auditor.py @@ -0,0 +1,79 @@ +import os +import re +import hashlib +from html.parser import HTMLParser +from .security import get_safe_path, PROJECTS_DIR +from .state import load_ledger, update_failure_count + +class TagBalanceParser(HTMLParser): + def __init__(self): + super().__init__() + self.stack = [] + self.errors = [] + # Exclude 'p' and 'font' to avoid false positives on standard HTML5 tag-omissions + self.tracked_tags = {'table', 'tr', 'td', 'div', 'nav', 'header', 'footer', 'main', 'section'} + + def handle_starttag(self, tag, attrs): + if tag in self.tracked_tags: self.stack.append((tag, self.getpos())) + + def handle_endtag(self, tag): + if tag in self.tracked_tags: + if not self.stack: + self.errors.append(f"Orphaned near line {self.getpos()[0]}") + else: + last_open, pos = self.stack.pop() + if last_open != tag: + self.errors.append(f"Mismatched tag: <{last_open}> (L{pos[0]}) closed by (L{self.getpos()[0]})") + +def run_audit(project_name, page_id): + ledger = load_ledger(project_name) + task = next((t for t in ledger.get("task_queue", []) if t["page_id"] == page_id), None) + if not task: return {"status": "error", "reason": "Task not found."} + + dest_file = get_safe_path(PROJECTS_DIR, project_name, "dist", task["filename"]) + if not os.path.exists(dest_file): + return {"status": "error", "reason": "Compiled file not found on disk."} + + # 1. Hash Freshness Check + with open(dest_file, "r", encoding="utf-8") as f: + disk_content = f.read() + + disk_hash = hashlib.sha256(disk_content.encode('utf-8')).hexdigest() + ledger_hash = ledger.get("hashes", {}).get(page_id, "") + if disk_hash != ledger_hash: + return {"status": "error", "reason": "Security Fault: Disk hash does not match Ledger hash. Stale or tampered file."} + + errors = [] + + # 2. Tag Balance Audit + parser = TagBalanceParser() + try: + parser.feed(disk_content) + errors.extend(parser.errors) + if parser.stack: + for unclosed, pos in parser.stack: + errors.append(f"Unclosed <{unclosed}> near line {pos[0]}") + except Exception as e: + errors.append(f"Parser failure: {str(e)}") + + # 3. Link Matrix Audit + allowed_files = {t["filename"] for t in ledger.get("task_queue", [])} + links = re.findall(r'href=["\']([^"\']+)["\']', disk_content, re.IGNORECASE) + for link in links: + if not link.startswith(("http", "https", "mailto:", "#")) and link not in allowed_files: + errors.append(f"Dead Link Found: '{link}' is not in the project task queue.") + + # 4. Three-Strike Circuit Breaker + if errors: + strikes = update_failure_count(project_name, page_id, increment=True) + if strikes >= 3: + return { + "status": "circuit_breaker", + "reason": f"Page '{page_id}' failed structural audit 3 consecutive times. Escalating to human.", + "errors": errors + } + return {"status": "error", "strikes": strikes, "errors": errors} + + # Reset strikes on success + update_failure_count(project_name, page_id, increment=False) + return {"status": "success", "message": "Audit passed. File is structurally sound."} \ No newline at end of file diff --git a/agents/modules/geoscaper/lib/compiler.py b/agents/modules/geoscaper/lib/compiler.py new file mode 100644 index 0000000..f708b2a --- /dev/null +++ b/agents/modules/geoscaper/lib/compiler.py @@ -0,0 +1,61 @@ +import os +import hashlib +from .security import get_safe_path, PROJECTS_DIR +from .state import load_staging, load_ledger, save_ledger +import sys + +def compile_page(project_name, page_id): + staging = load_staging(project_name) + ledger = load_ledger(project_name) + + task = next((t for t in ledger.get("task_queue", []) if t["page_id"] == page_id), None) + if not task: + return {"status": "error", "reason": f"Task '{page_id}' not found in task_queue."} + + # 1. Source File Reader + src_file = get_safe_path(PROJECTS_DIR, project_name, "src", task["filename"]) + if not os.path.exists(src_file): + err_msg = f"Source file '{task['filename']}' not found in src directory." + print(f"[Compiler] Error: {err_msg}", file=sys.stderr) + return {"status": "error", "reason": err_msg} + + with open(src_file, "r", encoding="utf-8") as bf: + content_html = bf.read() + + # 2. Assemble Document + styles = staging.get("style_tokens", {}) + full_document = f""" + + + {task.get('title', 'Project Component')} + + + +{content_html} + +""" + + # 3. Write & Hash + dest_dir = get_safe_path(PROJECTS_DIR, project_name, "dist") + os.makedirs(dest_dir, exist_ok=True) # Create nested build directories inside the module workspace safely + + dest_file = get_safe_path(PROJECTS_DIR, project_name, "dist", task["filename"]) + os.makedirs(os.path.dirname(dest_file), exist_ok=True) + with open(dest_file, "w", encoding="utf-8") as f: + f.write(full_document) + + file_hash = hashlib.sha256(full_document.encode('utf-8')).hexdigest() + + # 4. Update Ledger + hashes = ledger.get("hashes", {}) + hashes[page_id] = file_hash + ledger["hashes"] = hashes + save_ledger(project_name, ledger) + + return {"status": "success", "message": f"Compiled and hashed '{page_id}'", "hash": file_hash} \ No newline at end of file diff --git a/agents/modules/geoscaper/lib/security.py b/agents/modules/geoscaper/lib/security.py new file mode 100644 index 0000000..3cbacf2 --- /dev/null +++ b/agents/modules/geoscaper/lib/security.py @@ -0,0 +1,18 @@ +import os + +# Resolve the geoscaper module root directory +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) # agents/modules/geoscaper/lib +GEOSCAPER_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, "..")) # agents/modules/geoscaper + +# Keep all operations self-contained within geoscaper directory tree +STATE_DIR = os.path.join(GEOSCAPER_DIR, "state") +PROJECTS_DIR = os.path.join(GEOSCAPER_DIR, "projects") + +def get_safe_path(base_dir, *path_parts): + """Resolves and validates paths to enforce strict sandbox constraints.""" + real_base = os.path.realpath(base_dir) + real_target = os.path.realpath(os.path.join(real_base, *path_parts)) + + if not real_target.startswith(real_base + os.path.sep) and real_target != real_base: + raise PermissionError(f"Security Fault: Path '{real_target}' escaped '{real_base}'") + return real_target \ No newline at end of file diff --git a/agents/modules/geoscaper/lib/state.py b/agents/modules/geoscaper/lib/state.py new file mode 100644 index 0000000..9d84463 --- /dev/null +++ b/agents/modules/geoscaper/lib/state.py @@ -0,0 +1,47 @@ +import json +import os +from .security import get_safe_path, STATE_DIR + +def _load_json(filename): + try: + path = get_safe_path(STATE_DIR, filename) + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return {} + +def _save_json(filename, data): + os.makedirs(os.path.realpath(STATE_DIR), exist_ok=True) + path = get_safe_path(STATE_DIR, filename) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + +# --- Staging Cache (Planner) --- +def load_staging(project_name): + return _load_json(f"{project_name}_staging.json") + +def save_staging(project_name, data): + _save_json(f"{project_name}_staging.json", data) + +# --- Production Ledger (Builder/Reviewer) --- +def load_ledger(project_name): + return _load_json(f"{project_name}_ledger.json") + +def save_ledger(project_name, data): + _save_json(f"{project_name}_ledger.json", data) + +def update_failure_count(project_name, page_id, increment=True): + ledger = load_ledger(project_name) + failures = ledger.get("failure_counts", {}) + current = failures.get(page_id, 0) + + if increment: + failures[page_id] = current + 1 + else: + failures[page_id] = 0 + + ledger["failure_counts"] = failures + save_ledger(project_name, ledger) + return failures[page_id] \ No newline at end of file diff --git a/agents/modules/geoscaper/team/builder.md b/agents/modules/geoscaper/team/builder.md new file mode 100644 index 0000000..0797ee5 --- /dev/null +++ b/agents/modules/geoscaper/team/builder.md @@ -0,0 +1,49 @@ +# GEOSCAPER BUILDER PERSONA + +You are the Builder. Your goal is to generate clean, highly styled, responsive HTML code and trigger the compilation tool. You must infer the current `project_name` and `page_id` from the previous conversation history left by the Planner. + +## AVAILABLE TOOLS: +You have access to virtual tools. You must invoke these tools by generating a valid structured `tool_call` object within your JSON response. + +### `geoscaper` +- **Description**: Unified design-and-build automation server. +- **Arguments**: + - `action` (string, required): The command action. Must be `"compile"`, `"write_file"`, or `"read_file"`. + - `project_name` (string, required): Name of the targeted website project. + - `page_id` (string, optional): The specific page being compiled. + - `file_path` (string, optional): Relative path for file operations (e.g., `index.html`). + - `content` (string, optional): The code to write to the file. + +### `activate_persona` +- **Description**: Switches the active workspace system prompt and hands off execution to a specialized persona. +- **Arguments**: + - `persona` (string, required): The target persona path to activate. Must be `"geoscaper/team/reviewer"`. + +## PIPELINE STEPS (Perform one action per turn): +1. **Write Code:** + - Generate the complete, responsive webpage HTML structure or CSS/JS styles. + - Call the `geoscaper` tool with action `"write_file"` to save it. Use `"file_path": "index.html"` (or appropriate filename) and put the code in the `"content"` argument. +2. **Compile Project:** + - After writing the necessary files, call the `geoscaper` tool with action `"compile"`, providing the correct `project_name` and `page_id` from the context. +3. **Handoff to Reviewer:** + - Once compiled successfully, call the `activate_persona` tool with argument `"persona": "geoscaper/team/reviewer"`. + +## FORMAT RULES & CRITICAL CONSTRAINTS: +- Output must strictly match GBNF JSON grammar. +- Do not use XML tags (like ) inside the JSON "thought" property. Keep it flat. +- Never write text outside of the JSON block. +- **CRITICAL**: You MUST invoke a tool on every turn. Do NOT explain what you are going to do first and leave tool_call as null. Immediately populate the "tool_call" object with your action. + +## EXPECTED OUTPUT (Step 1): +{ + "thought": "I have created the responsive page code. Writing it to index.html now.", + "tool_call": { + "name": "geoscaper", + "arguments": { + "action": "write_file", + "project_name": "", + "file_path": "index.html", + "content": "\\n\\nProject\\n\\n

Welcome

\\n\\n" + } + } +} \ No newline at end of file diff --git a/agents/modules/geoscaper/team/planner.md b/agents/modules/geoscaper/team/planner.md new file mode 100644 index 0000000..7664f07 --- /dev/null +++ b/agents/modules/geoscaper/team/planner.md @@ -0,0 +1,76 @@ +# GEOSCAPER PLANNER PERSONA + +You are the Planner. Your goal is to interview the user to collect project requirements, initialize the project workspace, and coordinate style setup. + +## AVAILABLE TOOLS: +You have access to virtual tools. You must invoke these tools by generating a valid structured `tool_call` object within your JSON response. + +### `geoscaper` +- **Description**: Unified design-and-build automation server. +- **Arguments**: + - `action` (string, required): The command action: `init`, `update_staging`, `queue_task` + - `project_name` (string, required): Name of the targeted website project + - `data` (object, optional): Staging configuration payload + - `task` (object, optional): Task registration metadata + +### `activate_persona` +- **Description**: Switches the active workspace system prompt and hands off execution to a specialized persona. +- **Arguments**: + - `persona` (string, required): The target persona path to activate. Must be `"geoscaper/team/builder"`. + +### `ask_user` +- **Description**: Suspends autonomous execution and asks the user a direct question or provides a conversational response. +- **Arguments**: + - `message` (string, required): The exact text message you want to show to the user. + +## PIPELINE STEPS (Follow these sequentially): + +**Phase 1: Formulate Plan & Interview** +- You must interview the user to collect the project name, purpose, and desired styles. +- **IMPORTANT**: If the user says "you choose" or "you name it", creatively invent a suitable project name and style. You must then IMMEDIATELY proceed to Phase 2 by outputting the `geoscaper` tool call. +- When you DO need to ask a question, use the `ask_user` tool and provide your conversational question in the `message` argument. + +**Phase 2: Initialize Workspace** +- Once the user provides the project name (e.g. "my_shop"), call the `geoscaper` tool with action `init` and the chosen `project_name`. + +**Phase 3: Configure Style Tokens** +- After initialization, call the `geoscaper` tool with action `update_staging` to set styles based on user preferences. +- Example: `"data": {"style_tokens": {"background_color": "#000000", "text_color": "#FFFFFF", "font_family": "system-ui"}}` + +**Phase 4: Queue Landing Page Task** +- Call the `geoscaper` tool with action `queue_task` to queue the home page. +- Example: `"task": {"page_id": "home", "filename": "index.html", "title": "Home Page"}` + +**Phase 5: Handoff to Builder** +- Call the `activate_persona` tool with argument `"persona": "geoscaper/team/builder"`. + +## FORMAT RULES & CRITICAL CONSTRAINTS: +- Output must strictly match GBNF JSON grammar. +- No XML tags (like ) inside the JSON "thought" property. Keep it flat. +- Never write text outside of the JSON block. +- **CRITICAL**: You MUST invoke a tool on every turn. Do NOT explain what you are going to do first and leave tool_call as null. Immediately populate the "tool_call" object with your action. +- **Sequential Guardrails**: You must execute tool calls linearly. DO NOT combine them. For instance, do not pass a `data` payload inside an `init` action. Execute `init` first, wait for the result, then execute `update_staging`. +- **System Tag Handling**: If you receive a `[SYSTEM: Tool Execution Result]`, you MUST immediately output the tool call for the next Phase autonomously. DO NOT generate conversational filler thanking the user for the result. + +## EXPECTED OUTPUT (Phase 1): +{ + "thought": "I need to ask the user for the project name and purpose.", + "tool_call": { + "name": "ask_user", + "arguments": { + "message": "Welcome! I can help you build your website. To get started, what would you like the project to be named, and what is its main purpose?" + } + } +} + +## EXPECTED OUTPUT (Phase 2): +{ + "thought": "Initializing the workspace for the requested project.", + "tool_call": { + "name": "geoscaper", + "arguments": { + "action": "init", + "project_name": "my_shop" + } + } +} \ No newline at end of file diff --git a/agents/modules/geoscaper/team/reviewer.md b/agents/modules/geoscaper/team/reviewer.md new file mode 100644 index 0000000..00fddc3 --- /dev/null +++ b/agents/modules/geoscaper/team/reviewer.md @@ -0,0 +1,50 @@ +# GEOSCAPER REVIEWER PERSONA + +You are the Reviewer. Your goal is to audit the compiled pages for quality, link health, and structure. You must infer the current `project_name` and `page_id` from the previous conversation history. + +## AVAILABLE TOOLS: +You have access to virtual tools. You must invoke these tools by generating a valid structured `tool_call` object within your JSON response. + +### `geoscaper` +- **Description**: Unified design-and-build automation server. +- **Arguments**: + - `action` (string, required): The command action. Must be `"audit"` or `"read_file"`. + - `project_name` (string, required): Name of the targeted website project. + - `page_id` (string, optional): The specific page being compiled. + - `file_path` (string, optional): Relative path for file operations. + +### `activate_persona` +- **Description**: Switches the active workspace system prompt and hands off execution to a specialized persona. +- **Arguments**: + - `persona` (string, required): The target persona path to activate. Must be `"geoscaper/team/builder"`. + +### `deactivate_persona` +- **Description**: Restores the Master Orchestrator persona and ends your execution turn. +- **Arguments**: + - `none`: This tool does not take any arguments. Use `{}`. + +## PIPELINE STEPS (Perform one action per turn): +1. **Audit Compiled Page:** + - Call the `geoscaper` tool with action "audit", using the correct `project_name` and `page_id` inferred from context. +2. **Evaluate Audit Results:** + - If the audit output status is "success", return control to the Master Orchestrator by calling the `deactivate_persona` tool. + - If the audit output status contains errors or a circuit breaker, pass control back to the Builder by calling the `activate_persona` tool with argument "persona": "geoscaper/team/builder". + +## FORMAT RULES & CRITICAL CONSTRAINTS: +- Output must strictly match GBNF JSON grammar. +- Do not use XML tags (like ) inside the JSON "thought" property. Keep it flat. +- Never write text outside of the JSON block. +- **CRITICAL**: You MUST invoke a tool on every turn. Do NOT explain what you are going to do first and leave tool_call as null. Immediately populate the "tool_call" object with your action. + +## EXPECTED OUTPUT (Step 1): +{ + "thought": "Performing the structural and quality check on the compiled page.", + "tool_call": { + "name": "geoscaper", + "arguments": { + "action": "audit", + "project_name": "", + "page_id": "" + } + } +} \ No newline at end of file diff --git a/core/smarterframework.py b/core/smarterframework.py new file mode 100644 index 0000000..6c44803 --- /dev/null +++ b/core/smarterframework.py @@ -0,0 +1,761 @@ +#!/usr/bin/env python3 +import asyncio +import json +import os +import sys +import re +import atexit +from collections import deque +from typing import Dict, Any, Optional, List, AsyncGenerator +import httpx + +# --- Target Configurations from Shell Environment --- +BACKEND_URL = os.environ.get("AMDA_BACKEND_URL", "http://127.0.0.1:8080") +SYSTEM_PROMPT_PATH = os.environ.get("AMDA_SYSTEM_PROMPT", "agents/default_agent.md") +GRAMMAR_FILE_PATH = os.environ.get("AMDA_GRAMMAR_FILE", "tools/tool_rules.gbnf") + +MAX_TOKENS = 1024 +CONTEXT_LIMIT = 16384 +SAFETY_THRESHOLD = int(CONTEXT_LIMIT * 0.80) +DEFAULT_MAX_TURNS = 5 +TERM_WIDTH = 85 + +# --- ANSI Styling Palette --- +CYAN = "\033[96m" +GREEN = "\033[92m" +YELLOW = "\033[93m" +RED = "\033[91m" +RESET = "\033[0m" +BOLD = "\033[1m" +DIM = "\033[2m" +CLEAR = "\033[2J\033[H" +DIVIDER = f"{DIM}{'─' * TERM_WIDTH}{RESET}" + +# --- Specialist Persona Mapping --- +PERSONA_MAP = { + "geoscaper/team/planner": "agents/modules/geoscaper/team/planner.md", + "geoscaper/team/builder": "agents/modules/geoscaper/team/builder.md", + "geoscaper/team/reviewer": "agents/modules/geoscaper/team/reviewer.md", +} + +# --- System-level Virtual Instructions for Master Orchestrator --- +MASTER_ORCHESTRATOR_INSTRUCTION = ( + "\n\n[SYSTEM INSTRUCTION] You are the Master Orchestrator. " + "Your job is to route user requests to the specialized agent personas. " + "To do this, use the 'activate_persona' virtual tool when needed.\n" + "Available personas:\n" + "- 'geoscaper/team/planner': For strategic project planning, staging config, and task queuing.\n" + "- 'geoscaper/team/builder': For compiling HTML pages and generating styles/code.\n" + "- 'geoscaper/team/reviewer': For running quality assurance and structure audits.\n\n" + "To call a persona, output a valid JSON conforming to the grammar:\n" + "{\n" + " \"thought\": \"Your internal explanation for choosing this persona.\",\n" + " \"tool_call\": {\n" + " \"name\": \"activate_persona\",\n" + " \"arguments\": {\"persona\": \"geoscaper/team/planner\"}\n" + " }\n" + "}\n" + "Always state in your 'thought' when you want to activate or hand off to a persona.\n" + "If you need to speak directly to the user, you MUST use the 'ask_user' tool." +) + +class JSONStreamFilter: + """Pass-through streaming filter that simply unescapes the 'thought' field.""" + def __init__(self, target_key: str = "thought"): + self.buffer = "" + self.in_value = False + self.escaped = False + self.target_pattern = f'"{target_key}"' + + def feed(self, delta: str) -> str: + if not delta: + return "" + self.buffer += delta + if not self.in_value: + key_idx = self.buffer.find(self.target_pattern) + if key_idx != -1: + colon_idx = self.buffer.find(':', key_idx) + if colon_idx != -1: + quote_idx = self.buffer.find('"', colon_idx) + if quote_idx != -1: + self.in_value = True + remaining = self.buffer[quote_idx + 1:] + self.buffer = "" + return self._process_chars(remaining) + return "" + else: + return self._process_chars(delta) + + def _process_chars(self, text: str) -> str: + output = [] + for char in text: + if self.escaped: + if char == 'n': output.append('\n') + elif char == 't': output.append('\t') + elif char == 'r': output.append('\r') + else: output.append(char) + self.escaped = False + elif char == '\\': + self.escaped = True + elif char == '"': + self.in_value = False + break + else: + output.append(char) + return "".join(output) + +def extract_json_from_text(text: str) -> Optional[Dict[str, Any]]: + """Extracts and self-heals incomplete or malformed JSON from LLM output.""" + cleaned = text.strip() + if not cleaned: + return None + + if cleaned.startswith("```json"): + cleaned = cleaned[7:] + elif cleaned.startswith("```"): + cleaned = cleaned[3:] + if cleaned.endswith("```"): + cleaned = cleaned[:-3] + + cleaned = cleaned.strip() + start = cleaned.find('{') + if start == -1: + return None + + json_str = cleaned[start:] + repaired_chars = [] + in_string = False + escape = False + brace_stack = [] + + for char in json_str: + if escape: + repaired_chars.append(char) + escape = False + continue + if char == '\\': + repaired_chars.append(char) + escape = True + continue + if char == '"': + in_string = not in_string + repaired_chars.append(char) + continue + if in_string: + if char == '\n': + repaired_chars.append('\\n') + elif char == '\t': + repaired_chars.append('\\t') + else: + repaired_chars.append(char) + continue + + if char == '{': + brace_stack.append('}') + elif char == '[': + brace_stack.append(']') + elif char == '}': + if brace_stack and brace_stack[-1] == '}': + brace_stack.pop() + else: + continue + elif char == ']': + if brace_stack and brace_stack[-1] == ']': + brace_stack.pop() + else: + continue + repaired_chars.append(char) + + if in_string: + repaired_chars.append('"') + while brace_stack: + repaired_chars.append(brace_stack.pop()) + healed_str = "".join(repaired_chars) + + try: + return json.loads(healed_str, strict=False) + except json.JSONDecodeError: + try: + thought_match = re.search(r'"thought"\s*:\s*"((?:[^"\\]|\\.)*)"', healed_str, re.DOTALL) + if thought_match: + thought_content = thought_match.group(1).replace('\\n', '\n') + tool_match = re.search(r'"tool_call"\s*:\s*\{\s*"name"\s*:\s*"([^"]+)"\s*,\s*"arguments"\s*:\s*(\{.*?\}\s*\})', healed_str, re.DOTALL) + tool_call = None + if tool_match: + try: + tool_call = { + "name": tool_match.group(1), + "arguments": json.loads(tool_match.group(2), strict=False) + } + except Exception: + pass + return {"thought": thought_content, "tool_call": tool_call} + except Exception: + pass + return None + +async def count_tokens(client: httpx.AsyncClient, text: str) -> int: + """Queries llama-server's native /tokenize endpoint to count tokens precisely.""" + try: + response = await client.post(f"{BACKEND_URL}/tokenize", json={"content": text}) + if response.status_code == 200: + tokens = response.json().get("tokens", []) + return len(tokens) + except Exception: + pass + # Safe fallback estimation (roughly 4 characters per token) + return max(1, len(text) // 4) + +def log_telemetry(tag: str, content: str): + """Appends diagnostic information to the telemetry log without spamming stdout.""" + try: + log_dir = "core/logs" + os.makedirs(log_dir, exist_ok=True) + with open(os.path.join(log_dir, "telemetry.log"), "a", encoding="utf-8") as lf: + lf.write(f"[{tag}] {content}\n" + "="*50 + "\n") + except Exception: + pass + + +class CPUIntentRouter: + def __init__(self, model_name: str = "nomic-ai/nomic-embed-text-v1.5"): + self.model = None + self.model_name = model_name + self.prototypes = { + "geoscaper/team/planner": [ + "initialize workspace project", + "set up the design and staging configurations", + "queue landing page task", + "create home page and set background styles", + "setup showcase website and launch planner" + ], + "geoscaper/team/builder": [ + "compile the home page and generate html", + "write responsive html code structure", + "compile showcase project", + "builder compile index.html", + "build showcase webpage" + ], + "geoscaper/team/reviewer": [ + "audit compiled home page", + "run structural check and tag balance check", + "verify dead links and page validation", + "quality and structural audit check" + ] + } + self.prototype_embeddings = {} + + def lazy_init(self): + """Lazy load sentence-transformers on CPU to preserve Vulkan resources for 14B model.""" + if self.model is not None: + return + try: + import numpy as np + from sentence_transformers import SentenceTransformer + print(f"\n{CYAN}[Init] Loading CPU embedding model: {self.model_name}...{RESET}") + self.model = SentenceTransformer(self.model_name, trust_remote_code=True, device="cpu") + + for persona, sentences in self.prototypes.items(): + # Formulate search document input prefix for nomic-embed-text + inputs = [f"search_document: {s}" for s in sentences] + embeddings = self.model.encode(inputs, convert_to_numpy=True) + # L2 normalize + norms = np.linalg.norm(embeddings, axis=1, keepdims=True) + self.prototype_embeddings[persona] = embeddings / np.maximum(norms, 1e-12) + print(f"{GREEN}[Ready] CPU Embedding model initialized.{RESET}") + except Exception as e: + # Handle Fedora/RedHat systems missing ctypes gracefully + if "No module named '_ctypes'" in str(e): + print(f"\n{YELLOW}[Diagnostic] CPU embedding router skipped: Missing '_ctypes' standard module.{RESET}") + print(f" On Fedora/RedHat, you can fix this by running:") + print(f" {BOLD}sudo dnf install libffi-devel{RESET}") + print(f" and then rebuilding/reinstalling your Python binary or environment (e.g., pyenv).") + else: + print(f"\n{RED}[Warn] Could not load CPU embedding router: {str(e)}{RESET}") + self.model = False + + def route_intent(self, user_query: str, threshold: float = 0.70) -> Optional[str]: + self.lazy_init() + if not self.model or not self.prototype_embeddings: + # Fallback to simple keyword matching if sentence-transformers is missing + user_lower = user_query.lower() + if any(x in user_lower for x in ["plan", "design", "workspace"]): + return "geoscaper/team/planner" + if any(x in user_lower for x in ["build", "compile", "html"]): + return "geoscaper/team/builder" + if any(x in user_lower for x in ["audit", "review", "check"]): + return "geoscaper/team/reviewer" + return None + + import numpy as np + try: + query_input = f"search_query: {user_query}" + query_emb = self.model.encode([query_input], convert_to_numpy=True)[0] + q_norm = np.linalg.norm(query_emb) + if q_norm > 0: + query_emb = query_emb / q_norm + + best_persona = None + best_score = -1.0 + + for persona, proto_embs in self.prototype_embeddings.items(): + scores = np.dot(proto_embs, query_emb) + max_score = np.max(scores) + if max_score > best_score: + best_score = max_score + best_persona = persona + + if best_score >= threshold: + return best_persona + except Exception: + pass + return None + +class ActiveAgentState: + def __init__(self): + self.client = httpx.AsyncClient(timeout=120.0) + self.grammar = None + self.message_history = deque() + self.mcp_server_process = None + self.jsonrpc_id = 1 + self.current_persona = "orchestrator" # <-- Track the active persona + + # Self-healing GBNF initialization (forced overwrite to ensure strict mode) + try: + os.makedirs(os.path.dirname(GRAMMAR_FILE_PATH) or ".", exist_ok=True) + with open(GRAMMAR_FILE_PATH, "w") as f: + f.write("""root ::= object +object ::= "{" ws "\\"thought\\":" ws string "," ws "\\"tool_call\\":" ws tool-call ws "}" +tool-call ::= "{" ws "\\"name\\":" ws string "," ws "\\"arguments\\":" ws tool-args ws "}" +tool-args ::= "{" ws ( pair ("," ws pair)* )? ws "}" +pair ::= string ws ":" ws value +value ::= string | number | "true" | "false" | "null" | tool-args | array +array ::= "[" ws ( value ("," ws value)* )? ws "]" +string ::= "\\"" ([^"\\\\\\x00-\\x1F] | "\\\\" (["\\\\/bfnrt] | "u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F]))* "\\"" +number ::= "-"? ([0-9]+ | [0-9]* "." [0-9]+) ([eE] [+-]? [0-9]+)? +ws ::= [ \\t\\n\\r]* +""") + print(f"{GREEN}[Init] Dynamically recovered missing GBNF file at: {GRAMMAR_FILE_PATH}{RESET}") + except Exception as e: + print(f"{RED}[Init] Failed to bootstrap GBNF file: {str(e)}{RESET}") + + if os.path.exists(GRAMMAR_FILE_PATH): + with open(GRAMMAR_FILE_PATH, "r") as f: + self.grammar = f.read() + + if os.path.exists(SYSTEM_PROMPT_PATH): + with open(SYSTEM_PROMPT_PATH, "r") as f: + self.system_prompt = f.read().strip() + else: + self.system_prompt = "You are a helpful assistant. Output must strictly match GBNF JSON grammar." + + # Inject Master Orchestrator persona knowledge + self.system_prompt += MASTER_ORCHESTRATOR_INSTRUCTION + self.message_history.append({"role": "system", "content": self.system_prompt}) + atexit.register(self.shutdown_mcp_server) + + async def _drain_mcp_stderr(self, process: asyncio.subprocess.Process): + """Asynchronously drains the stderr of the MCP subprocess to prevent buffer-blocking.""" + try: + while True: + line = await process.stderr.readline() + if not line: + break + decoded = line.decode('utf-8', errors='replace').strip() + if decoded: + print(f"{DIM}{RED}[MCP STDERR] {decoded}{RESET}") + log_telemetry("MCP_STDERR", decoded) + except Exception as e: + log_telemetry("MCP_STDERR_CRASH", str(e)) + + async def launch_mcp_server(self) -> bool: + """Spawns the long-running geoscaper MCP server subprocess over persistent pipes.""" + server_path = None + script_dir = os.path.dirname(os.path.abspath(__file__)) + workspace_root = os.getcwd() + + # Build comprehensive resolution path checklist + candidate_paths = [ + os.path.join(workspace_root, "tools", "geoscaper.py"), + os.path.join(workspace_root, "agents", "tools", "geoscaper.py"), + os.path.join(workspace_root, "agents", "modules", "geoscaper", "geoscaper.py"), + os.path.join(workspace_root, "geoscaper.py"), + os.path.join(workspace_root, "core", "geoscaper.py"), + os.path.join(script_dir, "geoscaper.py"), + os.path.join(script_dir, "tools", "geoscaper.py"), + os.path.join(script_dir, "..", "tools", "geoscaper.py"), + os.path.join(script_dir, "..", "agents", "tools", "geoscaper.py"), + os.path.join(script_dir, "..", "agents", "modules", "geoscaper", "geoscaper.py"), + ] + + # Deduplicate paths keeping order + seen = set() + deduped_candidates = [] + for p in candidate_paths: + normalized = os.path.normpath(p) + if normalized not in seen: + seen.add(normalized) + deduped_candidates.append(normalized) + + for p in deduped_candidates: + if os.path.exists(p): + server_path = p + break + + if not server_path: + print(f"{RED}[ERR] MCP server binary 'geoscaper.py' not found in workspace.{RESET}") + print(f"{DIM}Searched the following locations:{RESET}") + for p in deduped_candidates: + print(f"{DIM} - {p}{RESET}") + return False + + try: + # Set up environment variables so subprocess can resolve local modules like lib.* + env = os.environ.copy() + additional_paths = [workspace_root, os.path.dirname(server_path)] + if "PYTHONPATH" in env: + env["PYTHONPATH"] = os.pathsep.join(additional_paths) + os.pathsep + env["PYTHONPATH"] + else: + env["PYTHONPATH"] = os.pathsep.join(additional_paths) + + self.mcp_server_process = await asyncio.create_subprocess_exec( + sys.executable, server_path, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env + ) + # Route stderr through background task to prevent deadlock + asyncio.create_task(self._drain_mcp_stderr(self.mcp_server_process)) + return True + except Exception as e: + print(f"{RED}[ERR] Failed to boot persistent MCP tool server: {str(e)}{RESET}") + return False + + async def call_mcp_tool(self, tool_name: str, arguments: Dict[str, Any]) -> str: + """Sends a JSON-RPC 2.0 tools/call request to the persistent MCP server and receives response.""" + if not self.mcp_server_process or self.mcp_server_process.returncode is not None: + await self.launch_mcp_server() + + self.jsonrpc_id += 1 + request = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments + }, + "id": self.jsonrpc_id + } + + try: + payload = json.dumps(request) + "\n" + self.mcp_server_process.stdin.write(payload.encode('utf-8')) + await self.mcp_server_process.stdin.drain() + + response = None + for _ in range(50): # Bounded retry to skip arbitrary stdout debug prints + stdout_line = await self.mcp_server_process.stdout.readline() + if not stdout_line: + return json.dumps({"status": "error", "reason": "MCP tool server disconnected abruptly."}) + + try: + parsed = json.loads(stdout_line.decode('utf-8').strip()) + if "jsonrpc" in parsed and parsed.get("id") == request["id"]: + response = parsed + break + except json.JSONDecodeError: + continue + + if not response: + return json.dumps({"status": "error", "reason": "Failed to parse valid JSON-RPC response from MCP server."}) + + if "error" in response: + return json.dumps({"status": "error", "reason": response["error"].get("message")}) + + content_items = response.get("result", {}).get("content", []) + for item in content_items: + if item.get("type") == "text": + return item.get("text") + + return json.dumps({"status": "error", "reason": "Invalid MCP response envelope."}) + + except Exception as e: + return json.dumps({"status": "error", "reason": f"MCP communication error: {str(e)}"}) + + def shutdown_mcp_server(self): + """Teardown hook ensuring the background MCP server process is killed.""" + if self.mcp_server_process and self.mcp_server_process.returncode is None: + try: + self.mcp_server_process.terminate() + except Exception: + pass + + async def budget(self): + while True: + total_tokens = 0 + for msg in self.message_history: + if "_token_count" not in msg: + msg["_token_count"] = await count_tokens(self.client, msg["content"]) + total_tokens += msg["_token_count"] + if total_tokens <= SAFETY_THRESHOLD or len(self.message_history) <= 3: + break + if len(self.message_history) >= 4: + sys_msg = self.message_history.popleft() + self.message_history.popleft() + if self.message_history and self.message_history[0]["role"] == "assistant": + self.message_history.popleft() + self.message_history.appendleft(sys_msg) + + async def infer(self) -> AsyncGenerator[Dict[str, Any], None]: + clean_history = [{"role": m["role"], "content": m["content"]} for m in self.message_history] + payload = { + "messages": clean_history, + "max_tokens": MAX_TOKENS, + "stream": True, + "temperature": 0.2, + } + if self.grammar: + payload["grammar"] = self.grammar + + full_buffer = "" + try: + async with self.client.stream("POST", f"{BACKEND_URL}/v1/chat/completions", json=payload) as response: + if response.status_code != 200: + await response.aread() + yield {"type": "error", "raw": f"HTTP {response.status_code}"} + return + + is_streaming = "text/event-stream" in response.headers.get("content-type", "").lower() + if is_streaming: + async for line in response.aiter_lines(): + line = line.strip() + if not line or not line.startswith("data:"): continue + data_str = line[5:].strip() + if data_str == "[DONE]": continue + try: + chunk = json.loads(data_str) + delta = chunk["choices"][0]["delta"].get("content") or chunk["choices"][0]["delta"].get("reasoning_content") or "" + full_buffer += delta + yield {"type": "token", "delta": delta} + except Exception: + continue + else: + await response.aread() + try: + result = response.json() + content = result["choices"][0]["message"].get("content") or result["choices"][0]["message"].get("reasoning_content") or "" + full_buffer = content + yield {"type": "token", "delta": content} + except Exception as e: + yield {"type": "error", "raw": str(e)} + return + except Exception as e: + yield {"type": "error", "raw": str(e)} + return + + parsed_json = extract_json_from_text(full_buffer) + if parsed_json is not None: + yield {"type": "final", "data": parsed_json, "raw": full_buffer} + else: + yield {"type": "error", "raw": "JSON Parse Failure", "raw_buffer": full_buffer} + + async def close(self): + await self.client.aclose() + self.shutdown_mcp_server() + + +async def async_input(prompt_string: str) -> str: + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, input, prompt_string) + + +async def app_loop(): + engine = ActiveAgentState() + + print(f"{CYAN}[Init] Spawning persistent Model Context Protocol (MCP) server...{RESET}") + if not await engine.launch_mcp_server(): + print(f"{RED}[ERR] Tool engine aborted due to MCP failure.{RESET}") + await engine.close() + return + + try: + sys.stdout.write(CLEAR) + print(f"{CYAN}{BOLD}=== Model Context Protocol TUI Shell ==={RESET}") + print(f"{DIM}Transport: Persistent STDIO | RPC: JSON-RPC 2.0{RESET}") + print(DIVIDER) + + router = CPUIntentRouter() + + while True: + user_input = await async_input(f"{YELLOW}{BOLD}User >{RESET} ") + if user_input.lower() in ["exit", "quit", "q"]: break + if not user_input.strip(): continue + + # Semantic Intent Routing Check + routed_persona = router.route_intent(user_input, threshold=0.72) + if routed_persona and engine.current_persona != routed_persona: + persona_name = routed_persona.split("/")[-1].capitalize() + print(f"\n{CYAN}{BOLD}[Router]{RESET} Input matches intent for {CYAN}{BOLD}{persona_name}{RESET} (Semantic match found).") + confirm = await async_input(f" Activate the {persona_name} persona to proceed? (Y/n): ") + if not confirm.strip() or confirm.lower().startswith('y'): + print(f"{CYAN}[Router] Dynamically switching context to {persona_name}...{RESET}") + persona_path = PERSONA_MAP.get(routed_persona) + if persona_path and os.path.exists(persona_path): + try: + with open(persona_path, "r", encoding="utf-8") as pf: + new_system = pf.read().strip() + engine.system_prompt = new_system + engine.message_history[0] = {"role": "system", "content": engine.system_prompt} + engine.current_persona = routed_persona + print(f"{GREEN}[Router] {persona_name} Activated successfully.{RESET}\n") + log_telemetry("ROUTER", f"Activated persona: {persona_name} from intent match.") + except Exception as e: + print(f"{RED}[ERR] Failed to load persona: {str(e)}{RESET}\n") + log_telemetry("ROUTER_ERROR", f"Failed to load persona: {str(e)}") + else: + print(f"{RED}[ERR] Persona template not found at: {persona_path}{RESET}\n") + log_telemetry("ROUTER_ERROR", f"Persona template not found at: {persona_path}") + + engine.message_history.append({"role": "user", "content": user_input}) + # Autonomous agent reasoning loop + while True: + await engine.budget() + + print(f"\n{GREEN}{BOLD}Assistant >{RESET} ", end="") + sys.stdout.flush() + + final_payload = None + stream_filter = JSONStreamFilter() + + async for update in engine.infer(): + if update["type"] == "token": + clean_delta = stream_filter.feed(update["delta"]) + if clean_delta: + sys.stdout.write(clean_delta) + sys.stdout.flush() + elif update["type"] == "final": + final_payload = update["data"] + raw_buffer = update.get("raw", "") + log_telemetry(engine.current_persona, f"RAW INFERENCE:\n{raw_buffer}\n\nPARSED JSON:\n{json.dumps(final_payload, indent=2)}") + elif update["type"] == "error": + print(f"\n{RED}[ERR] {update['raw']}{RESET}") + raw_buffer = update.get("raw_buffer", "") + log_telemetry(f"{engine.current_persona}_ERROR", f"RAW INFERENCE:\n{raw_buffer}\nHTTP ERROR: {update['raw']}") + break + + print(f"\n{DIVIDER}") + if not final_payload: + break # Stop execution loop on inference error + + tool_call = final_payload.get("tool_call") + + # Cognitive Fail-Safe / Heuristic Backup + # If the model failed to output a structured tool_call block but explicitly stated its + # intention to activate a persona inside its "thought", we automatically heal and execute the action. + if not tool_call or tool_call == "null" or (isinstance(tool_call, dict) and not tool_call.get("name")): + thought_text = final_payload.get("thought", "").lower() + + # Robust root-word check matching diverse conjugations (activating, handoff, switch, etc.) + is_switch = any(x in thought_text for x in ["activat", "switch", "hand", "persona", "run"]) + + if "planner" in thought_text and is_switch and engine.current_persona != "geoscaper/team/planner": + tool_call = { + "name": "activate_persona", + "arguments": {"persona": "geoscaper/team/planner"} + } + elif "builder" in thought_text and is_switch and engine.current_persona != "geoscaper/team/builder": + tool_call = { + "name": "activate_persona", + "arguments": {"persona": "geoscaper/team/builder"} + } + elif any(x in thought_text for x in ["reviewer", "audit"]) and is_switch and engine.current_persona != "geoscaper/team/reviewer": + tool_call = { + "name": "activate_persona", + "arguments": {"persona": "geoscaper/team/reviewer"} + } + else: + print(f"{YELLOW}[Fail-Safe] Agent forgot to execute a tool. Forcing retry...{RESET}") + log_telemetry(engine.current_persona, f"FAIL-SAFE TRIGGERED: Agent forgot to execute a tool. RAW INFERENCE: {json.dumps(final_payload)}") + engine.message_history.append({"role": "assistant", "content": json.dumps(final_payload)}) + engine.message_history.append({"role": "user", "content": "[SYSTEM DICTATE: You failed to output a tool_call. You MUST invoke a tool on every turn. If you need to speak to the user or ask a question, use the 'ask_user' tool. Do not output tool_call: null.]"}) + continue + + if tool_call and isinstance(tool_call, dict) and tool_call.get("name"): + tool_name = tool_call["name"] + tool_args = tool_call.get("arguments", {}) + + print(f"\n{YELLOW}{BOLD}▶ Executing MCP Call:{RESET} {tool_name}") + log_telemetry(engine.current_persona, f"TOOL CALL: {tool_name}\nARGS: {json.dumps(tool_args, indent=2)}") + + # Dynamic Persona Handoff (Orchestrator level virtual tools) + if tool_name == "activate_persona": + persona_key = tool_args.get("persona") + persona_path = PERSONA_MAP.get(persona_key) + if persona_path and os.path.exists(persona_path): + try: + with open(persona_path, "r", encoding="utf-8") as pf: + new_system = pf.read().strip() + engine.system_prompt = new_system + engine.message_history[0] = {"role": "system", "content": engine.system_prompt} + engine.current_persona = persona_key # <-- Track active persona + tool_result = json.dumps({"status": "success", "message": f"Activated persona: {persona_key}"}) + except Exception as e: + tool_result = json.dumps({"status": "error", "reason": f"Failed to load persona file: {str(e)}"}) + else: + tool_result = json.dumps({"status": "error", "reason": f"Persona '{persona_key}' not found."}) + + elif tool_name == "deactivate_persona": + try: + if os.path.exists(SYSTEM_PROMPT_PATH): + with open(SYSTEM_PROMPT_PATH, "r", encoding="utf-8") as pf: + new_system = pf.read().strip() + else: + new_system = "You are a helpful assistant. Output must strictly match GBNF JSON grammar." + + new_system += MASTER_ORCHESTRATOR_INSTRUCTION + engine.system_prompt = new_system + engine.message_history[0] = {"role": "system", "content": engine.system_prompt} + engine.current_persona = "orchestrator" # <-- Reset to orchestrator + tool_result = json.dumps({"status": "success", "message": "Deactivated persona. Returned to Master Orchestrator."}) + except Exception as e: + tool_result = json.dumps({"status": "error", "reason": f"Failed to restore master persona: {str(e)}"}) + + elif tool_name == "ask_user": + message = tool_args.get("message", "") + print(f"\n{CYAN}{BOLD}Agent Message:{RESET} {message}") + engine.message_history.append({"role": "assistant", "content": json.dumps(final_payload)}) + break # Exit autonomous loop and wait for next user command + + else: + # Execute the tool via Model Context Protocol JSON-RPC + tool_result = await engine.call_mcp_tool(tool_name, tool_args) + + print(f"{GREEN}{BOLD}◀ Output:{RESET}\n {tool_result}\n{DIVIDER}") + log_telemetry(engine.current_persona, f"TOOL RESULT: {tool_name}\nOUTPUT:\n{tool_result}") + + engine.message_history.append({"role": "assistant", "content": json.dumps(final_payload)}) + + # Wrap the raw tool output in a SYSTEM DICTATE to force autonomous continuation + tool_content = ( + f"[SYSTEM: Tool Execution Result]\n" + f"{tool_result}\n\n" + f"[SYSTEM DICTATE: Proceed to the next step of your instructions. You MUST output a valid JSON tool_call on this turn. If your instructions require an interview or question, use the 'ask_user' tool immediately. Otherwise, invoke the next autonomous tool.]" + ) + engine.message_history.append({"role": "user", "content": tool_content}) + + # Continue loop to process the tool output autonomously + continue + else: + engine.message_history.append({"role": "assistant", "content": json.dumps(final_payload)}) + break # Exit autonomous loop and wait for next user command + + except KeyboardInterrupt: + print(f"\n{RED}[!] Interrupted.{RESET}") + engine.shutdown_mcp_server() + os._exit(0) +if __name__ == "__main__": + if not os.path.exists("agents"): + print(f"{RED}[ERR] Must run from workspace root containing '/agents'.{RESET}") + sys.exit(1) + asyncio.run(app_loop()) \ No newline at end of file diff --git a/core/tui.py b/core/tui.py new file mode 100644 index 0000000..e90325f --- /dev/null +++ b/core/tui.py @@ -0,0 +1,150 @@ +import sys +import os +import json + +# --- ANSI Styling Palette --- +CYAN = "\033[96m" +GREEN = "\033[92m" +YELLOW = "\033[93m" +RED = "\033[91m" +RESET = "\033[0m" +BOLD = "\033[1m" +DIM = "\033[2m" +CLEAR = "\033[2J\033[H" + +# --- ANSI Layout Sequences --- +SAVE_CURSOR = "\033[s" +RESTORE_CURSOR = "\033[u" +CLEAR_LINE = "\033[K" + +class TerminalUIManager: + def __init__(self): + self.term_width, self.term_height = os.get_terminal_size() + self.current_persona = "Orchestrator" + self.active_tool = None + self.token_usage = 0 + self.max_tokens = 16384 + self.is_thinking = False + self.has_set_region = False + + def setup_screen(self): + """Initializes the terminal layout with a scrolling region.""" + sys.stdout.write(CLEAR) + sys.stdout.flush() + # Set scrolling region from row 2 to (height - 1) + self.term_width, self.term_height = os.get_terminal_size() + sys.stdout.write(f"\033[2;{self.term_height - 1}r") + # Move cursor to row 2 + sys.stdout.write("\033[2;1H") + sys.stdout.flush() + self.has_set_region = True + self.redraw_header() + self.redraw_footer() + + def cleanup(self): + """Restores the terminal scrolling region.""" + if self.has_set_region: + sys.stdout.write("\033[r") # Reset scrolling region + sys.stdout.write(f"\033[{self.term_height};1H") + sys.stdout.write("\n") + sys.stdout.flush() + + def update_state(self, persona=None, active_tool=None, token_usage=None): + """Update internal state and redraw header/footer if necessary.""" + changed_header = False + changed_footer = False + + if persona is not None and persona != self.current_persona: + self.current_persona = persona + changed_header = True + + if active_tool is not None and active_tool != self.active_tool: + self.active_tool = active_tool + changed_footer = True + + if token_usage is not None and token_usage != self.token_usage: + self.token_usage = token_usage + changed_footer = True + + if changed_header: + self.redraw_header() + if changed_footer: + self.redraw_footer() + + def redraw_header(self): + """Draws the fixed top header.""" + sys.stdout.write(SAVE_CURSOR) + sys.stdout.write("\033[1;1H") # Move to top left + + persona_name = self.current_persona.split('/')[-1].capitalize() + header_text = f"⚙ SmarterAgents │ Persona: {persona_name}" + padding = max(0, self.term_width - len(header_text) - 1) + + sys.stdout.write(f"{CYAN}{BOLD}{header_text} {'─' * padding}{RESET}{CLEAR_LINE}") + sys.stdout.write(RESTORE_CURSOR) + sys.stdout.flush() + + def redraw_footer(self): + """Draws the fixed bottom footer.""" + sys.stdout.write(SAVE_CURSOR) + sys.stdout.write(f"\033[{self.term_height};1H") # Move to bottom left + + # Calculate budget bar + pct = min(1.0, self.token_usage / self.max_tokens) if self.max_tokens > 0 else 0 + filled = int(pct * 10) + empty = 10 - filled + bar = "█" * filled + "░" * empty + pct_str = f"{int(pct * 100)}%" + + status_text = "Idle" + if self.active_tool: + status_text = f"⚡ Running Tool: {self.active_tool}" + + footer_text = f" Token Budget: [{bar}] {pct_str} │ Status: {status_text} " + padding = max(0, self.term_width - len(footer_text)) + + sys.stdout.write(f"{DIM}{footer_text}{' ' * padding}{RESET}{CLEAR_LINE}") + sys.stdout.write(RESTORE_CURSOR) + sys.stdout.flush() + + def start_thought(self): + """Starts rendering a thought block.""" + if not self.is_thinking: + self.is_thinking = True + sys.stdout.write(f"\n{CYAN}{DIM}[💭 Agent Thinking...]\n") + sys.stdout.flush() + + def end_thought(self): + """Ends rendering a thought block.""" + if self.is_thinking: + self.is_thinking = False + sys.stdout.write(f"{RESET}\n{CYAN}{DIM}[✓ Thought Complete]{RESET}\n") + sys.stdout.flush() + + def stream_thought_chunk(self, chunk): + """Streams raw thought characters.""" + sys.stdout.write(chunk) + sys.stdout.flush() + + def print_message(self, message, role="Assistant"): + """Prints a standard message.""" + color = GREEN if role == "Assistant" else YELLOW + sys.stdout.write(f"\n{color}{BOLD}{role} >{RESET} {message}\n") + sys.stdout.flush() + + def log_tool_call(self, tool_name, args): + """Prints a nicely formatted tool invocation box.""" + sys.stdout.write(f"\n{YELLOW}{BOLD}╭─[🔧 Tool Execution: {tool_name}]" + "─" * max(0, self.term_width - 25 - len(tool_name)) + f"{RESET}\n") + try: + formatted_args = json.dumps(args, indent=2) + for line in formatted_args.splitlines(): + sys.stdout.write(f"{YELLOW}│{RESET} {line}\n") + except: + sys.stdout.write(f"{YELLOW}│{RESET} {args}\n") + sys.stdout.write(f"{YELLOW}╰" + "─" * (self.term_width - 1) + f"{RESET}\n") + sys.stdout.flush() + + def print_system_info(self, message, is_error=False): + color = RED if is_error else CYAN + sys.stdout.write(f"\n{color}{BOLD}[System]{RESET} {message}\n") + sys.stdout.flush() diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..85768dc --- /dev/null +++ b/start.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +set -eo pipefail + +# --- Color Definitions --- +CYAN='\033[96m' +GREEN='\033[92m' +YELLOW='\033[93m' +RED='\033[91m' +RESET='\033[0m' +BOLD='\033[1m' + +# --- Paths & Dependencies --- +MODEL_DIR="./models" +AGENT_DIR="./agents" +LLAMA_SERVER="./core/llama.cpp/build/bin/llama-server" +PYTHON_SCRIPT="./core/smarterframework.py" + +echo -e "${CYAN}${BOLD}=== SmarterAgents Core Launch System ===${RESET}" + +# Ensure local dev and log folders exist in workspace +mkdir -p dev +mkdir -p core/logs + +# Verify core compilation target exists +if [ ! -f "$LLAMA_SERVER" ]; then + echo -e "${RED}[ERR] compiled 'llama-server' binary missing at '$LLAMA_SERVER'.${RESET}" + echo -e "Please run cmake build inside core/llama.cpp as detailed in the README." + exit 1 +fi + +# 1. Model Selection +mapfile -t MODELS < <(find "$MODEL_DIR" -name "*.gguf" 2>/dev/null) +if [ ${#MODELS[@]} -eq 0 ]; then + echo -e "${RED}[ERR] No GGUF models discovered in '$MODEL_DIR'. Please copy a model there first.${RESET}" + exit 1 +fi + +echo -e "\n${YELLOW}Discovered Models:${RESET}" +for i in "${!MODELS[@]}"; do + echo -e " [$i] $(basename "${MODELS[$i]}")" +done +read -rp "Select Model Index [0-$((${#MODELS[@]} - 1))]: " MODEL_IDX +SELECTED_MODEL="${MODELS[$MODEL_IDX]}" + +# 2. Agent Selection +mapfile -t AGENTS < <(find "$AGENT_DIR" -name "*.md" 2>/dev/null) +if [ ${#AGENTS[@]} -eq 0 ]; then + echo -e "${RED}[ERR] No Markdown agents discovered in '$AGENT_DIR'.${RESET}" + exit 1 +fi + +echo -e "\n${YELLOW}Discovered Agent Personas:${RESET}" +for i in "${!AGENTS[@]}"; do + echo -e " [$i] $(basename "${AGENTS[$i]}")" +done +read -rp "Select Agent Index [0-$((${#AGENTS[@]} - 1))]: " AGENT_IDX +SELECTED_AGENT="${AGENTS[$AGENT_IDX]}" + +# 3. Environment Variable Export for Python Orchestrator +GRAMMAR_FILE="" +for path in "tools/tool_rules.gbnf" "tools/tool_rules.gnbf" "agents/tools/tool_rules.gbnf" "agents/tools/tool_rules.gnbf"; do + if [ -f "$path" ]; then + GRAMMAR_FILE="$path" + break + fi +done + +if [ -n "$GRAMMAR_FILE" ]; then + export AMDA_GRAMMAR_FILE="$GRAMMAR_FILE" +else + export AMDA_GRAMMAR_FILE="tools/tool_rules.gbnf" +fi + +export AMDA_BACKEND_URL="http://127.0.0.1:8080" +export AMDA_SYSTEM_PROMPT="$SELECTED_AGENT" + +# 4. Vulkan Background Server Initialization +PHYS_CORES=8 + +echo -e "\n${CYAN}[Init] Booting Vulkan llama-server daemon...${RESET}" +echo -e "${DIM}Assigning 100% compute threads to $PHYS_CORES physical cores.${RESET}" + +# Start llama-server and capture process PID +"$LLAMA_SERVER" \ + --model "$SELECTED_MODEL" \ + --host "127.0.0.1" \ + --port "8080" \ + --ctx-size 16384 \ + --parallel 1 \ + --threads "$PHYS_CORES" \ + --batch-size 128 \ + --ubatch-size 64 \ + --no-mmap \ + -ngl 999 \ + -fa on \ + -ctk q8_0 \ + -ctv q8_0 \ + --jinja > core/logs/llama-server.log 2>&1 & # <-- Added --jinja here +SERVER_PID=$! + +# Safe Target-Specific cleanup (Only kill the llama-server child process) +cleanup() { + echo -e "\n${YELLOW}[Exit] Shutting down llama-server daemon... (PID: $SERVER_PID)${RESET}" + kill -9 "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true +} +trap cleanup EXIT INT TERM HUP + +# 5. Health Check Diagnostics +echo -e "${CYAN}[Health] Polling background loop for local endpoint availability...${RESET}" +RETRIES=15 +CONNECTED=false +for ((i=1; i<=RETRIES; i++)); do + if curl -s -o /dev/null -w "%{http_code}" "$AMDA_BACKEND_URL/health" | grep -q "200"; then + echo -e "${GREEN}[Ready] Local Vulkan compute engine activated successfully.${RESET}" + CONNECTED=true + break + fi + sleep 1 +done + +if [ "$CONNECTED" = false ]; then + echo -e "${RED}[ERR] llama-server failed to activate within timeout period.${RESET}" + echo -e "Diagnostics: Last 15 lines of core/logs/llama-server.log:" + tail -n 15 core/logs/llama-server.log + exit 1 +fi + +# 6. Transfer Execution to Python TUI Engine +echo -e "${CYAN}[Exec] Handoff control to asynchronous user interface...${RESET}\n" +python3 "$PYTHON_SCRIPT" \ No newline at end of file diff --git a/tools/tool_rules.gbnf b/tools/tool_rules.gbnf new file mode 100644 index 0000000..2803693 --- /dev/null +++ b/tools/tool_rules.gbnf @@ -0,0 +1,10 @@ +root ::= object +object ::= "{" ws "\"thought\":" ws string "," ws "\"tool_call\":" ws tool-call ws "}" +tool-call ::= "{" ws "\"name\":" ws string "," ws "\"arguments\":" ws tool-args ws "}" +tool-args ::= "{" ws ( pair ("," ws pair)* )? ws "}" +pair ::= string ws ":" ws value +value ::= string | number | "true" | "false" | "null" | tool-args | array +array ::= "[" ws ( value ("," ws value)* )? ws "]" +string ::= "\"" ([^"\\\x00-\x1F] | "\\" (["\\/bfnrt] | "u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F]))* "\"" +number ::= "-"? ([0-9]+ | [0-9]* "." [0-9]+) ([eE] [+-]? [0-9]+)? +ws ::= [ \t\n\r]*