Spaces:
Sleeping
Sleeping
| """ | |
| Accordis Inference Script | |
| =================================== | |
| MANDATORY environment variables: | |
| ACCORDIS_ADAPTER Adapter to use: "simulated" (default) or "librabft". | |
| LLM selection: | |
| API_KEY / HF_TOKEN → OpenAI compatible model | |
| Optional: | |
| ACCORDIS_TASKS Task difficulty: "easy" (default), "medium", "hard". | |
| ACCORDIS_MAX_STEPS Override maximum steps per episode (default from task). | |
| STDOUT FORMAT | |
| - The script emits exactly three line types to stdout, in this order: | |
| [START] task=<task_name> adapter=<adapter> | |
| [STEP] step=<n> reward=<r> total=<cumulative> done=<True|False> | |
| [END] steps=<n> total_reward=<r> score=<s> | |
| Rules: | |
| - One [START] line at episode begin. | |
| - One [STEP] line per step, immediately after env.step() returns. | |
| - One [END] line after episode ends, always emitted (even on exception). | |
| - reward and total_reward are formatted to 1 decimal place. | |
| - score is formatted to 2 decimal places. | |
| - done is a Python bool string (True or False). | |
| - All fields on a single line with no newlines within a line. | |
| Example: | |
| [START] task=easy adapter=simulated | |
| [STEP] step=1 reward=-1.0 total=-1.0 done=False | |
| [STEP] step=2 reward=59.0 total=58.0 done=False | |
| ... | |
| [END] steps=87 total_reward=340.0 score=0.73 | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import asyncio | |
| import argparse | |
| import textwrap | |
| import subprocess | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| from typing import Any, Dict, Optional | |
| from openai import OpenAI | |
| from models import ( | |
| AccordisAction, | |
| AccordisObservation, | |
| MultiNodeAction, | |
| MultiNodeObservation, | |
| NodeID, | |
| STATIC_BASELINE_CONFIG, | |
| ) | |
| from client import AccordisEnvironment | |
| from server.accordis_environment import AccordisEnvironment as ServerAccordisEnvironment | |
| from server.tasks.task_easy import EasyTask | |
| from server.tasks.task_medium import MediumTask | |
| from server.tasks.task_hard import HardTask | |
| SYSTEM_PROMPT = textwrap.dedent( | |
| """ | |
| You are a control policy that tunes Byzantine Fault-Tolerant (BFT) consensus | |
| parameters for a cluster of honest nodes. At each step you receive JSON | |
| observations for every honest node and must return a JSON config for each | |
| node. Your goal: maximise transaction throughput, keep view changes low, | |
| and stay stable. | |
| ════════════════════════════════════════════════════════════════════════════ | |
| FIRST PRINCIPLE — STABILITY BEATS CLEVERNESS | |
| ════════════════════════════════════════════════════════════════════════════ | |
| Oscillating parameters step-to-step DESTROYS throughput. The cluster needs | |
| several consecutive steps with the SAME config to build a commit pipeline. | |
| Default behaviour: REPEAT THE PREVIOUS STEP'S CONFIG. Only change a value | |
| when a specific decision rule below tells you to. Never change more than | |
| TWO parameters in a single step. | |
| ════════════════════════════════════════════════════════════════════════════ | |
| DEFAULT STARTING CONFIG (use this on step 0 for every node) | |
| ════════════════════════════════════════════════════════════════════════════ | |
| view_timeout_ms = 1000 | |
| pipeline_depth = 4 | |
| replication_batch_size = 256 | |
| equivocation_threshold = 5 | |
| vote_aggregation_timeout_ms = 800 | |
| These defaults are SAFE under both clean and adversarial conditions — | |
| start here on step 0 and only adjust based on the rules below. | |
| ════════════════════════════════════════════════════════════════════════════ | |
| TIMING MODEL — INTERNALISE THIS | |
| ════════════════════════════════════════════════════════════════════════════ | |
| 1 environment step ≈ 50 ms of simulated wall-clock time. Episodes have a | |
| bounded step budget. view_timeout_ms is the wall-clock time the cluster | |
| waits for a leader before triggering a view change. If view_timeout_ms | |
| is set close to the remaining step budget, NO view change can fire and | |
| a Byzantine leader stall deadlocks the rest of the episode. | |
| HARD CEILING: never set view_timeout_ms above 1500 ms. The bound allows | |
| up to 3000 ms but using it is almost always a mistake — it leaves no | |
| room for the pacemaker to recover from an unresponsive leader. | |
| view_stuck_ms reports how long THIS NODE has been waiting in its current | |
| view. It is in the same unit as view_timeout_ms — compare them directly. | |
| ════════════════════════════════════════════════════════════════════════════ | |
| DECISION RULES — apply in this order, top to bottom, at most one fires | |
| ════════════════════════════════════════════════════════════════════════════ | |
| RULE 1 — LEADER STALL (highest priority) | |
| IF any node has view_stuck_ms > 0.6 × view_timeout_ms | |
| AND qc_miss_streak > 5 | |
| THEN halve view_timeout_ms (floor at 400 ms) on EVERY node. | |
| WHY: the current leader is unresponsive; rotating faster lets the | |
| cluster pick a non-Byzantine leader. | |
| RULE 2 — DELAY ATTACK | |
| IF qc_miss_streak ≥ 3 on any node | |
| AND view_stuck_ms is NOT growing fast (no leader stall) | |
| THEN raise vote_aggregation_timeout_ms by 200 ms (cap at 1000) on | |
| EVERY node. Do NOT touch view_timeout_ms. | |
| WHY: the leader is alive but votes are arriving late under | |
| SELECTIVE_DELAY / ADAPTIVE_MIRROR. Bigger vote window = more QCs. | |
| RULE 3 — EQUIVOCATION DETECTED | |
| IF any peer in suspected_byzantine is true | |
| THEN keep replication_batch_size ≥ 128, and lower equivocation_threshold | |
| by 1 (floor at 2). Do NOT lower batch_size below 128 even under attack. | |
| WHY: small batches throttle throughput; the right defence is to detect | |
| attackers earlier, not to ship less data per round. | |
| RULE 4 — THROUGHPUT RAMP (when no rule above fired) | |
| IF cluster commit_tps is stable and > 0 | |
| AND no rule above fired | |
| THEN raise replication_batch_size by 64 (cap at 512) on EVERY node. | |
| WHY: in a healthy cluster the only way to drain the pool faster is to | |
| ship more txns per block. | |
| RULE 5 — DEFAULT | |
| IF none of the above fired, REPEAT the previous step's config exactly. | |
| Stability is the default action, not a fallback. | |
| ════════════════════════════════════════════════════════════════════════════ | |
| HARD CONSTRAINTS — NEVER VIOLATE | |
| ════════════════════════════════════════════════════════════════════════════ | |
| - replication_batch_size ≥ 64 (lower values throttle throughput) | |
| - vote_aggregation_timeout_ms < view_timeout_ms / 2 (env will clamp) | |
| - view_timeout_ms ≤ 1500 (soft cap; bound allows 3000 but don't) | |
| - Apply the SAME config to every node unless a node-specific rule fires | |
| (no current rule is node-specific — use uniform configs) | |
| ════════════════════════════════════════════════════════════════════════════ | |
| PARAMETER RANGES (env clamps to these) | |
| ════════════════════════════════════════════════════════════════════════════ | |
| view_timeout_ms : 200 – 3000 (target ≤ 1500) | |
| pipeline_depth : 1 – 8 (target 4) | |
| replication_batch_size : 1 – 512 (target 256–512) | |
| equivocation_threshold : 1 – 15 (target 3–5) | |
| vote_aggregation_timeout_ms : 50 – 1000 (target 600–1000, must be < view_timeout_ms / 2) | |
| ════════════════════════════════════════════════════════════════════════════ | |
| OBSERVATION FORMAT | |
| ════════════════════════════════════════════════════════════════════════════ | |
| Each step's observation is a JSON object with two top-level keys: | |
| - "cluster_min_pending": int — minimum pending_txns across all honest | |
| nodes. The episode is close to ending when this approaches 0. | |
| - "nodes": object keyed by node_id, each containing local metrics: | |
| role, view, commit_tps, pending_txns, pipeline_utilisation, | |
| qc_miss_streak, view_changes_recent, view_stuck_ms, | |
| suspected_byzantine, current_config. | |
| PARTIAL OBSERVABILITY: nodes' pending_txns values diverge because QC | |
| messages propagate with latency. Use cluster_min_pending as the | |
| cluster-wide progress signal. The leader has the freshest view. | |
| ════════════════════════════════════════════════════════════════════════════ | |
| RESPONSE FORMAT | |
| ════════════════════════════════════════════════════════════════════════════ | |
| Return a FLAT JSON object keyed by node_id — do NOT nest under "nodes" | |
| or any other wrapper. Include EVERY node_id present in the observation. | |
| Respond with ONLY valid JSON — no prose, no code fences, no markdown. | |
| Example (4-node cluster, step 0 with the default config): | |
| { | |
| "node_0": {"view_timeout_ms": 1000, "pipeline_depth": 4, "replication_batch_size": 256, "equivocation_threshold": 5, "vote_aggregation_timeout_ms": 800}, | |
| "node_1": {"view_timeout_ms": 1000, "pipeline_depth": 4, "replication_batch_size": 256, "equivocation_threshold": 5, "vote_aggregation_timeout_ms": 800}, | |
| "node_2": {"view_timeout_ms": 1000, "pipeline_depth": 4, "replication_batch_size": 256, "equivocation_threshold": 5, "vote_aggregation_timeout_ms": 800}, | |
| "node_3": {"view_timeout_ms": 1000, "pipeline_depth": 4, "replication_batch_size": 256, "equivocation_threshold": 5, "vote_aggregation_timeout_ms": 800} | |
| } | |
| """ | |
| ).strip() | |
| IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME") or os.getenv("IMAGE_NAME") # Docker image name for OpenEnv runtime | |
| ACCORDIS_BASE_URL = os.getenv("ACCORDIS_BASE_URL") or os.getenv("BASE_URL") | |
| SUCCESS_SCORE_THRESHOLD = 0.1 | |
| def _format_exception(exc: Exception) -> str: | |
| return f"{exc.__class__.__name__}: {exc}" | |
| def _build_task_result( | |
| task_name: str, | |
| *, | |
| steps: int = 0, | |
| total_reward: float = 0.0, | |
| score: float = 0.0, | |
| status: str = "success", | |
| error: Optional[str] = None, | |
| stage: Optional[str] = None, | |
| ) -> dict: | |
| result = { | |
| "task": task_name, | |
| "status": status, | |
| "steps": steps, | |
| "total_reward": round(total_reward, 2), | |
| "score": round(score, 4), | |
| } | |
| if error is not None: | |
| result["error"] = error | |
| if stage is not None: | |
| result["stage"] = stage | |
| return result | |
| def _coerce_config_int(raw_value: Any, fallback: int, field_name: str, node_id: NodeID) -> int: | |
| try: | |
| return int(raw_value) | |
| except (TypeError, ValueError): | |
| print( | |
| f"[WARN] Invalid LLM value for node={node_id} field={field_name}: " | |
| f"{raw_value!r}; using previous value {fallback}" | |
| ) | |
| return fallback | |
| class OpenAIClient(): | |
| """OpenAI chat-completion client with Hugging Face model compatibility.""" | |
| def __init__(self, model: str) -> None: | |
| self._BASE_URL = os.getenv("API_BASE_URL", "https://huggingface.co/proxy/router.huggingface.co/v1") | |
| self._MODEL = model | |
| self._API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY") | |
| if not self._API_KEY: | |
| raise EnvironmentError( | |
| "No API key configured. Set HF_TOKEN or API_KEY before running inference." | |
| ) | |
| self._client = OpenAI( | |
| base_url=self._BASE_URL, | |
| api_key=self._API_KEY | |
| ) | |
| def complete(self, system: str, user: str) -> str: | |
| resp = self._client.chat.completions.create( | |
| model=self._MODEL, | |
| messages=[ | |
| {"role": "system", "content": system}, | |
| {"role": "user", "content": user}, | |
| ], | |
| ) | |
| return (resp.choices[0].message.content or "").strip() | |
| async def close(self) -> None: | |
| close = getattr(self._client, "close", None) | |
| if not callable(close): | |
| return | |
| result = close() | |
| if asyncio.iscoroutine(result): | |
| await result | |
| # ── Logging ──────────────────────────────────────────────────────────────────── | |
| def log_start(task: str, adapter: str) -> None: | |
| print(f"[START] task={task} adapter={adapter}") | |
| def log_step(step: int, action: any, obs: any, reward: float, total: float, done: bool) -> None: | |
| print( | |
| f"[STEP] step={step} action={action} observation={obs} reward={reward:.2f} total={total:.1f} done={done}" | |
| ) | |
| def log_end(steps: int, total_reward: float, score: float) -> None: | |
| print( | |
| f"[END] steps={steps} total_reward={total_reward:.1f} score={score:.2f}" | |
| ) | |
| def _select_task(name: str): | |
| name = name.lower() | |
| if name == "easy": | |
| return EasyTask(curriculum_level=1) | |
| if name == "medium": | |
| return MediumTask() | |
| if name == "hard": | |
| return HardTask() | |
| raise ValueError(f"Unsupported task {name!r}. Expected one of: easy, medium, hard.") | |
| def _obs_to_dict(obs_nodes: Dict[NodeID, AccordisObservation]) -> str: | |
| """Compact JSON summary of all honest-node observations for the LLM. | |
| Includes a top-level cluster_min_pending field: the minimum pending_txns | |
| across all honest nodes. This is derived entirely from per-node observable | |
| data and gives the best lower bound on remaining cluster work. | |
| """ | |
| node_summary = {} | |
| for nid, o in obs_nodes.items(): | |
| node_summary[nid] = { | |
| "role": o.current_role.value, | |
| "view": o.current_view, | |
| "commit_tps": round(o.commit_throughput_tps, 2), | |
| "pending_txns": o.pending_txn_count, | |
| "pipeline_utilisation": round(o.pipeline_utilisation, 2), | |
| "qc_miss_streak": o.qc_formation_miss_streak, | |
| "view_changes_recent": o.view_change_count_recent, | |
| "view_stuck_ms": o.view_stuck_ms, | |
| "suspected_byzantine": o.suspected_byzantine, | |
| "current_config": { | |
| "view_timeout_ms": o.current_config.view_timeout_ms, | |
| "pipeline_depth": o.current_config.pipeline_depth, | |
| "replication_batch_size": o.current_config.replication_batch_size, | |
| "equivocation_threshold": o.current_config.equivocation_threshold, | |
| "vote_aggregation_timeout_ms": o.current_config.vote_aggregation_timeout_ms, | |
| }, | |
| } | |
| cluster_min_pending = min((o.pending_txn_count for o in obs_nodes.values()), default=0) | |
| summary = {"cluster_min_pending": cluster_min_pending, "nodes": node_summary} | |
| return json.dumps(summary, indent=2) | |
| def _get_static_action(obs: MultiNodeObservation) -> MultiNodeAction: | |
| """Return STATIC_BASELINE_CONFIG for every honest node — no changes.""" | |
| node_actions: Dict[NodeID, AccordisAction] = {} | |
| for nid in obs.nodes: | |
| node_actions[nid] = AccordisAction( | |
| node_id=nid, | |
| view_timeout_ms=STATIC_BASELINE_CONFIG.view_timeout_ms, | |
| pipeline_depth=STATIC_BASELINE_CONFIG.pipeline_depth, | |
| replication_batch_size=STATIC_BASELINE_CONFIG.replication_batch_size, | |
| equivocation_threshold=STATIC_BASELINE_CONFIG.equivocation_threshold, | |
| vote_aggregation_timeout_ms=STATIC_BASELINE_CONFIG.vote_aggregation_timeout_ms, | |
| ) | |
| return MultiNodeAction(nodes=node_actions) | |
| async def _get_llm_action( | |
| llm: Optional[OpenAIClient], | |
| step: int, | |
| obs: MultiNodeObservation, | |
| last_reward: float, | |
| ) -> MultiNodeAction: | |
| node_ids = list(obs.nodes.keys()) | |
| user_prompt = textwrap.dedent( | |
| f""" | |
| Step: {step} | |
| Last reward: {last_reward:.2f} | |
| Current observations: | |
| {_obs_to_dict(obs.nodes)} | |
| Return your BFT configuration for every node_id found under "nodes" above. | |
| """ | |
| ).strip() | |
| raw_configs: Dict[str, Any] = {} | |
| try: | |
| if llm is None: | |
| raise RuntimeError("LLM client is not available for a non-static provider.") | |
| text = llm.complete(SYSTEM_PROMPT, user_prompt) | |
| parsed = json.loads(text) | |
| if not isinstance(parsed, dict): | |
| raise ValueError(f"Expected top-level JSON object, got {type(parsed).__name__}") | |
| raw_configs = parsed | |
| except Exception as exc: | |
| print(f"[WARN] LLM request or JSON parse failed at step={step}: {_format_exception(exc)}") | |
| node_actions: Dict[NodeID, AccordisAction] = {} | |
| for nid in node_ids: | |
| cfg_raw = raw_configs.get(nid, {}) | |
| prev_cfg = obs.nodes[nid].current_config | |
| if not isinstance(cfg_raw, dict): | |
| print( | |
| f"[WARN] Invalid config payload for node={nid}: expected object, " | |
| f"got {type(cfg_raw).__name__}; using previous config" | |
| ) | |
| cfg_raw = {} | |
| node_actions[nid] = AccordisAction( | |
| node_id=nid, | |
| view_timeout_ms=_coerce_config_int( | |
| cfg_raw.get("view_timeout_ms", prev_cfg.view_timeout_ms), | |
| prev_cfg.view_timeout_ms, | |
| "view_timeout_ms", | |
| nid, | |
| ), | |
| pipeline_depth=_coerce_config_int( | |
| cfg_raw.get("pipeline_depth", prev_cfg.pipeline_depth), | |
| prev_cfg.pipeline_depth, | |
| "pipeline_depth", | |
| nid, | |
| ), | |
| replication_batch_size=_coerce_config_int( | |
| cfg_raw.get("replication_batch_size", prev_cfg.replication_batch_size), | |
| prev_cfg.replication_batch_size, | |
| "replication_batch_size", | |
| nid, | |
| ), | |
| equivocation_threshold=_coerce_config_int( | |
| cfg_raw.get("equivocation_threshold", prev_cfg.equivocation_threshold), | |
| prev_cfg.equivocation_threshold, | |
| "equivocation_threshold", | |
| nid, | |
| ), | |
| vote_aggregation_timeout_ms=_coerce_config_int( | |
| cfg_raw.get("vote_aggregation_timeout_ms", prev_cfg.vote_aggregation_timeout_ms), | |
| prev_cfg.vote_aggregation_timeout_ms, | |
| "vote_aggregation_timeout_ms", | |
| nid, | |
| ), | |
| ) | |
| return MultiNodeAction(nodes=node_actions) | |
| async def _run_single_task( | |
| task_name: str, | |
| provider: str, | |
| model: Optional[str], | |
| ) -> dict: | |
| """Run one full episode for the given task and return a result dict.""" | |
| try: | |
| task = _select_task(task_name) | |
| conds = task.get_initial_conditions() | |
| except Exception as exc: | |
| return _build_task_result( | |
| task_name, | |
| status="error", | |
| error=_format_exception(exc), | |
| stage="task_setup", | |
| ) | |
| llm: Optional[OpenAIClient] = None | |
| try: | |
| if provider != "static": | |
| llm = _create_llm_client(provider=provider, model=model) | |
| except Exception as exc: | |
| return _build_task_result( | |
| task_name, | |
| status="error", | |
| error=_format_exception(exc), | |
| stage="llm_init", | |
| ) | |
| try: | |
| env = await _create_environment_client() | |
| except Exception as exc: | |
| print(f"[WARN] Client environment unavailable; using in-process server fallback: {exc}") | |
| return await _run_single_task_server(task_name, provider, model, llm=llm) | |
| log_start(task_name, os.getenv("ACCORDIS_ADAPTER", "simulated")) | |
| total_reward = 0.0 | |
| steps_taken = 0 | |
| score = 0.0 | |
| run_error: Optional[str] = None | |
| run_stage: Optional[str] = None | |
| try: | |
| reset_result = await env.reset(**conds) | |
| obs: MultiNodeObservation = reset_result.observation | |
| last_reward = 0.0 | |
| episode_max_steps = conds.get("max_steps", int(os.getenv("ACCORDIS_MAX_STEPS", "100"))) | |
| for step in range(1, episode_max_steps + 1): | |
| if provider == "static": | |
| action = _get_static_action(obs) | |
| else: | |
| action = await _get_llm_action(llm, step, obs, last_reward) | |
| step_result = await env.step(action) | |
| obs = step_result.observation | |
| reward = float(step_result.reward) if step_result.reward is not None else 0.0 | |
| done = bool(step_result.done) | |
| last_reward = reward | |
| total_reward += reward | |
| steps_taken = step | |
| log_step(step=step, action=action, obs=obs, reward=reward, total=total_reward, done=done) | |
| if done: | |
| break | |
| try: | |
| state = await env.state() | |
| if state.episode_log is not None: | |
| score = task.grade(state.episode_log) | |
| except Exception as exc: | |
| run_error = _format_exception(exc) | |
| run_stage = "grading" | |
| print(f"[ERROR] Failed to fetch or grade task state: {run_error}") | |
| except Exception as exc: | |
| run_error = _format_exception(exc) | |
| run_stage = "episode" | |
| print(f"[ERROR] Episode error: {run_error}") | |
| finally: | |
| if llm is not None: | |
| try: | |
| await llm.close() | |
| except Exception: | |
| pass | |
| try: | |
| await env.close() | |
| except Exception as e: | |
| print(f"[DEBUG] env.close() error (container cleanup): {e}") | |
| log_end(steps=steps_taken, total_reward=total_reward, score=score) | |
| return _build_task_result( | |
| task_name, | |
| steps=steps_taken, | |
| total_reward=total_reward, | |
| score=score, | |
| status="error" if run_error else "success", | |
| error=run_error, | |
| stage=run_stage, | |
| ) | |
| async def _run_single_task_server( | |
| task_name: str, | |
| provider: str, | |
| model: Optional[str], | |
| llm: Optional[OpenAIClient] = None, | |
| ) -> dict: | |
| """Run one episode using the in-process server environment as a fallback.""" | |
| try: | |
| task = _select_task(task_name) | |
| conds = task.get_initial_conditions() | |
| except Exception as exc: | |
| return _build_task_result( | |
| task_name, | |
| status="error", | |
| error=_format_exception(exc), | |
| stage="task_setup", | |
| ) | |
| env = ServerAccordisEnvironment() | |
| owns_llm = llm is None and provider != "static" | |
| if llm is None and provider != "static": | |
| try: | |
| llm = _create_llm_client(provider=provider, model=model) | |
| except Exception as exc: | |
| return _build_task_result( | |
| task_name, | |
| status="error", | |
| error=_format_exception(exc), | |
| stage="llm_init", | |
| ) | |
| log_start(task_name, os.getenv("ACCORDIS_ADAPTER", "simulated")) | |
| total_reward = 0.0 | |
| steps_taken = 0 | |
| score = 0.0 | |
| run_error: Optional[str] = None | |
| run_stage: Optional[str] = None | |
| try: | |
| obs: MultiNodeObservation = env.reset(**conds) | |
| last_reward = 0.0 | |
| episode_max_steps = conds.get("max_steps", int(os.getenv("ACCORDIS_MAX_STEPS", "100"))) | |
| for step in range(1, episode_max_steps + 1): | |
| if provider == "static": | |
| action = _get_static_action(obs) | |
| else: | |
| action = await _get_llm_action(llm, step, obs, last_reward) | |
| obs = env.step(action) | |
| reward = float(obs.reward) if obs.reward is not None else 0.0 | |
| done = bool(obs.done) | |
| last_reward = reward | |
| total_reward += reward | |
| steps_taken = step | |
| log_step(step=step, action=action, obs=obs, reward=reward, total=total_reward, done=done) | |
| if done: | |
| break | |
| try: | |
| if env._episode_log is not None: | |
| score = task.grade(env._episode_log) | |
| except Exception as exc: | |
| run_error = _format_exception(exc) | |
| run_stage = "grading" | |
| print(f"[ERROR] Failed to grade fallback episode: {run_error}") | |
| except Exception as exc: | |
| run_error = _format_exception(exc) | |
| run_stage = "episode" | |
| print(f"[ERROR] Fallback episode error: {run_error}") | |
| finally: | |
| if owns_llm and llm is not None: | |
| try: | |
| await llm.close() | |
| except Exception: | |
| pass | |
| try: | |
| close = getattr(env, "close", None) | |
| if callable(close): | |
| close() | |
| except Exception: | |
| pass | |
| log_end(steps=steps_taken, total_reward=total_reward, score=score) | |
| return _build_task_result( | |
| task_name, | |
| steps=steps_taken, | |
| total_reward=total_reward, | |
| score=score, | |
| status="error" if run_error else "success", | |
| error=run_error, | |
| stage=run_stage, | |
| ) | |
| async def _create_environment_client() -> AccordisEnvironment: | |
| """Create an environment client from either Docker or a running server.""" | |
| if IMAGE_NAME: | |
| print(f"Using Docker image: {IMAGE_NAME}") | |
| try: | |
| return await AccordisEnvironment.from_docker_image(image=IMAGE_NAME) | |
| except Exception as exc: | |
| if ACCORDIS_BASE_URL: | |
| print( | |
| "Docker runtime unavailable; falling back to " | |
| f"ACCORDIS_BASE_URL={ACCORDIS_BASE_URL}" | |
| ) | |
| return AccordisEnvironment(base_url=ACCORDIS_BASE_URL) | |
| message = ( | |
| "Failed to start the Accordis environment from Docker image " | |
| f"{IMAGE_NAME!r}. Make sure Docker is installed and the daemon " | |
| "is running, or set ACCORDIS_BASE_URL=http://localhost:8000 to " | |
| "use an already running server." | |
| ) | |
| if isinstance(exc, subprocess.CalledProcessError): | |
| detail = f" docker command failed: {' '.join(exc.cmd)} (exit {exc.returncode})." | |
| else: | |
| detail = f" original error: {exc}" | |
| raise RuntimeError(message + detail) from exc | |
| if ACCORDIS_BASE_URL: | |
| print(f"Using running Accordis server: {ACCORDIS_BASE_URL}") | |
| return AccordisEnvironment(base_url=ACCORDIS_BASE_URL) | |
| raise ValueError( | |
| "No environment target configured. Set IMAGE_NAME to use Docker, or " | |
| "set ACCORDIS_BASE_URL=http://localhost:8000 to use a running Accordis server." | |
| ) | |
| def _create_llm_client(provider: str, model: Optional[str]) -> OpenAIClient: | |
| """Create the right LLM client for the selected provider.""" | |
| if model is None: | |
| raise ValueError("model must be set when provider is not 'static'") | |
| if provider == "huggingface" or provider == "openai": | |
| return OpenAIClient(model=model) | |
| raise ValueError( | |
| f"Unsupported provider {provider!r}. Expected one of: static, huggingface, openai." | |
| ) | |
| async def inference( | |
| provider: str, | |
| model: Optional[str], | |
| tasks: Optional[list[str]] = None, | |
| ) -> dict: | |
| """Run baseline evaluation across one or more task difficulties. | |
| Args: | |
| tasks: List of task names to run. Defaults to all three if None. | |
| provider: Inference provider — "static" or "openai". | |
| model: LLM model name (required when provider is not "static"). | |
| Returns: | |
| A dict with status and per-task results. | |
| """ | |
| if tasks is None: | |
| tasks = ["easy", "medium", "hard"] | |
| results = {} | |
| for task_name in tasks: | |
| try: | |
| results[task_name] = await _run_single_task(task_name, provider, model) | |
| except Exception as exc: | |
| results[task_name] = _build_task_result( | |
| task_name, | |
| status="error", | |
| error=_format_exception(exc), | |
| stage="task_runner", | |
| ) | |
| task_statuses = [result["status"] for result in results.values()] | |
| if task_statuses and all(status == "success" for status in task_statuses): | |
| overall_status = "success" | |
| elif task_statuses and any(status == "success" for status in task_statuses): | |
| overall_status = "partial_success" | |
| else: | |
| overall_status = "error" | |
| inference_result = { | |
| "status": overall_status, | |
| "provider": provider, | |
| "tasks": tasks, | |
| "data": results, | |
| } | |
| print(f"Baseline result: {json.dumps(inference_result, indent=2)}") | |
| return inference_result | |
| if __name__ == "__main__": | |
| parser = argparse.ArgumentParser(description="Run Accordis baseline evaluation") | |
| parser.add_argument( | |
| "--provider", default="huggingface", | |
| choices=["static", "huggingface", "openai"], | |
| help="LLM provider (default: huggingface)" | |
| ) | |
| parser.add_argument( | |
| "--model", default=os.getenv("MODEL_NAME", None), | |
| help="LLM model to use (default: provider-specific default)" | |
| ) | |
| parser.add_argument( | |
| "--tasks", nargs="*", default=os.getenv("ACCORDIS_TASKS", "easy,medium,hard").split(","), | |
| help="Difficulty levels to evaluate (default: all(easy,medium,hard). Ignored when --scenario is set." | |
| ) | |
| args = parser.parse_args() | |
| if args.provider == "huggingface" and args.model is None: | |
| args.model = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct") | |
| if args.provider == "openai" and args.model is None: | |
| raise ValueError("Model must be specified for OpenAI provider") | |
| result = asyncio.run(inference( | |
| provider=args.provider, | |
| model=args.model, | |
| tasks=args.tasks | |
| )) | |
| if result["status"] != "success": | |
| raise SystemExit(1) |