fix(agent): inline opax_mcp_client direkte i agent.py — fjern ekstern import

This commit is contained in:
chrischristiansen-glitch 2026-06-15 21:32:21 +02:00
parent 31f58e4ce0
commit 294f455d67

View File

@ -1,24 +1,20 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
agent.py OSVauco OPAX agent (Jason Vauger). agent.py OSVauco OPAX agent (Jason Vauger).
opax_mcp_client er inlina direkte her for Vertex AI Agent Engine-kompatibilitet.
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.
""" """
import asyncio import asyncio
import os import os
import logging import logging
import uuid import uuid
import httpx
import google.auth
import google.auth.transport.requests
from typing import Literal from typing import Literal
from google.adk.agents import Agent from google.adk.agents import Agent
from google.adk.tools import FunctionTool
from google.adk.runners import Runner from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.genai import types from google.genai import types
@ -66,6 +62,161 @@ except ImportError as _e:
create_bq_table_if_not_exists() 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: def _normalize_mode(mode: str) -> str:
mapping = {"A": "light", "A+": "heavy", "light": "light", "heavy": "heavy"} mapping = {"A": "light", "A+": "heavy", "light": "light", "heavy": "heavy"}
if mode not in mapping: 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.") 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: def get_models_for_mode(mode: Mode) -> dict:
if mode == "heavy": if mode == "heavy":
return { return {"orchestrator": HEAVY_ORCHESTRATOR, "subagent": HEAVY_SUBAGENT, "reasoning": HEAVY_REASONING, "budget_usd": BUDGET_HEAVY}
"orchestrator": HEAVY_ORCHESTRATOR, return {"orchestrator": ORCHESTRATOR_MODEL, "subagent": SUBAGENT_MODEL, "reasoning": REASONING_MODEL, "budget_usd": BUDGET_LIGHT}
"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 = ( _INSTRUCTION_TEMPLATE = (
"Du er Jason Vauger — OPAX-agenten for Vauco AS. Modus: {mode}. Modell: {model}. " "Du er Jason Vauger — OPAX-agenten for Vauco AS. Modus: {mode}. Modell: {model}. "
"Du har tilgang til alle OPAX-tools via opax-mcp. " "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_billing_summary, get_billing_credits, get_billing_anomalies, "
"get_build_status, get_state, get_telemetry, run_terminal, " "get_build_status, get_state, get_telemetry, run_terminal, "
"create_invite, list_customers, send_webhook, send_email, get_notify_channels. " "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: def build_agent(mode: str = "light", message: str = "") -> tuple:
mode = _normalize_mode(mode) mode = _normalize_mode(mode)
models = get_models_for_mode(mode) models = get_models_for_mode(mode)
actual_model = route_model( actual_model = route_model(message=message, mode=mode, flash_model=ORCHESTRATOR_MODEL, pro_model=models["orchestrator"])
message=message,
mode=mode,
flash_model=ORCHESTRATOR_MODEL,
pro_model=models["orchestrator"],
)
instruction = _INSTRUCTION_TEMPLATE.format(mode=mode, model=actual_model) instruction = _INSTRUCTION_TEMPLATE.format(mode=mode, model=actual_model)
agent = Agent( agent = Agent(
model=actual_model, model=actual_model,
name="jason_vauger", name="jason_vauger",
description=f"Jason Vauger — OPAX enterprise agent [{mode}]", description=f"Jason Vauger — OPAX enterprise agent [{mode}]",
instruction=instruction, instruction=instruction,
tools=opax_tools, tools=OPAX_TOOLS,
) )
return agent, actual_model, instruction return agent, actual_model, instruction
@ -142,16 +267,9 @@ def build_agent(mode: str = "light", message: str = "") -> tuple:
root_agent, _, _ = build_agent(mode="light") root_agent, _, _ = build_agent(mode="light")
async def _run_async( async def _run_async(message: str, user_id: str, session_id: str, mode: str, caller_type: str = "agent") -> str:
message: str,
user_id: str,
session_id: str,
mode: str,
caller_type: str = "agent",
) -> str:
mode = _normalize_mode(mode) mode = _normalize_mode(mode)
agent, actual_model, instruction = build_agent(mode=mode, message=message) agent, actual_model, instruction = build_agent(mode=mode, message=message)
models = get_models_for_mode(mode)
module_name = f"jason/{mode}" module_name = f"jason/{mode}"
try: try:
@ -160,9 +278,7 @@ async def _run_async(
return f"⚠️ Token-budsjett overskredet: {e}" return f"⚠️ Token-budsjett overskredet: {e}"
session_service = InMemorySessionService() session_service = InMemorySessionService()
session = await session_service.create_session( session = await session_service.create_session(app_name=APP_NAME, user_id=user_id, session_id=session_id)
app_name=APP_NAME, user_id=user_id, session_id=session_id,
)
runner = Runner(agent=agent, app_name=APP_NAME, session_service=session_service) runner = Runner(agent=agent, app_name=APP_NAME, session_service=session_service)
new_message = types.Content(role="user", parts=[types.Part(text=message)]) new_message = types.Content(role="user", parts=[types.Part(text=message)])
final_text = "" final_text = ""
@ -170,9 +286,7 @@ async def _run_async(
output_tokens = 0 output_tokens = 0
request_id = str(uuid.uuid4()) request_id = str(uuid.uuid4())
async for event in runner.run_async( async for event in runner.run_async(user_id=user_id, session_id=session.id, new_message=new_message):
user_id=user_id, session_id=session.id, new_message=new_message,
):
if event.is_final_response() and event.content and event.content.parts: if event.is_final_response() and event.content and event.content.parts:
final_text = event.content.parts[0].text or "" final_text = event.content.parts[0].text or ""
if hasattr(event, "usage_metadata") and event.usage_metadata: 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: if input_tokens > 0 or output_tokens > 0:
log_token_usage( log_token_usage(
agent_name=module_name, agent_name=module_name, model_name=actual_model,
model_name=actual_model, input_tokens=input_tokens, output_tokens=output_tokens,
input_tokens=input_tokens, request_id=request_id, module_name=module_name,
output_tokens=output_tokens, caller_type=caller_type, session_id=session_id,
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 return final_text
def run( def run(message: str, user_id: str = "opax", session_id: str = "default", mode: str = "light", caller_type: str = "agent") -> str:
message: str,
user_id: str = "opax",
session_id: str = "default",
mode: str = "light",
caller_type: str = "agent",
) -> str:
mode = _normalize_mode(mode) mode = _normalize_mode(mode)
authorize_mode(user_id, mode) authorize_mode(user_id, mode)
return asyncio.run(_run_async( return asyncio.run(_run_async(message=message, user_id=user_id, session_id=session_id, mode=mode, caller_type=caller_type))
message=message,
user_id=user_id,
session_id=session_id,
mode=mode,
caller_type=caller_type,
))
if __name__ == "__main__": if __name__ == "__main__":