284 lines
9.9 KiB
Python
284 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Live long-context WebChat smoke.
|
|
|
|
Opt-in maintainer gate. Requires OPENROUTER_API_KEY and starts a temporary
|
|
gateway against a temporary state dir. The smoke verifies that WebChat accepts
|
|
and completes a turn whose current user input is far above the gateway soft
|
|
context budget, instead of returning a synchronous context-overflow refusal.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
_THIS_REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(_THIS_REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_THIS_REPO_ROOT))
|
|
|
|
from scripts.smoke_v4_phase3_router import ( # noqa: E402
|
|
REPO_ROOT,
|
|
SRC_DIR,
|
|
_free_port,
|
|
_post_json,
|
|
_read_json,
|
|
_read_turn_call_records,
|
|
_summarize_llm_request_context,
|
|
_wait_for_assistant_reply,
|
|
_write_live_gateway_config,
|
|
)
|
|
|
|
|
|
def _long_message(marker: str, chars: int) -> str:
|
|
filler = "\n".join(
|
|
f"long-context-line-{index:05d}: preserve liveness while compacting history."
|
|
for index in range(max(chars // 72, 1))
|
|
)
|
|
return (
|
|
f"Reply with one short sentence and include marker {marker}. Do not call tools.\n"
|
|
f"{filler}\n"
|
|
f"Final reminder: the reply must include {marker}."
|
|
)
|
|
|
|
|
|
def _enable_memory_distill_failure_simulation(config_path: Path) -> None:
|
|
text = config_path.read_text(encoding="utf-8")
|
|
text = text.replace(
|
|
'debug = false\n',
|
|
'debug = false\ncontext_budget_tokens = 256\n',
|
|
1,
|
|
)
|
|
text = text.replace(
|
|
'[memory]\nsource = "state"\n',
|
|
(
|
|
'[memory]\n'
|
|
'source = "state"\n'
|
|
'flush_enabled = true\n'
|
|
'flush_timeout_seconds = 0.001\n'
|
|
'flush_background_timeout_seconds = 0.001\n'
|
|
'flush_compaction_requires_safe_receipt = false\n'
|
|
'flush_compaction_safety_mode = "protect"\n'
|
|
),
|
|
1,
|
|
)
|
|
config_path.write_text(text, encoding="utf-8")
|
|
|
|
|
|
def _blocking_memory_errors(turns: list[dict[str, Any]]) -> list[str]:
|
|
blocked: list[str] = []
|
|
needles = ("flush failed", "bad json", "memory distill failed")
|
|
for turn in turns:
|
|
accepted = str(turn.get("accepted") or "").lower()
|
|
if any(needle in accepted for needle in needles):
|
|
blocked.append(str(turn.get("name") or turn.get("index") or "unknown"))
|
|
return blocked
|
|
|
|
|
|
def run_live_long_context_smoke(
|
|
*,
|
|
long_chars: int,
|
|
timeout_seconds: float,
|
|
simulate_memory_distill_failure: bool = False,
|
|
) -> dict[str, Any]:
|
|
if not os.environ.get("OPENROUTER_API_KEY"):
|
|
return {
|
|
"name": "opensquilla_gateway_live_long_context_chat",
|
|
"ok": False,
|
|
"error": "OPENROUTER_API_KEY is required",
|
|
}
|
|
|
|
port = _free_port()
|
|
live_model = os.environ.get("OPENSQUILLA_LIVE_LLM_MODEL", "").strip()
|
|
session_key = f"live-long-context:{int(time.time() * 1000)}"
|
|
turns_spec = [
|
|
{
|
|
"message": (
|
|
"Long-context baseline turn: reply with one short sentence. Do not call tools."
|
|
),
|
|
"intent": "new_chat",
|
|
"name": "baseline",
|
|
},
|
|
{
|
|
"message": _long_message("LONG_CONTEXT_CONTINUES", long_chars),
|
|
"intent": "continue",
|
|
"name": "oversized_current_input",
|
|
},
|
|
]
|
|
|
|
with tempfile.TemporaryDirectory(
|
|
prefix="opensquilla-live-long-context-",
|
|
ignore_cleanup_errors=True,
|
|
) as tmp:
|
|
tmp_path = Path(tmp)
|
|
config_path = tmp_path / "live-config.toml"
|
|
turn_log_dir = tmp_path / "turn-calls"
|
|
_write_live_gateway_config(config_path, live_model)
|
|
if simulate_memory_distill_failure:
|
|
_enable_memory_distill_failure_simulation(config_path)
|
|
|
|
env = os.environ.copy()
|
|
env["PYTHONPATH"] = str(SRC_DIR) + os.pathsep + env.get("PYTHONPATH", "")
|
|
env["OPENSQUILLA_GATEWAY_CONFIG_PATH"] = str(config_path)
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_MEMORY_DREAM_DISABLED"] = "1"
|
|
env["OPENSQUILLA_SANDBOX_SANDBOX"] = "false"
|
|
env["OPENSQUILLA_SANDBOX_SECURITY_GRADING"] = "false"
|
|
env["OPENSQUILLA_TURN_CALL_LOG"] = "1"
|
|
env["OPENSQUILLA_TURN_CALL_LOG_DIR"] = str(turn_log_dir)
|
|
if simulate_memory_distill_failure:
|
|
env["OPENSQUILLA_SESSION_FLUSH"] = "1"
|
|
|
|
proc = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"opensquilla.cli.main",
|
|
"gateway",
|
|
"run",
|
|
"--port",
|
|
str(port),
|
|
"--bind",
|
|
"127.0.0.1",
|
|
],
|
|
cwd=REPO_ROOT,
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
|
|
turns: list[dict[str, Any]] = []
|
|
health: dict[str, Any] | None = None
|
|
usage: dict[str, Any] = {}
|
|
error: str | None = None
|
|
stdout_tail = ""
|
|
stderr_tail = ""
|
|
try:
|
|
deadline = time.monotonic() + 45
|
|
while time.monotonic() < deadline:
|
|
if proc.poll() is not None:
|
|
stdout, stderr = proc.communicate(timeout=1)
|
|
error = f"gateway exited early with code {proc.returncode}: {stderr or stdout}"
|
|
break
|
|
try:
|
|
health = _read_json(f"http://127.0.0.1:{port}/health")
|
|
break
|
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
|
|
time.sleep(0.25)
|
|
if health is None and error is None:
|
|
error = "gateway did not become healthy before timeout"
|
|
|
|
assistant_count = 0
|
|
if error is None:
|
|
for index, spec in enumerate(turns_spec, start=1):
|
|
accepted = _post_json(
|
|
f"http://127.0.0.1:{port}/api/chat",
|
|
{
|
|
"sessionKey": session_key,
|
|
"message": spec["message"],
|
|
"intent": spec["intent"],
|
|
},
|
|
timeout=20.0,
|
|
)
|
|
if accepted.get("ok") is not True:
|
|
error = f"turn {index} was not accepted: {accepted}"
|
|
break
|
|
assistant, history, turn_error = _wait_for_assistant_reply(
|
|
port=port,
|
|
session_key=session_key,
|
|
previous_assistant_count=assistant_count,
|
|
timeout_seconds=timeout_seconds,
|
|
)
|
|
if turn_error:
|
|
error = f"turn {index} failed: {turn_error}"
|
|
break
|
|
assistant_count += 1
|
|
turns.append(
|
|
{
|
|
"index": index,
|
|
"name": spec["name"],
|
|
"accepted": accepted,
|
|
"assistant_text": str((assistant or {}).get("text", "")).strip(),
|
|
"history_message_count": len((history or {}).get("messages", [])),
|
|
"message_chars": len(spec["message"]),
|
|
}
|
|
)
|
|
if error is None:
|
|
usage = _read_json(f"http://127.0.0.1:{port}/api/usage", timeout=5.0)
|
|
finally:
|
|
if proc.poll() is None:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
proc.wait(timeout=5)
|
|
stdout, stderr = proc.communicate(timeout=1)
|
|
stdout_tail = (stdout or "")[-2000:]
|
|
stderr_tail = (stderr or "")[-2000:]
|
|
turn_call_records = _read_turn_call_records(turn_log_dir)
|
|
|
|
context_summary = _summarize_llm_request_context(
|
|
turn_call_records,
|
|
session_keys={session_key},
|
|
)
|
|
blocking_memory_errors = _blocking_memory_errors(turns)
|
|
ok = (
|
|
error is None
|
|
and len(turns) == len(turns_spec)
|
|
and all(turn.get("assistant_text") for turn in turns)
|
|
and int(usage.get("totalTokens", 0) or 0) > 0
|
|
and not blocking_memory_errors
|
|
)
|
|
return {
|
|
"name": "opensquilla_gateway_live_long_context_chat",
|
|
"ok": ok,
|
|
"session_key": session_key,
|
|
"model": live_model,
|
|
"long_chars": long_chars,
|
|
"simulate_memory_distill_failure": simulate_memory_distill_failure,
|
|
"blocking_memory_errors": blocking_memory_errors,
|
|
"health": health or {},
|
|
"turns": turns,
|
|
"usage": usage,
|
|
"llm_request_context_summary": context_summary,
|
|
"error": error,
|
|
"stdout_tail": stdout_tail,
|
|
"stderr_tail": stderr_tail,
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--long-chars", type=int, default=350_000)
|
|
parser.add_argument("--timeout-seconds", type=float, default=180.0)
|
|
parser.add_argument(
|
|
"--simulate-memory-distill-failure",
|
|
action="store_true",
|
|
help=(
|
|
"Lower the context budget and flush timeouts so pre-compaction "
|
|
"memory distill cannot block WebChat sendability."
|
|
),
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
result = run_live_long_context_smoke(
|
|
long_chars=max(args.long_chars, 1),
|
|
timeout_seconds=max(args.timeout_seconds, 1.0),
|
|
simulate_memory_distill_failure=args.simulate_memory_distill_failure,
|
|
)
|
|
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
|
|
return 0 if result.get("ok") else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|