refactor(mode): rename A/A+ → light/heavy gjennomgående + ML-spor i MASTERPLAN

This commit is contained in:
chrischristiansen-glitch 2026-05-26 01:20:53 +02:00
parent 7d7c5e91a8
commit 5e1bb4e7ab
2 changed files with 56 additions and 104 deletions

View File

@ -1,14 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
agent.py OSVauco root agent using ADK 1.x with A / A+ mode routing. agent.py OSVauco root agent using ADK 1.x with light / heavy mode routing.
Modes: Modes:
A (standard) gemini-2.0-flash, $1/task hard stop light (standard) gemini-2.0-flash, $1/task hard stop
A+ (audit+) gemini-2.5-pro orchestrator + subagents, heavy (audit+) gemini-2.5-pro orchestrator + subagents,
gemini-3.5-flash reasoning, $3/task hard stop, gemini-3.5-flash reasoning, $3/task hard stop,
multi-agent pipeline enabled multi-agent pipeline enabled
Authorized users for A+: opax, admin Authorized users for heavy: opax, admin
Requires: google-adk >= 1.0.0,<2.0.0 Requires: google-adk >= 1.0.0,<2.0.0
google-cloud-aiplatform >= 1.112.0 google-cloud-aiplatform >= 1.112.0
@ -34,26 +34,34 @@ PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5")
LOCATION = os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1") LOCATION = os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1")
RAG_CORPUS = os.environ.get("RAG_CORPUS", "") RAG_CORPUS = os.environ.get("RAG_CORPUS", "")
# Standard (A) models # Light mode models
ORCHESTRATOR_MODEL = os.environ.get("ORCHESTRATOR_MODEL", "gemini-2.0-flash") ORCHESTRATOR_MODEL = os.environ.get("ORCHESTRATOR_MODEL", "gemini-2.0-flash")
SUBAGENT_MODEL = os.environ.get("SUBAGENT_MODEL", "gemini-2.0-flash") SUBAGENT_MODEL = os.environ.get("SUBAGENT_MODEL", "gemini-2.0-flash")
REASONING_MODEL = os.environ.get("REASONING_MODEL", "gemini-2.0-flash") REASONING_MODEL = os.environ.get("REASONING_MODEL", "gemini-2.0-flash")
# A+ (audit+) models # Heavy mode models
HEAVY_ORCHESTRATOR = os.environ.get("HEAVY_ORCHESTRATOR_MODEL", "gemini-2.5-pro") HEAVY_ORCHESTRATOR = os.environ.get("HEAVY_ORCHESTRATOR_MODEL", "gemini-2.5-pro")
HEAVY_SUBAGENT = os.environ.get("HEAVY_SUBAGENT_MODEL", "gemini-2.5-pro") HEAVY_SUBAGENT = os.environ.get("HEAVY_SUBAGENT_MODEL", "gemini-2.5-pro")
HEAVY_REASONING = os.environ.get("HEAVY_REASONING_MODEL", "gemini-3.5-flash") # NOT 2.5-flash HEAVY_REASONING = os.environ.get("HEAVY_REASONING_MODEL", "gemini-3.5-flash")
# Budget hard stops (USD per task) # Budget hard stops (USD per task)
BUDGET_A = float(os.environ.get("BUDGET_A_USD_PER_TASK", "1.0")) BUDGET_LIGHT = float(os.environ.get("BUDGET_A_USD_PER_TASK", "1.0"))
BUDGET_APLUS = float(os.environ.get("HEAVY_MODE_BUDGET_USD_PER_DAY", "3.0")) BUDGET_HEAVY = float(os.environ.get("HEAVY_MODE_BUDGET_USD_PER_DAY", "3.0"))
HEAVY_MODE_ALLOWED_USERS = ["opax", "admin"] HEAVY_MODE_ALLOWED_USERS = ["opax", "admin"]
Mode = Literal["A", "A+"] Mode = Literal["light", "heavy"]
APP_NAME = "opax" APP_NAME = "opax"
def _normalize_mode(mode: str) -> str:
"""Normalize legacy mode names to light/heavy."""
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' (also accepts legacy 'A' / 'A+').")
return mapping[mode]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# RAG tool # RAG tool
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -83,29 +91,29 @@ else:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def get_models_for_mode(mode: Mode) -> dict: def get_models_for_mode(mode: Mode) -> dict:
"""Return model config dict for the given mode.""" if mode == "heavy":
if mode == "A+":
return { return {
"orchestrator": HEAVY_ORCHESTRATOR, "orchestrator": HEAVY_ORCHESTRATOR,
"subagent": HEAVY_SUBAGENT, "subagent": HEAVY_SUBAGENT,
"reasoning": HEAVY_REASONING, "reasoning": HEAVY_REASONING,
"budget_usd": BUDGET_APLUS, "budget_usd": BUDGET_HEAVY,
"multi_agent": True, "multi_agent": True,
} }
return { return {
"orchestrator": ORCHESTRATOR_MODEL, "orchestrator": ORCHESTRATOR_MODEL,
"subagent": SUBAGENT_MODEL, "subagent": SUBAGENT_MODEL,
"reasoning": REASONING_MODEL, "reasoning": REASONING_MODEL,
"budget_usd": BUDGET_A, "budget_usd": BUDGET_LIGHT,
"multi_agent": False, "multi_agent": False,
} }
def authorize_mode(user_id: str, mode: Mode) -> None: def authorize_mode(user_id: str, mode: str) -> None:
"""Raise PermissionError if user is not authorized for A+ mode.""" """Raise PermissionError if user is not authorized for heavy mode."""
if mode == "A+" and user_id not in HEAVY_MODE_ALLOWED_USERS: mode = _normalize_mode(mode)
if mode == "heavy" and user_id not in HEAVY_MODE_ALLOWED_USERS:
raise PermissionError( raise PermissionError(
f"User '{user_id}' is not authorized for A+ mode. " f"User '{user_id}' is not authorized for heavy mode. "
f"Authorized: {HEAVY_MODE_ALLOWED_USERS}" f"Authorized: {HEAVY_MODE_ALLOWED_USERS}"
) )
@ -114,10 +122,10 @@ def authorize_mode(user_id: str, mode: Mode) -> None:
# Agent factory # Agent factory
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def build_agent(mode: Mode = "A") -> Agent: def build_agent(mode: str = "light") -> Agent:
"""Build and return an ADK Agent configured for the given mode.""" mode = _normalize_mode(mode)
models = get_models_for_mode(mode) models = get_models_for_mode(mode)
mode_label = "A+ (audit+)" if mode == "A+" else "A (standard)" mode_label = "heavy" if mode == "heavy" else "light"
instruction = ( instruction = (
f"Du er OPAX — OSVauco AI-agent. " f"Du er OPAX — OSVauco AI-agent. "
@ -137,10 +145,10 @@ def build_agent(mode: Mode = "A") -> Agent:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Default root agent (A mode) — used by ADK runner and Cloud Run # Default root agent (light mode) — used by ADK runner and Cloud Run
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
root_agent = build_agent(mode="A") root_agent = build_agent(mode="light")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -151,9 +159,9 @@ async def _run_async(
message: str, message: str,
user_id: str, user_id: str,
session_id: str, session_id: str,
mode: Mode, mode: str,
) -> str: ) -> str:
"""Async implementation using ADK 1.x Runner pattern.""" mode = _normalize_mode(mode)
agent = build_agent(mode=mode) agent = build_agent(mode=mode)
session_service = InMemorySessionService() session_service = InMemorySessionService()
@ -190,27 +198,26 @@ def run(
message: str, message: str,
user_id: str = "opax", user_id: str = "opax",
session_id: str = "default", session_id: str = "default",
mode: str = "A", mode: str = "light",
) -> str: ) -> str:
""" """
Handle a single request. Handle a single request.
Args: Args:
message: User message / query. message: User message / query.
user_id: Caller identity. A+ requires 'opax' or 'admin'. user_id: Caller identity. heavy requires 'opax' or 'admin'.
session_id: Session identifier for conversation continuity. session_id: Session identifier for conversation continuity.
mode: 'A' (standard) or 'A+' (audit+). mode: 'light' (standard) or 'heavy' (audit+).
Legacy values 'A' and 'A+' are accepted and normalized.
Returns: Returns:
Agent response as a string. Agent response as a string.
Raises: Raises:
PermissionError: If user_id is not authorized for A+ mode. PermissionError: If user_id is not authorized for heavy mode.
ValueError: If mode is not 'A' or 'A+'. ValueError: If mode is not recognized.
""" """
if mode not in ("A", "A+"): mode = _normalize_mode(mode)
raise ValueError(f"Invalid mode '{mode}'. Must be 'A' or 'A+'.")
authorize_mode(user_id, mode) authorize_mode(user_id, mode)
return asyncio.run(_run_async( return asyncio.run(_run_async(
@ -229,8 +236,8 @@ if __name__ == "__main__":
import sys import sys
logging.basicConfig(level=logging.WARNING) logging.basicConfig(level=logging.WARNING)
query = sys.argv[1] if len(sys.argv) > 1 else "Hva er OPAX A+ mode?" query = sys.argv[1] if len(sys.argv) > 1 else "Hva er OPAX heavy mode?"
requested_mode = sys.argv[2] if len(sys.argv) > 2 else "A" requested_mode = sys.argv[2] if len(sys.argv) > 2 else "light"
print(f"Mode : {requested_mode}") print(f"Mode : {requested_mode}")
print(f"Query: {query}") print(f"Query: {query}")

79
main.py
View File

@ -8,68 +8,51 @@ ML-1 changes:
- /run pushes last_result + last_duration_s to AgentStateStore - /run pushes last_result + last_duration_s to AgentStateStore
- /run/dag accepts a list of messages and runs them as a parallel Dask DAG - /run/dag accepts a list of messages and runs them as a parallel Dask DAG
- /state and /telemetry endpoints expose live ML observability - /state and /telemetry endpoints expose live ML observability
Modes: light (default) | heavy
""" """
import os import os
import sys import sys
import time import time
# Ensure agents/core-logic is on the path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "agents", "core-logic")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "agents", "core-logic"))
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List, Optional from typing import List
from agent import run, authorize_mode # agents/core-logic/agent.py from agent import run, authorize_mode
from ml import ( from ml import build_agent_dag, execute_dag, get_store, log_agent_call
build_agent_dag,
execute_dag,
get_store,
log_agent_call,
)
from ml.telemetry import log_dag_execution from ml.telemetry import log_dag_execution
app = FastAPI(title="OSVauco OPAX Agent") app = FastAPI(title="OSVauco OPAX Agent")
AGENT_ID = "opax-core" AGENT_ID = "opax-core"
# ---------------------------------------------------------------------------
# Request / Response models
# ---------------------------------------------------------------------------
class RunRequest(BaseModel): class RunRequest(BaseModel):
message: str message: str
user_id: str = "opax" user_id: str = "opax"
session_id: str = "default" session_id: str = "default"
mode: str = "A" mode: str = "light"
class DagRequest(BaseModel): class DagRequest(BaseModel):
messages: List[str] = Field(..., description="Liste av meldinger som kjøres parallelt") messages: List[str] = Field(..., description="Liste av meldinger som kjøres parallelt")
user_id: str = "opax" user_id: str = "opax"
session_id: str = "default" session_id: str = "default"
mode: str = "A" mode: str = "light"
scheduler: str = Field("threads", description="Dask scheduler: synchronous | threads | processes") scheduler: str = Field("threads", description="Dask scheduler: synchronous | threads | processes")
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
@app.get("/health") @app.get("/health")
def health(): def health():
return {"status": "ok"} return {"status": "ok"}
# ---------------------------------------------------------------------------
# /run — enkelt agent-kall med telemetri + state store (ML-1)
# ---------------------------------------------------------------------------
@app.post("/run") @app.post("/run")
def run_agent(req: RunRequest): def run_agent(req: RunRequest):
authorize_mode(req.user_id, req.mode) # raises PermissionError if unauthorized authorize_mode(req.user_id, req.mode)
store = get_store() store = get_store()
start = time.monotonic() start = time.monotonic()
@ -93,20 +76,18 @@ def run_agent(req: RunRequest):
finally: finally:
duration = round(time.monotonic() - start, 3) duration = round(time.monotonic() - start, 3)
success = error_msg is None success = error_msg is None
model = "gemini-2.0-flash" if req.mode in ("light", "A") else "gemini-2.5-pro"
# ML-1: logg til JSONL-telemetri
log_agent_call( log_agent_call(
agent_id=AGENT_ID, agent_id=AGENT_ID,
input_payload={"message": req.message, "mode": req.mode}, input_payload={"message": req.message, "mode": req.mode},
output=response, output=response,
model_used="gemini-2.0-flash" if req.mode == "A" else "gemini-2.5-pro", model_used=model,
mode=req.mode, mode=req.mode,
duration_s=duration, duration_s=duration,
success=success, success=success,
error=error_msg, error=error_msg,
) )
# ML-1: push til Parameter Server state store
store.push(AGENT_ID, "last_duration_s", duration) store.push(AGENT_ID, "last_duration_s", duration)
store.push(AGENT_ID, "last_mode", req.mode) store.push(AGENT_ID, "last_mode", req.mode)
store.push(AGENT_ID, "last_success", success) store.push(AGENT_ID, "last_success", success)
@ -116,24 +97,14 @@ def run_agent(req: RunRequest):
return {"response": response} return {"response": response}
# ---------------------------------------------------------------------------
# /run/dag — parallell Dask DAG over flere meldinger (ML-1)
# ---------------------------------------------------------------------------
@app.post("/run/dag") @app.post("/run/dag")
def run_dag(req: DagRequest): def run_dag(req: DagRequest):
"""
Kjører en liste av meldinger som parallelle Dask-tasks.
Hver melding behandles av en separat agent-wrapper.
Returnerer alle svar med timing og suksess-status.
"""
try: try:
authorize_mode(req.user_id, req.mode) authorize_mode(req.user_id, req.mode)
except PermissionError as e: except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e)) raise HTTPException(status_code=403, detail=str(e))
def make_agent_fn(msg: str, idx: int): def make_agent_fn(msg: str, idx: int):
"""Lukker over msg + idx for å lage en unik callable per melding."""
def _agent_fn(payload: dict): def _agent_fn(payload: dict):
return run( return run(
message=msg, message=msg,
@ -144,12 +115,7 @@ def run_dag(req: DagRequest):
_agent_fn.__name__ = f"opax-dag-{idx}" _agent_fn.__name__ = f"opax-dag-{idx}"
return _agent_fn return _agent_fn
payload = { payload = {"user_id": req.user_id, "session_id": req.session_id, "mode": req.mode}
"user_id": req.user_id,
"session_id": req.session_id,
"mode": req.mode,
}
agent_fns = [make_agent_fn(msg, i) for i, msg in enumerate(req.messages)] agent_fns = [make_agent_fn(msg, i) for i, msg in enumerate(req.messages)]
agent_ids = [f"opax-dag-{i}" for i in range(len(req.messages))] agent_ids = [f"opax-dag-{i}" for i in range(len(req.messages))]
@ -158,13 +124,8 @@ def run_dag(req: DagRequest):
results = execute_dag(tasks, scheduler=req.scheduler) results = execute_dag(tasks, scheduler=req.scheduler)
total_dur = round(time.monotonic() - start, 3) total_dur = round(time.monotonic() - start, 3)
# ML-1: logg DAG-kjøring og push aggregert state
store = get_store() store = get_store()
log_dag_execution( log_dag_execution(dag_id=req.session_id, agent_results=results, total_duration_s=total_dur)
dag_id=req.session_id,
agent_results=results,
total_duration_s=total_dur,
)
store.push(AGENT_ID, "last_dag_total_duration_s", total_dur) store.push(AGENT_ID, "last_dag_total_duration_s", total_dur)
store.push(AGENT_ID, "last_dag_agent_count", len(results)) store.push(AGENT_ID, "last_dag_agent_count", len(results))
store.push(AGENT_ID, "last_dag_success_count", sum(1 for r in results if r["success"])) store.push(AGENT_ID, "last_dag_success_count", sum(1 for r in results if r["success"]))
@ -185,42 +146,26 @@ def run_dag(req: DagRequest):
} }
# ---------------------------------------------------------------------------
# /state — live Parameter Server state snapshot (ML-1 observability)
# ---------------------------------------------------------------------------
@app.get("/state") @app.get("/state")
def get_state(): def get_state():
"""Returnerer nåværende AgentStateStore snapshot."""
return get_store().snapshot() return get_store().snapshot()
@app.get("/state/agents") @app.get("/state/agents")
def list_agents(): def list_agents():
"""Returnerer alle agent-IDer som har pushet state."""
return {"agents": get_store().list_agents()} return {"agents": get_store().list_agents()}
@app.get("/state/aggregate/{key}") @app.get("/state/aggregate/{key}")
def aggregate_key(key: str): def aggregate_key(key: str):
"""Aggregerer alle agenter sine verdier for en gitt nøkkel."""
return get_store().aggregate(key) return get_store().aggregate(key)
# ---------------------------------------------------------------------------
# /telemetry — historikk-log (ML-1 observability)
# ---------------------------------------------------------------------------
@app.get("/telemetry/history") @app.get("/telemetry/history")
def telemetry_history(limit: int = 50): def telemetry_history(limit: int = 50):
"""Returnerer siste N state-operasjoner fra AgentStateStore historikk."""
return {"history": get_store().history(limit=limit)} return {"history": get_store().history(limit=limit)}
# ---------------------------------------------------------------------------
# Startup
# ---------------------------------------------------------------------------
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
port = int(os.environ.get("PORT", 8080)) port = int(os.environ.get("PORT", 8080))