From bfd4d37f6ef1a79c73ca0a656ab9bd618c171cb7 Mon Sep 17 00:00:00 2001 From: chrischristiansen-glitch Date: Mon, 15 Jun 2026 20:08:39 +0200 Subject: [PATCH] fix(jason): kopier opax_mcp_client inn i core-logic, redeploy med tools --- agents/core-logic/agent.py | 92 ++++-------- agents/core-logic/opax_mcp_client.py | 212 +++++++++++++++++++++++++++ 2 files changed, 239 insertions(+), 65 deletions(-) create mode 100644 agents/core-logic/opax_mcp_client.py diff --git a/agents/core-logic/agent.py b/agents/core-logic/agent.py index 03a3d0a..740840c 100644 --- a/agents/core-logic/agent.py +++ b/agents/core-logic/agent.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -agent.py — OSVauco OPAX agent. +agent.py — OSVauco OPAX agent (Jason Vauger). Modes: light — gemini-2.5-flash, $1/task @@ -9,19 +9,7 @@ Modes: Legacy 'A' / 'A+' normaliseres automatisk. Autoriserte brukere for heavy: opax, admin -Gjeldende modell-tilgjengelighet (mai 2026): - gemini-2.0-flash-001 kun for eksisterende kunder — bruk IKKE. - gemini-2.5-flash / gemini-2.5-pro krever GOOGLE_CLOUD_LOCATION=global. - -NOTE: RAG er midlertidig deaktivert (CI6) — blokkerte AFC når kombinert med -FunctionTools. Legges tilbake som dedikert sub-agent når RAG-korpuset er -oppdatert (OQ-RAG backlog). - -CG4-budget (ny): - - Flash-first routing: heavy-kall under FLASH_THRESHOLD rutes til Flash - - Context-trimming: maks OPAX_CONTEXT_TURNS turns beholdes i historikk - - Output-tak: MAX_OUTPUT_TOKENS satt på alle kall - - TokenBudgetExceeded kastes ved for stor input +NOTE: opax_mcp_client.py ligg i same mappe (core-logic/) for Vertex AI-kompatibilitet. """ import asyncio @@ -59,10 +47,8 @@ APP_NAME = "opax" try: from token_logger import log_token_usage, create_bq_table_if_not_exists except ImportError: - def log_token_usage(*args, **kwargs): - pass - def create_bq_table_if_not_exists(): - pass + def log_token_usage(*args, **kwargs): pass + def create_bq_table_if_not_exists(): pass try: import sys as _sys, pathlib as _pathlib @@ -70,13 +56,12 @@ try: from token_budget import trim_context, route_model, TokenBudgetExceeded, budget_summary logger.info(f"[agent] token_budget lastet: {budget_summary()}") except ImportError as _e: - logger.warning(f"[agent] token_budget ikke tilgjengelig: {_e} — kjører uten trimming") + logger.warning(f"[agent] token_budget ikke tilgjengelig: {_e}") def trim_context(history, system_prompt="", max_tokens=32000): return history[-10:] if len(history) > 10 else history def route_model(message, mode, flash_model, pro_model): return flash_model if mode != "heavy" else pro_model - class TokenBudgetExceeded(Exception): - pass + class TokenBudgetExceeded(Exception): pass create_bq_table_if_not_exists() @@ -84,32 +69,25 @@ create_bq_table_if_not_exists() def _normalize_mode(mode: str) -> str: mapping = {"A": "light", "A+": "heavy", "light": "light", "heavy": "heavy"} if mode not in mapping: - raise ValueError( - f"Invalid mode '{mode}'. Must be 'light' or 'heavy' " - f"(also accepts legacy 'A' / 'A+')." - ) + raise ValueError(f"Invalid mode '{mode}'. Must be 'light' or 'heavy'.") return mapping[mode] def authorize_mode(user_id: str, mode: str) -> None: mode = _normalize_mode(mode) if mode == "heavy" and user_id not in HEAVY_MODE_ALLOWED_USERS: - raise PermissionError( - f"User '{user_id}' is not authorized for heavy mode. " - f"Authorized: {HEAVY_MODE_ALLOWED_USERS}" - ) + raise PermissionError(f"User '{user_id}' is not authorized for heavy mode.") -# —— OPAX-MCP FunctionTools (feiler stille) —— +# —— OPAX-MCP FunctionTools —— +# opax_mcp_client.py er i same mappe — Vertex AI finn den alltid opax_tools = [] try: - import sys, pathlib - sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "tools")) - from mcp_tools import get_opax_tools - opax_tools = get_opax_tools() - logger.info(f"OPAX-MCP tools lastet: {[t.name for t in opax_tools]}") + from opax_mcp_client import get_all_function_tools + opax_tools = get_all_function_tools() + logger.info(f"OPAX-MCP tools lastet: {len(opax_tools)} tools") except Exception as e: - logger.warning(f"OPAX-MCP tools ikke tilgjengelig (CI3): {e}") + logger.warning(f"OPAX-MCP tools ikkje tilgjengeleg: {e}") def get_models_for_mode(mode: Mode) -> dict: @@ -119,55 +97,42 @@ def get_models_for_mode(mode: Mode) -> dict: "subagent": HEAVY_SUBAGENT, "reasoning": HEAVY_REASONING, "budget_usd": BUDGET_HEAVY, - "multi_agent": True, } return { "orchestrator": ORCHESTRATOR_MODEL, "subagent": SUBAGENT_MODEL, "reasoning": REASONING_MODEL, "budget_usd": BUDGET_LIGHT, - "multi_agent": False, } -# Kompakt system-prompt (redusert fra ~800 til ~350 tokens) _INSTRUCTION_TEMPLATE = ( - "Du er OPAX — OSVauco AI-agent (Jason). Modus: {mode}. " - "Modell: {model}. Budsjett: ${budget}/oppgave. " - "GCP-kostnader: /billing/summary, /billing/forecast, /billing/anomalies. " - "Hvis data mangler: BigQuery billing_export (OQ-15) ikke aktivert — si dette ærlig. " - "HITL: ikke kjør terraform/onboard/deploy uten Chris. " - "MCP-tools: get_build_status, get_logs, push_static, deploy_service. " - "Svar på norsk (bokmål) med mindre annet er bedt om." + "Du er Jason Vauger — OPAX-agenten for Vauco AS. Modus: {mode}. Modell: {model}. " + "Du har tilgang til alle OPAX-tools via opax-mcp. " + "Tools du kan bruke: list_commits, get_file, push_file, create_issue, list_open_issues (Gitea), " + "get_billing_summary, get_billing_credits, get_billing_anomalies, " + "get_build_status, get_state, get_telemetry, run_terminal, " + "create_invite, list_customers, send_webhook, send_email, get_notify_channels. " + "Når brukaren ber om data — KALL ALLTID rett tool før du svarar. " + "Svar på norsk (bokmål) med mindre annet er bedt om. " + "HITL: ikkje kjør terraform/deploy utan godkjenning frå Chris." ) def build_agent(mode: str = "light", message: str = "") -> tuple: - """ - Returnerer (agent, actual_model, instruction) der actual_model er - Flash-first-rutet basert på meldingslængde. - """ mode = _normalize_mode(mode) models = get_models_for_mode(mode) - - # CG4-budget: Flash-first routing actual_model = route_model( message=message, mode=mode, flash_model=ORCHESTRATOR_MODEL, pro_model=models["orchestrator"], ) - - instruction = _INSTRUCTION_TEMPLATE.format( - mode=mode, - model=actual_model, - budget=models["budget_usd"], - ) - + instruction = _INSTRUCTION_TEMPLATE.format(mode=mode, model=actual_model) agent = Agent( model=actual_model, - name="opax_agent", - description=f"OPAX — OSVauco enterprise agent [{mode}]", + name="jason_vauger", + description=f"Jason Vauger — OPAX enterprise agent [{mode}]", instruction=instruction, tools=opax_tools, ) @@ -189,7 +154,6 @@ async def _run_async( models = get_models_for_mode(mode) module_name = f"jason/{mode}" - # CG4-budget: trim melding hvis enkeltmelding er for lang try: trim_context([types.Content(role="user", parts=[types.Part(text=message)])], system_prompt=instruction) except TokenBudgetExceeded as e: @@ -216,7 +180,6 @@ async def _run_async( input_tokens += getattr(um, "prompt_token_count", 0) or 0 output_tokens += getattr(um, "candidates_token_count", 0) or 0 - # Logg faktisk modell brukt (ikke nødvendigvis Pro selv i heavy-mode) if input_tokens > 0 or output_tokens > 0: log_token_usage( agent_name=module_name, @@ -233,7 +196,6 @@ async def _run_async( f"[agent] {mode}/{actual_model} in={input_tokens} out={output_tokens} " f"caller={caller_type} session={session_id}" ) - return final_text @@ -261,6 +223,6 @@ if __name__ == "__main__": query = sys.argv[1] if len(sys.argv) > 1 else "Hva er OPAX?" mode = sys.argv[2] if len(sys.argv) > 2 else "light" caller_type = sys.argv[3] if len(sys.argv) > 3 else "cli" - print(f"Mode: {mode} | Caller: {caller_type} | Query: {query}") + print(f"Mode: {mode} | Query: {query}") print("-" * 60) print(run(message=query, user_id="opax", mode=mode, caller_type=caller_type)) diff --git a/agents/core-logic/opax_mcp_client.py b/agents/core-logic/opax_mcp_client.py new file mode 100644 index 0000000..c174d99 --- /dev/null +++ b/agents/core-logic/opax_mcp_client.py @@ -0,0 +1,212 @@ +""" +opax_mcp_client.py — REST-klient for opax-mcp Cloud Run service. +Kopiert inn i core-logic/ slik at Vertex AI Agent Engine finn den ved deploy. + +Auth-lag: + 1. Cloud Run IAM : Authorization: Bearer (automatisk) + 2. Tool-level : X-MCP-Secret: (frå env) + +URL: https://opax-mcp-zjbqp3prqq-uc.a.run.app +""" + +import os +import httpx +import google.auth +import google.auth.transport.requests + +OPAX_MCP_URL = os.environ.get("MCP_SERVER_URL", "https://opax-mcp-zjbqp3prqq-uc.a.run.app") +MCP_SECRET = os.environ.get("MCP_SECRET", "") + + +def _identity_token() -> str: + metadata_url = ( + "http://metadata.google.internal/computeMetadata/v1/instance" + f"/service-accounts/default/identity?audience={OPAX_MCP_URL}&format=full" + ) + try: + resp = httpx.get(metadata_url, headers={"Metadata-Flavor": "Google"}, timeout=5) + if resp.status_code == 200 and resp.text.strip(): + return resp.text.strip() + except Exception: + pass + credentials, _ = google.auth.default() + credentials.refresh(google.auth.transport.requests.Request()) + return credentials.token + + +def _headers() -> dict: + return { + "Authorization": f"Bearer {_identity_token()}", + "X-MCP-Secret": MCP_SECRET, + "Content-Type": "application/json", + } + + +def call_tool(tool: str, params: dict = None) -> dict: + resp = httpx.post( + f"{OPAX_MCP_URL}/tools/call", + headers=_headers(), + json={"tool": tool, "params": params or {}}, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + + +# --------------------------------------------------------------------------- +# Billing +# --------------------------------------------------------------------------- +def get_billing_summary() -> dict: + """Hent billing-oversikt for OSVauco (GCP-kostnader, token-forbruk).""" + return call_tool("get_billing_summary") + +def get_billing_forecast() -> dict: + """Hent token-estimat og kostnadsframskriving.""" + return call_tool("get_billing_forecast") + +def get_billing_credits() -> dict: + """Hent gjenværende GCP-kreditter og burn-rate.""" + return call_tool("get_billing_credits") + +def get_billing_anomalies() -> dict: + """Sjekk for kostnadanomalier og uventede spiker.""" + return call_tool("get_billing_anomalies") + +def get_billing_history() -> dict: + """Hent historisk kostnadsdata (90 dagar).""" + return call_tool("get_billing_history") + +def get_billing_budget() -> dict: + """Hent gjeldande budsjett for OSVauco.""" + return call_tool("get_billing_budget") + +def set_billing_budget(amount: float) -> dict: + """Sett nytt budsjettgrense i NOK/USD. amount er beløpet.""" + return call_tool("set_billing_budget", {"amount": amount}) + + +# --------------------------------------------------------------------------- +# Onboarding +# --------------------------------------------------------------------------- +def create_invite(email: str, company: str = "", tier: str = "starter") -> dict: + """Opprett invite-link for ny kunde. tier: starter | guard | shield | enterprise.""" + return call_tool("create_invite", {"email": email, "company": company, "tier": tier}) + +def list_customers() -> dict: + """List alle onboardede kunder og systemtilstand.""" + return call_tool("list_customers") + + +# --------------------------------------------------------------------------- +# Notify +# --------------------------------------------------------------------------- +def send_webhook(message: str, url: str = "", title: str = "OPAX varsel") -> dict: + """Send webhook-varsling til Slack/Teams/Discord. url er valgfri override.""" + return call_tool("send_webhook", {"message": message, "url": url, "title": title}) + +def send_email(to: str, subject: str, body: str = "") -> dict: + """Send e-post via SendGrid. to er mottaker-adresse.""" + return call_tool("send_email", {"to": to, "subject": subject, "body": body}) + +def send_sms(to: str, message: str, tier: str = "guard") -> dict: + """Send SMS via Twilio. Krev Guard+-tier. to er telefonnummer med landkode.""" + return call_tool("send_sms", {"to": to, "message": message, "tier": tier}) + +def get_notify_channels() -> dict: + """List konfigurerte varslingskanalar (e-post, SMS, webhook).""" + return call_tool("get_notify_channels") + + +# --------------------------------------------------------------------------- +# Agents +# --------------------------------------------------------------------------- +def run_jason(prompt: str) -> dict: + """Kall Jason-agenten med ein prompt via OPAX /run.""" + return call_tool("run_jason", {"prompt": prompt}) + +def run_emma(prompt: str) -> dict: + """Kall Emma-agenten (Gemma lokal) med ein prompt via OPAX /emma.""" + return call_tool("run_emma", {"prompt": prompt}) + + +# --------------------------------------------------------------------------- +# Platform +# --------------------------------------------------------------------------- +def get_health() -> dict: + """Sjekk helsestatus for OPAX-plattformen.""" + return call_tool("get_health") + +def get_build_status() -> dict: + """Hent status på siste Cloud Build-kjøring.""" + return call_tool("get_build_status") + +def get_state() -> dict: + """Hent gjeldande systemtilstand og agent-statistikk for OSVauco.""" + return call_tool("get_state") + +def get_telemetry() -> dict: + """Hent token-forbruk og ytingsdata per modul.""" + return call_tool("get_telemetry") + +def run_terminal(command: str) -> dict: + """Kjør whitelist-kommando i OPAX terminal. Gyldige: health, billing, build, logs, help.""" + return call_tool("run_terminal", {"command": command}) + + +# --------------------------------------------------------------------------- +# Gitea (repo-tools) +# --------------------------------------------------------------------------- +def list_commits(limit: int = 10) -> dict: + """List siste commits i OSVauco-repoet på Gitea.""" + return call_tool("list_commits", {"limit": limit}) + +def get_file(path: str) -> dict: + """Hent innhald i ein fil frå Gitea-repoet. path er relativ til rot.""" + return call_tool("get_file", {"path": path}) + +def list_open_issues() -> dict: + """List alle opne issues i OSVauco Gitea-repoet.""" + return call_tool("list_open_issues", {}) + +def create_issue(title: str, body: str = "") -> dict: + """Opprett nytt issue i Gitea-repoet.""" + return call_tool("create_issue", {"title": title, "body": body}) + +def push_file(path: str, content: str, message: str = "", sha: str = "") -> dict: + """Push/oppdater ein fil i Gitea. sha krevst ved oppdatering av eksisterande fil.""" + return call_tool("push_file", {"path": path, "content": content, "message": message, "sha": sha}) + + +# --------------------------------------------------------------------------- +# ADK FunctionTools — for bruk i agent.py +# --------------------------------------------------------------------------- +def get_all_function_tools() -> list: + """Returner alle tools som ADK FunctionTool-liste for Jason.""" + from google.adk.tools import FunctionTool + return [ + FunctionTool(func=get_billing_summary), + FunctionTool(func=get_billing_forecast), + FunctionTool(func=get_billing_credits), + FunctionTool(func=get_billing_anomalies), + FunctionTool(func=get_billing_history), + FunctionTool(func=get_billing_budget), + FunctionTool(func=set_billing_budget), + FunctionTool(func=create_invite), + FunctionTool(func=list_customers), + FunctionTool(func=send_webhook), + FunctionTool(func=send_email), + FunctionTool(func=send_sms), + FunctionTool(func=get_notify_channels), + FunctionTool(func=run_jason), + FunctionTool(func=run_emma), + FunctionTool(func=get_health), + FunctionTool(func=get_build_status), + FunctionTool(func=get_state), + FunctionTool(func=get_telemetry), + FunctionTool(func=run_terminal), + FunctionTool(func=list_commits), + FunctionTool(func=get_file), + FunctionTool(func=list_open_issues), + FunctionTool(func=create_issue), + FunctionTool(func=push_file), + ]