diff --git a/agents/core-logic/agent.py b/agents/core-logic/agent.py index 740840c..ba0d78d 100644 --- a/agents/core-logic/agent.py +++ b/agents/core-logic/agent.py @@ -1,24 +1,20 @@ #!/usr/bin/env python3 """ agent.py — OSVauco OPAX agent (Jason Vauger). - -Modes: - light — gemini-2.5-flash, $1/task - heavy — gemini-2.5-pro, $3/task - -Legacy 'A' / 'A+' normaliseres automatisk. -Autoriserte brukere for heavy: opax, admin - -NOTE: opax_mcp_client.py ligg i same mappe (core-logic/) for Vertex AI-kompatibilitet. +opax_mcp_client er inlina direkte her for Vertex AI Agent Engine-kompatibilitet. """ import asyncio import os import logging import uuid +import httpx +import google.auth +import google.auth.transport.requests from typing import Literal from google.adk.agents import Agent +from google.adk.tools import FunctionTool from google.adk.runners import Runner from google.adk.sessions.in_memory_session_service import InMemorySessionService from google.genai import types @@ -66,6 +62,161 @@ except ImportError as _e: create_bq_table_if_not_exists() +# --------------------------------------------------------------------------- +# OPAX MCP Client (inlina) +# --------------------------------------------------------------------------- +_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 _mcp_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=_mcp_headers(), + json={"tool": tool, "params": params or {}}, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + + +def get_billing_summary() -> dict: + """Hent billing-oversikt for OSVauco (GCP-kostnader, token-forbruk).""" + return _call_tool("get_billing_summary") + +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. amount er beløpet i USD.""" + return _call_tool("set_billing_budget", {"amount": amount}) + +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") + +def send_webhook(message: str, url: str = "", title: str = "OPAX varsel") -> dict: + """Send webhook-varsling til Slack/Teams/Discord.""" + 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.""" + return _call_tool("send_email", {"to": to, "subject": subject, "body": body}) + +def get_notify_channels() -> dict: + """List konfigurerte varslingskanalar.""" + return _call_tool("get_notify_channels") + +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}) + +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.""" + 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.""" + return _call_tool("push_file", {"path": path, "content": content, "message": message, "sha": sha}) + + +OPAX_TOOLS = [ + FunctionTool(func=get_billing_summary), + 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=get_notify_channels), + 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), +] + + +# --------------------------------------------------------------------------- +# Agent logic +# --------------------------------------------------------------------------- def _normalize_mode(mode: str) -> str: mapping = {"A": "light", "A+": "heavy", "light": "light", "heavy": "heavy"} if mode not in mapping: @@ -79,37 +230,16 @@ def authorize_mode(user_id: str, mode: str) -> None: raise PermissionError(f"User '{user_id}' is not authorized for heavy mode.") -# —— OPAX-MCP FunctionTools —— -# opax_mcp_client.py er i same mappe — Vertex AI finn den alltid -opax_tools = [] -try: - 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 ikkje tilgjengeleg: {e}") - - def get_models_for_mode(mode: Mode) -> dict: if mode == "heavy": - return { - "orchestrator": HEAVY_ORCHESTRATOR, - "subagent": HEAVY_SUBAGENT, - "reasoning": HEAVY_REASONING, - "budget_usd": BUDGET_HEAVY, - } - return { - "orchestrator": ORCHESTRATOR_MODEL, - "subagent": SUBAGENT_MODEL, - "reasoning": REASONING_MODEL, - "budget_usd": BUDGET_LIGHT, - } + return {"orchestrator": HEAVY_ORCHESTRATOR, "subagent": HEAVY_SUBAGENT, "reasoning": HEAVY_REASONING, "budget_usd": BUDGET_HEAVY} + return {"orchestrator": ORCHESTRATOR_MODEL, "subagent": SUBAGENT_MODEL, "reasoning": REASONING_MODEL, "budget_usd": BUDGET_LIGHT} _INSTRUCTION_TEMPLATE = ( "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), " + "Tools: 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. " @@ -122,19 +252,14 @@ _INSTRUCTION_TEMPLATE = ( def build_agent(mode: str = "light", message: str = "") -> tuple: mode = _normalize_mode(mode) models = get_models_for_mode(mode) - actual_model = route_model( - message=message, - mode=mode, - flash_model=ORCHESTRATOR_MODEL, - pro_model=models["orchestrator"], - ) + 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) agent = Agent( model=actual_model, name="jason_vauger", description=f"Jason Vauger — OPAX enterprise agent [{mode}]", instruction=instruction, - tools=opax_tools, + tools=OPAX_TOOLS, ) return agent, actual_model, instruction @@ -142,16 +267,9 @@ def build_agent(mode: str = "light", message: str = "") -> tuple: root_agent, _, _ = build_agent(mode="light") -async def _run_async( - message: str, - user_id: str, - session_id: str, - mode: str, - caller_type: str = "agent", -) -> str: +async def _run_async(message: str, user_id: str, session_id: str, mode: str, caller_type: str = "agent") -> str: mode = _normalize_mode(mode) agent, actual_model, instruction = build_agent(mode=mode, message=message) - models = get_models_for_mode(mode) module_name = f"jason/{mode}" try: @@ -160,9 +278,7 @@ async def _run_async( return f"⚠️ Token-budsjett overskredet: {e}" session_service = InMemorySessionService() - session = await session_service.create_session( - app_name=APP_NAME, user_id=user_id, session_id=session_id, - ) + session = await session_service.create_session(app_name=APP_NAME, user_id=user_id, session_id=session_id) runner = Runner(agent=agent, app_name=APP_NAME, session_service=session_service) new_message = types.Content(role="user", parts=[types.Part(text=message)]) final_text = "" @@ -170,9 +286,7 @@ async def _run_async( output_tokens = 0 request_id = str(uuid.uuid4()) - async for event in runner.run_async( - user_id=user_id, session_id=session.id, new_message=new_message, - ): + async for event in runner.run_async(user_id=user_id, session_id=session.id, new_message=new_message): if event.is_final_response() and event.content and event.content.parts: final_text = event.content.parts[0].text or "" if hasattr(event, "usage_metadata") and event.usage_metadata: @@ -182,39 +296,19 @@ async def _run_async( if input_tokens > 0 or output_tokens > 0: log_token_usage( - agent_name=module_name, - model_name=actual_model, - input_tokens=input_tokens, - output_tokens=output_tokens, - request_id=request_id, - module_name=module_name, - caller_type=caller_type, - session_id=session_id, + agent_name=module_name, model_name=actual_model, + input_tokens=input_tokens, output_tokens=output_tokens, + request_id=request_id, module_name=module_name, + caller_type=caller_type, session_id=session_id, ) - - logger.info( - f"[agent] {mode}/{actual_model} in={input_tokens} out={output_tokens} " - f"caller={caller_type} session={session_id}" - ) + logger.info(f"[agent] {mode}/{actual_model} in={input_tokens} out={output_tokens} caller={caller_type} session={session_id}") return final_text -def run( - message: str, - user_id: str = "opax", - session_id: str = "default", - mode: str = "light", - caller_type: str = "agent", -) -> str: +def run(message: str, user_id: str = "opax", session_id: str = "default", mode: str = "light", caller_type: str = "agent") -> str: mode = _normalize_mode(mode) authorize_mode(user_id, mode) - return asyncio.run(_run_async( - message=message, - user_id=user_id, - session_id=session_id, - mode=mode, - caller_type=caller_type, - )) + return asyncio.run(_run_async(message=message, user_id=user_id, session_id=session_id, mode=mode, caller_type=caller_type)) if __name__ == "__main__":