From c4be4d3e02e3436ff64320b557ebe247bed526f0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Jun 2026 16:11:47 +0000 Subject: [PATCH 01/25] feat(agi): stream fix, persistence defaults, infra specialist, Aria wiring - Materialize SSE generator bodies for azure-functions 1.x compatibility - Default AGI JSONL persistence and expose /api/agi/persistence without 404 - Add infrastructure-specialist agent and domain routing - Wire Aria command parser to accept agi/agi-reasoning provider aliases - Expand AGI smoke CI with stream and LM Studio routing coverage Co-authored-by: Bryan --- .github/workflows/agi-smoke.yml | 4 + ai-projects/chat-cli/src/agi_provider.py | 38 +++++++++- apps/aria/server.py | 13 ++++ function_app.py | 95 ++++++++++++------------ local.settings.json.example | 7 +- tests/test_agi_persistence_endpoint.py | 19 +++++ tests/test_agi_provider.py | 20 +++++ tests/test_agi_smoke.py | 9 +++ tests/test_aria_server.py | 31 ++++++++ tests/test_function_app_endpoints.py | 19 +---- tests/test_lmstudio_agi_integration.py | 13 ++++ 11 files changed, 200 insertions(+), 68 deletions(-) diff --git a/.github/workflows/agi-smoke.yml b/.github/workflows/agi-smoke.yml index e9f4f429c..6ea56b720 100644 --- a/.github/workflows/agi-smoke.yml +++ b/.github/workflows/agi-smoke.yml @@ -55,6 +55,8 @@ jobs: tests/test_agi_persistence_endpoint.py \ tests/test_agi_persistence_auth.py \ tests/test_agi_prune.py \ + tests/test_lmstudio_agi_integration.py \ + tests/test_function_app_endpoints.py::TestAgiEndpoints::test_agi_stream_emits_done_sentinel \ -q pytest \ tests/test_agi_smoke.py \ @@ -62,6 +64,8 @@ jobs: tests/test_agi_persistence_endpoint.py \ tests/test_agi_persistence_auth.py \ tests/test_agi_prune.py \ + tests/test_lmstudio_agi_integration.py \ + tests/test_function_app_endpoints.py::TestAgiEndpoints::test_agi_stream_emits_done_sentinel \ -v --tb=short --timeout=10 --maxfail=1 \ --junitxml=agi-smoke-junit.xml diff --git a/ai-projects/chat-cli/src/agi_provider.py b/ai-projects/chat-cli/src/agi_provider.py index 73ec6ba7c..538708a8b 100644 --- a/ai-projects/chat-cli/src/agi_provider.py +++ b/ai-projects/chat-cli/src/agi_provider.py @@ -344,6 +344,19 @@ def complete( ], "description": "Produces meta-learning insights and retrospective analysis from cycle history", }, + "infrastructure-specialist": { + "domains": ["infrastructure"], + "intents": ["explanation", "question", "coding", "creation", "debugging"], + "provider": "agi", + "confidence_boost": 0.18, + "subtask_templates": [ + "Map the deployment or runtime topology involved", + "Identify configuration, networking, and auth constraints", + "Propose the smallest safe infrastructure change", + "List verification steps and rollback criteria", + ], + "description": "DevOps, deployment, CI/CD, and cloud infrastructure specialist", + }, "general": { "domains": [], "intents": [], @@ -720,7 +733,7 @@ def _analyze_query(self, query: str) -> Dict[str, Any]: ``creation``, ``question``, ``general``. Domain categories: - ``quantum``, ``aria``, ``ai``, ``technical``, ``general``. + ``quantum``, ``aria``, ``ai``, ``technical``, ``infrastructure``, ``general``. Parameters ---------- @@ -826,6 +839,26 @@ def _analyze_query(self, query: str) -> Dict[str, Any]: ] ): domain = "ai" + elif any( + w in query_lower + for w in [ + "deploy", + "deployment", + "kubernetes", + "k8s", + "docker", + "terraform", + "bicep", + "pipeline", + "ci/cd", + "github actions", + "azure functions", + "infrastructure", + "devops", + "hosting", + ] + ): + domain = "infrastructure" elif any( w in query_lower for w in [ @@ -1774,7 +1807,8 @@ def _ensure_parent_dir(path: str) -> None: os.makedirs(parent, exist_ok=True) # Optionally attach a persistence backend if configured via environment. - persist_enabled = os.getenv("QAI_AGI_PERSIST", "").lower() in ("1", "true", "yes") + persist_env = os.getenv("QAI_AGI_PERSIST", "true") + persist_enabled = persist_env.lower() in ("1", "true", "yes") jsonl_path = os.getenv("QAI_AGI_PERSIST_PATH") sqlite_path = os.getenv("QAI_AGI_PERSIST_DB") or os.getenv("QAI_AGI_PERSIST_SQLITE") if sqlite_path: diff --git a/apps/aria/server.py b/apps/aria/server.py index 1f52af4bb..504a99c50 100644 --- a/apps/aria/server.py +++ b/apps/aria/server.py @@ -334,6 +334,17 @@ def build_health_payload( "timestamp": datetime.datetime.now(timezone.utc).isoformat(), "llm_available": bool(llm), "model_loaded": bool(model_flag), + "agi_provider_supported": bool(llm), + "supported_providers": [ + "auto", + "local", + "ollama", + "lmstudio", + "azure", + "openai", + "quantum", + "agi", + ], "counts": { "objects": len(objects) if isinstance(objects, dict) else 0, "action_types": len(ARIA_ACTIONS), @@ -435,6 +446,8 @@ def _normalize_provider_alias(explicit: Optional[str]) -> Optional[str]: "quantum-llm": "quantum", "quantum_llm": "quantum", "azure_openai": "azure", + "agi-reasoning": "agi", + "agi_reasoning": "agi", } return alias_map.get(normalized, normalized) diff --git a/function_app.py b/function_app.py index b6a6a4133..ae4eef6ea 100644 --- a/function_app.py +++ b/function_app.py @@ -850,13 +850,7 @@ def _sse_iterable(): yield (f"event: error\n" f"data: {err_payload}\n\n").encode("utf-8") yield b"data: [DONE]\n\n" - # Pass generator directly so SSE chunks stream incrementally. - return func.HttpResponse( - body=_sse_iterable(), - status_code=200, - mimetype="text/event-stream", - headers={**create_cors_response_headers(), "Cache-Control": "no-cache"}, - ) + return _make_sse_response(_sse_iterable()) except ValueError as ve: return func.HttpResponse( json.dumps({"status": "error", "error": f"Validation error: {ve}"}), @@ -937,6 +931,7 @@ def agi_persistence(req: func.HttpRequest) -> func.HttpResponse: sqlite_path = os.getenv("QAI_AGI_PERSIST_DB") or os.getenv("QAI_AGI_PERSIST_SQLITE") jsonl_path = os.getenv("QAI_AGI_PERSIST_PATH") jsonl_enabled = os.getenv("QAI_AGI_PERSIST", "").lower() in ("1", "true", "yes") + default_jsonl_path = _default_agi_persist_jsonl_path() if sqlite_path: try: @@ -960,8 +955,8 @@ def agi_persistence(req: func.HttpRequest) -> func.HttpResponse: headers=create_cors_response_headers(), ) - if jsonl_path or jsonl_enabled: - path = jsonl_path or os.path.join(os.getcwd(), "data_out", "agi_reasoning.jsonl") + path = jsonl_path or default_jsonl_path + if jsonl_path or jsonl_enabled or os.path.exists(path) or not sqlite_path: try: entries = [] if os.path.exists(path): @@ -974,7 +969,16 @@ def agi_persistence(req: func.HttpRequest) -> func.HttpResponse: except Exception: entries.append({"raw": ln}) return func.HttpResponse( - json.dumps({"status": "ok", "backend": "jsonl", "entries": entries}, default=str), + json.dumps( + { + "status": "ok", + "backend": "jsonl", + "path": path, + "configured": bool(jsonl_path or jsonl_enabled or os.path.exists(path)), + "entries": entries, + }, + default=str, + ), status_code=200, mimetype="application/json", headers=create_cors_response_headers(), @@ -1398,6 +1402,35 @@ def create_cors_response_headers(): } +def _default_agi_persist_jsonl_path() -> str: + """Default JSONL audit path for AGI reasoning chains.""" + return str(Path(__file__).resolve().parent / "data_out" / "agi_reasoning.jsonl") + + +def _materialize_sse_body(sse_generator) -> bytes: + """Join SSE byte chunks for azure.functions.HttpResponse. + + azure-functions 1.x accepts only str/bytes/bytearray bodies, not generators. + """ + if sse_generator is None: + return b"" + if isinstance(sse_generator, (bytes, bytearray)): + return bytes(sse_generator) + if isinstance(sse_generator, str): + return sse_generator.encode("utf-8") + return b"".join(sse_generator) + + +def _make_sse_response(sse_generator, *, status_code: int = 200) -> func.HttpResponse: + """Build an SSE HttpResponse, materializing generator output when required.""" + return func.HttpResponse( + body=_materialize_sse_body(sse_generator), + status_code=status_code, + mimetype="text/event-stream", + headers={**create_cors_response_headers(), "Cache-Control": "no-cache"}, + ) + + # ============================================================================= # Automation Tool Endpoints: Resource Monitor, Model Deployer, Results Exporter, Evaluation # ============================================================================= @@ -1657,12 +1690,7 @@ def blocked_sse(): yield (f"data: {payload}\n\n").encode("utf-8") yield b"data: [DONE]\n\n" - return func.HttpResponse( - body=blocked_sse(), - status_code=200, - mimetype="text/event-stream", - headers={**create_cors_response_headers(), "Cache-Control": "no-cache"}, - ) + return _make_sse_response(blocked_sse()) stream_memory_messages: list[dict] = [] if stream_user_content: try: @@ -1861,12 +1889,7 @@ def sse_iterable(): # generator yielding bytes # Canonical SSE completion sentinel used by chat-web clients. yield b"data: [DONE]\n\n" - return func.HttpResponse( - body=sse_iterable(), - status_code=200, - mimetype="text/event-stream", - headers={**create_cors_response_headers(), "Cache-Control": "no-cache"}, - ) + return _make_sse_response(sse_iterable()) except ValueError as ve: logging.error(f"chat/stream validation error: {ve}") @@ -4629,12 +4652,7 @@ def _unavail(): yield b'data: {"error": "Quantum LLM pipeline unavailable"}\n\n' yield b"data: [DONE]\n\n" - return func.HttpResponse( - body=_unavail(), - status_code=503, - mimetype="text/event-stream", - headers={**create_cors_response_headers(), "Cache-Control": "no-cache"}, - ) + return _make_sse_response(_unavail(), status_code=503) # Honour per-request max_tokens (within cap) β€” use a local override dict # instead of mutating the shared pipeline config to avoid race conditions. @@ -4703,12 +4721,7 @@ def _unavail(): yield b'data: {"error": "Quantum LLM pipeline unavailable"}\n\n' yield b"data: [DONE]\n\n" - return func.HttpResponse( - body=_unavail(), - status_code=503, - mimetype="text/event-stream", - headers={**create_cors_response_headers(), "Cache-Control": "no-cache"}, - ) + return _make_sse_response(_unavail(), status_code=503) import asyncio # noqa: PLC0415 @@ -4733,12 +4746,7 @@ async def _collect(): finally: loop.close() - return func.HttpResponse( - body=_sse_generator(), - status_code=200, - mimetype="text/event-stream", - headers={**create_cors_response_headers(), "Cache-Control": "no-cache"}, - ) + return _make_sse_response(_sse_generator()) except Exception as exc: # noqa: BLE001 logging.error("quantum-llm/stream error: %s", exc) _exc = exc # capture before exception binding is deleted at end of except block @@ -4747,12 +4755,7 @@ def _err(): yield f'data: {json.dumps({"error": str(_exc)})}\n\n'.encode("utf-8") yield b"data: [DONE]\n\n" - return func.HttpResponse( - body=_err(), - status_code=200, - mimetype="text/event-stream", - headers={**create_cors_response_headers(), "Cache-Control": "no-cache"}, - ) + return _make_sse_response(_err()) @app.route(route="referrals/leaderboard", methods=["GET"], auth_level=func.AuthLevel.ANONYMOUS) diff --git a/local.settings.json.example b/local.settings.json.example index 2827db0c9..613113f83 100644 --- a/local.settings.json.example +++ b/local.settings.json.example @@ -28,7 +28,10 @@ "QAI_ENABLE_LOCAL_TTS": "true", "# Default chat provider for Azure Functions endpoints (auto|ollama|lmstudio|local|azure|openai). Use azure when Azure OpenAI keys above are set": "", "DEFAULT_AI_PROVIDER": "auto", - "# Aria web UI LLM provider (ollama|lmstudio|auto|local|azure) - set azure to match Azure OpenAI; falls back to DEFAULT_AI_PROVIDER": "", - "ARIA_LLM_PROVIDER": "" + "# Aria web UI LLM provider (ollama|lmstudio|auto|local|azure|agi) - set azure to match Azure OpenAI; falls back to DEFAULT_AI_PROVIDER": "", + "ARIA_LLM_PROVIDER": "", + "# AGI reasoning persistence (JSONL audit log written to data_out/agi_reasoning.jsonl by default)": "", + "QAI_AGI_PERSIST": "true", + "QAI_AGI_PERSIST_PATH": "data_out/agi_reasoning.jsonl" } } diff --git a/tests/test_agi_persistence_endpoint.py b/tests/test_agi_persistence_endpoint.py index f8642ad99..31b766660 100644 --- a/tests/test_agi_persistence_endpoint.py +++ b/tests/test_agi_persistence_endpoint.py @@ -91,3 +91,22 @@ def test_agi_persistence_endpoint_sqlite(monkeypatch, tmp_path, app_module): assert any(e.get("meta", {}).get("source") == "test_sqlite" or e.get("chain") for e in entries) backend.close() + + +def test_agi_persistence_endpoint_default_jsonl_path(monkeypatch, tmp_path, app_module): + monkeypatch.delenv("QAI_AGI_PERSIST_DB", raising=False) + monkeypatch.delenv("QAI_AGI_PERSIST_SQLITE", raising=False) + monkeypatch.delenv("QAI_AGI_PERSIST", raising=False) + monkeypatch.delenv("QAI_AGI_PERSIST_PATH", raising=False) + + default_path = tmp_path / "data_out" / "agi_reasoning.jsonl" + monkeypatch.setattr(app_module, "_default_agi_persist_jsonl_path", lambda: str(default_path)) + + req = _mock_request("GET", params={"limit": "5"}) + resp = app_module.agi_persistence(req) + assert resp.status_code == 200 + data = json.loads(resp.get_body()) + assert data.get("status") == "ok" + assert data.get("backend") == "jsonl" + assert data.get("path") == str(default_path) + assert data.get("entries") == [] diff --git a/tests/test_agi_provider.py b/tests/test_agi_provider.py index ec8f2e180..30a44f208 100644 --- a/tests/test_agi_provider.py +++ b/tests/test_agi_provider.py @@ -312,6 +312,26 @@ def test_query_analysis_does_not_false_positive_ai_from_paint(self): assert analysis["domain"] != "ai" + def test_query_analysis_detects_infrastructure_domain(self): + """Deployment and DevOps queries should map to the infrastructure domain.""" + mock_provider = MockBaseProvider() + agi = AGIProvider(base_provider=mock_provider) + + analysis = agi._analyze_query("How do I deploy this Azure Functions app with GitHub Actions?") + + assert analysis["domain"] == "infrastructure" + + def test_infrastructure_specialist_routing(self): + """Infrastructure queries should prefer infrastructure-specialist.""" + mock_provider = MockBaseProvider() + agi = AGIProvider(base_provider=mock_provider) + + analysis = agi._analyze_query("Design a CI/CD pipeline for Azure deployment") + selected_agent, score = agi._select_agent(analysis) + + assert selected_agent == "infrastructure-specialist" + assert score > 0.5 + def test_task_decomposition_explanation(self): """Test task decomposition for explanation queries.""" mock_provider = MockBaseProvider() diff --git a/tests/test_agi_smoke.py b/tests/test_agi_smoke.py index 9b9c6d783..77e56a659 100644 --- a/tests/test_agi_smoke.py +++ b/tests/test_agi_smoke.py @@ -65,3 +65,12 @@ def test_agi_stream_requires_query_or_messages(app_module): req = _mock_request("POST", body={}) resp = app_module.agi_stream(req) assert resp.status_code == 400 + + +def test_materialize_sse_body_joins_generator_chunks(app_module): + def _chunks(): + yield b"event: meta\n" + yield b"data: {}\n\n" + + body = app_module._materialize_sse_body(_chunks()) + assert body == b"event: meta\ndata: {}\n\n" diff --git a/tests/test_aria_server.py b/tests/test_aria_server.py index d623b0f33..5779bce0f 100644 --- a/tests/test_aria_server.py +++ b/tests/test_aria_server.py @@ -827,6 +827,37 @@ def fake_detect_provider(explicit=None, model_override=None, **kwargs): assert captured["model_override"] == "data_out/quantum_llm_training" +def test_parse_accepts_agi_provider_alias(monkeypatch): + captured = {} + + class DummyProvider: + def complete(self, messages, stream=False): + return '[{"action": "gesture", "gesture_type": "wave"}]' + + class DummyChoice: + name = "agi" + model = "agi-local-local-echo" + + def fake_detect_provider(explicit=None, model_override=None, **kwargs): + captured["explicit"] = explicit + captured["model_override"] = model_override + return DummyProvider(), DummyChoice() + + monkeypatch.setattr(aria_server, "detect_provider", fake_detect_provider) + + parser = aria_server.AriaActionParser() + actions = parser.parse("wave hello", use_llm=True, provider_choice="agi-reasoning") + + assert any(a.get("action") == "gesture" for a in actions) + assert captured["explicit"] == "agi" + + +def test_health_payload_includes_agi_provider_support(): + payload = aria_server.build_health_payload(stage={"aria": {}, "objects": {}}) + assert payload["agi_provider_supported"] is True + assert "agi" in payload["supported_providers"] + + def test_parse_falls_back_when_provider_resolution_fails(monkeypatch): parser = aria_server.AriaActionParser() diff --git a/tests/test_function_app_endpoints.py b/tests/test_function_app_endpoints.py index 7c6e98f75..5420eea6f 100644 --- a/tests/test_function_app_endpoints.py +++ b/tests/test_function_app_endpoints.py @@ -813,12 +813,6 @@ def test_agi_reason_validation_error_when_missing_input(self, app_module): assert "validation error" in data["error"].lower() def test_agi_stream_emits_done_sentinel(self, app_module, monkeypatch): - import inspect - - import azure.functions as _af - - captured: dict = {"sse_body": b""} - class _FakeAgiProvider: def complete(self, messages, stream=False): assert stream is True @@ -837,17 +831,6 @@ def set_goal(self, _goal: str): ), ) - _real_HttpResponse = _af.HttpResponse - - def _capturing_HttpResponse(body=None, **kwargs): - if body is not None and inspect.isgenerator(body): - consumed = b"".join(body) - captured["sse_body"] = consumed - return _real_HttpResponse(consumed, **kwargs) - return _real_HttpResponse(body, **kwargs) - - monkeypatch.setattr(app_module.func, "HttpResponse", _capturing_HttpResponse) - req = _mock_request( "POST", body={"query": "stream a short response", "goals": ["be concise"]}, @@ -855,7 +838,7 @@ def _capturing_HttpResponse(body=None, **kwargs): resp = app_module.agi_stream(req) assert resp.status_code == 200 - body_text = captured["sse_body"].decode("utf-8") + body_text = resp.get_body().decode("utf-8") assert "event: meta" in body_text assert '"delta": "Hello"' in body_text or '"delta": " world"' in body_text assert "data: [DONE]" in body_text diff --git a/tests/test_lmstudio_agi_integration.py b/tests/test_lmstudio_agi_integration.py index 513be5b8d..d8ace290d 100644 --- a/tests/test_lmstudio_agi_integration.py +++ b/tests/test_lmstudio_agi_integration.py @@ -77,6 +77,18 @@ def test_env_configuration(): print(f" LMSTUDIO_MODEL: {lmstudio_model}") +def test_lmstudio_specialist_routing(): + """General Q&A should be able to route to lmstudio-specialist.""" + from agi_provider import create_agi_provider + + provider, _info = create_agi_provider() + analysis = provider._analyze_query("Explain how transformers work in simple terms") + selected_agent, score = provider._select_agent(analysis) + + assert selected_agent in {"lmstudio-specialist", "ai-specialist", "reasoning-specialist", "general"} + assert score > 0 + + def run_all_tests(): """Run all integration tests.""" print("\n" + "=" * 70) @@ -86,6 +98,7 @@ def run_all_tests(): tests = [ ("Agent Registration", test_agent_registration), ("Provider Detection", test_provider_detection), + ("LM Studio Specialist Routing", test_lmstudio_specialist_routing), ("AGI Provider Initialization", test_agent_class_methods), ("Environment Configuration", test_env_configuration), ] From 77480899ff68648a1d3b54e774d392cccfd28023 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Jun 2026 16:35:20 +0000 Subject: [PATCH 02/25] fix(tests): align chat_stream SSE tests with materialized response bodies Update TestPostValidation chat_stream tests to read SSE via resp.get_body() after _make_sse_response materializes generators. Fix yamllint bracket and comment spacing in agi-smoke.yml. Co-authored-by: Bryan --- .github/workflows/agi-smoke.yml | 4 +- tests/test_function_app_endpoints.py | 58 +++------------------------- 2 files changed, 7 insertions(+), 55 deletions(-) diff --git a/.github/workflows/agi-smoke.yml b/.github/workflows/agi-smoke.yml index 6ea56b720..18576a5f1 100644 --- a/.github/workflows/agi-smoke.yml +++ b/.github/workflows/agi-smoke.yml @@ -2,7 +2,7 @@ name: AGI smoke tests on: pull_request: - branches: [ main ] + branches: [main] workflow_dispatch: permissions: @@ -13,7 +13,7 @@ env: HARDEN_RUNNER_SHA: 0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.2 CHECKOUT_SHA: 11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 SETUP_PYTHON_SHA: a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - UPLOAD_ARTIFACT_SHA: 65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + UPLOAD_ARTIFACT_SHA: 65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 concurrency: group: agi-smoke-${{ github.event.pull_request.number || github.ref }} diff --git a/tests/test_function_app_endpoints.py b/tests/test_function_app_endpoints.py index 5420eea6f..2350e2e2b 100644 --- a/tests/test_function_app_endpoints.py +++ b/tests/test_function_app_endpoints.py @@ -520,24 +520,8 @@ def test_chat_stream_whitespace_only_input_text_block_message(self, app_module): data = json.loads(resp.get_body()) assert "validation error" in data["error"].lower() - def test_chat_stream_guardrail_blocks_prompt_injection(self, app_module, monkeypatch): + def test_chat_stream_guardrail_blocks_prompt_injection(self, app_module): """POST /api/chat/stream should emit safe fallback SSE when prompt is blocked.""" - import inspect - - import azure.functions as _af - - captured: dict = {"sse_body": b""} - _real_HttpResponse = _af.HttpResponse - - def _capturing_HttpResponse(body=None, **kwargs): - if body is not None and inspect.isgenerator(body): - consumed = b"".join(body) - captured["sse_body"] = consumed - return _real_HttpResponse(consumed, **kwargs) - return _real_HttpResponse(body, **kwargs) - - monkeypatch.setattr(app_module.func, "HttpResponse", _capturing_HttpResponse) - req = _mock_request( "POST", body={ @@ -551,17 +535,13 @@ def _capturing_HttpResponse(body=None, **kwargs): ) resp = app_module.chat_stream(req) assert resp.status_code == 200 - body_text = captured["sse_body"].decode("utf-8") + body_text = resp.get_body().decode("utf-8") assert "data: [DONE]" in body_text assert "safely" in body_text.lower() def test_chat_stream_memory_injection(self, app_module, monkeypatch): """POST /api/chat/stream should call memory helpers and include count in meta SSE event.""" - import inspect - - import azure.functions as _af - - captured: dict = {"embedding": None, "session_id": None, "sse_body": b""} + captured: dict = {"embedding": None, "session_id": None} def _fake_embedding(text: str): captured["embedding"] = text @@ -571,17 +551,6 @@ def _fake_similar(query_emb, top_k=5, session_id=None, min_similarity=0.0): captured["session_id"] = session_id return [{"content": "Previous answer about widgets", "similarity": 0.88}] - # Patch func.HttpResponse inside function_app so streaming body (generator) is consumed - _real_HttpResponse = _af.HttpResponse - - def _capturing_HttpResponse(body=None, **kwargs): - if body is not None and inspect.isgenerator(body): - consumed = b"".join(body) - captured["sse_body"] = consumed - return _real_HttpResponse(consumed, **kwargs) - return _real_HttpResponse(body, **kwargs) - - monkeypatch.setattr(app_module.func, "HttpResponse", _capturing_HttpResponse) monkeypatch.setattr(app_module, "generate_embedding", _fake_embedding) monkeypatch.setattr(app_module, "fetch_similar_messages", _fake_similar) @@ -596,7 +565,7 @@ def _capturing_HttpResponse(body=None, **kwargs): assert resp.status_code == 200 # Parse SSE body for the meta event - body_text = captured["sse_body"].decode("utf-8") + body_text = resp.get_body().decode("utf-8") meta_data: dict | None = None for line in body_text.splitlines(): if line.startswith("data:"): @@ -615,12 +584,6 @@ def _capturing_HttpResponse(body=None, **kwargs): def test_chat_stream_emits_done_sentinel(self, app_module, monkeypatch): """POST /api/chat/stream should terminate SSE with data: [DONE].""" - import inspect - - import azure.functions as _af - - captured: dict = {"sse_body": b""} - class _FakeProvider: def complete(self, messages, stream=False): assert stream is True @@ -642,17 +605,6 @@ def complete(self, messages, stream=False): lambda query_emb, top_k=5, session_id=None: [], ) - _real_HttpResponse = _af.HttpResponse - - def _capturing_HttpResponse(body=None, **kwargs): - if body is not None and inspect.isgenerator(body): - consumed = b"".join(body) - captured["sse_body"] = consumed - return _real_HttpResponse(consumed, **kwargs) - return _real_HttpResponse(body, **kwargs) - - monkeypatch.setattr(app_module.func, "HttpResponse", _capturing_HttpResponse) - req = _mock_request( "POST", body={"messages": [{"role": "user", "content": "say hi"}]}, @@ -660,7 +612,7 @@ def _capturing_HttpResponse(body=None, **kwargs): resp = app_module.chat_stream(req) assert resp.status_code == 200 - body_text = captured["sse_body"].decode("utf-8") + body_text = resp.get_body().decode("utf-8") assert '"delta": "Hello"' in body_text or '"delta": " world"' in body_text assert "data: [DONE]" in body_text From 095981fa90b97c24c08cbee930b9ab916042c2f5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Jun 2026 18:03:21 +0000 Subject: [PATCH 03/25] feat(agi): wire chat UI and LM Studio MCP integration - Add AGI mode toggle and SSE streaming in chat.js and index.html - Serve agi_stream_utils.js via /api/chat-web/static/agi_stream_utils.js - Add agi_analyze and agi_reason MCP tools with import-safe server boot - Fix devcontainer LM Studio URL test isolation and add MCP/endpoint tests Co-authored-by: Bryan --- .github/workflows/agi-smoke.yml | 4 +- ai-projects/lmstudio-mcp/agi_mcp_tools.py | 89 +++++ .../lmstudio-mcp/lmstudio_mcp_server.py | 324 ++++++++++-------- apps/chat/chat.js | 250 +++++++++++++- apps/chat/index.html | 198 +++++++++++ function_app.py | 34 ++ tests/test_function_app_endpoints.py | 14 + tests/test_lmstudio_agi_integration_impl.py | 1 + tests/test_lmstudio_mcp_agi_tools.py | 82 +++++ 9 files changed, 837 insertions(+), 159 deletions(-) create mode 100644 ai-projects/lmstudio-mcp/agi_mcp_tools.py create mode 100644 tests/test_lmstudio_mcp_agi_tools.py diff --git a/.github/workflows/agi-smoke.yml b/.github/workflows/agi-smoke.yml index 18576a5f1..6ea56b720 100644 --- a/.github/workflows/agi-smoke.yml +++ b/.github/workflows/agi-smoke.yml @@ -2,7 +2,7 @@ name: AGI smoke tests on: pull_request: - branches: [main] + branches: [ main ] workflow_dispatch: permissions: @@ -13,7 +13,7 @@ env: HARDEN_RUNNER_SHA: 0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.2 CHECKOUT_SHA: 11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 SETUP_PYTHON_SHA: a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - UPLOAD_ARTIFACT_SHA: 65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + UPLOAD_ARTIFACT_SHA: 65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 concurrency: group: agi-smoke-${{ github.event.pull_request.number || github.ref }} diff --git a/ai-projects/lmstudio-mcp/agi_mcp_tools.py b/ai-projects/lmstudio-mcp/agi_mcp_tools.py new file mode 100644 index 000000000..2a11da225 --- /dev/null +++ b/ai-projects/lmstudio-mcp/agi_mcp_tools.py @@ -0,0 +1,89 @@ +"""AGI provider helpers exposed as LM Studio MCP tools.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _load_agi_factory(): + root = _repo_root() + root_str = str(root) + if root_str not in sys.path: + sys.path.insert(0, root_str) + + from agi_provider import create_agi_provider + + return create_agi_provider + + +def _build_messages(query: str | None, messages: list[dict[str, Any]] | None) -> list[dict[str, str]]: + if isinstance(messages, list) and messages: + return [ + {"role": str(item.get("role", "user")), "content": str(item.get("content", ""))} + for item in messages + if isinstance(item, dict) + ] + if isinstance(query, str) and query.strip(): + return [{"role": "user", "content": query.strip()}] + raise ValueError("Provide either a non-empty query or a messages array") + + +def run_agi_analyze(query: str) -> dict[str, Any]: + """Analyze a query with AGI routing metadata.""" + if not isinstance(query, str) or not query.strip(): + raise ValueError("query is required") + + create_agi_provider = _load_agi_factory() + provider, choice = create_agi_provider() + + analysis = provider._analyze_query(query.strip()) + selected_agent, agent_score = provider._select_agent(analysis) + + return { + "success": True, + "query": query.strip(), + "analysis": analysis, + "routing": { + "selected_agent": selected_agent, + "agent_score": float(agent_score), + }, + "provider": { + "name": "agi", + "base_provider": getattr(choice, "name", None), + "base_model": getattr(choice, "model", None), + }, + } + + +def run_agi_reason( + query: str | None = None, + messages: list[dict[str, Any]] | None = None, + *, + include_reasoning_summary: bool = True, +) -> dict[str, Any]: + """Run full AGI completion and return response payload.""" + chat_messages = _build_messages(query, messages) + create_agi_provider = _load_agi_factory() + provider, choice = create_agi_provider() + result = provider.complete(chat_messages, stream=False) + if hasattr(result, "__iter__") and not isinstance(result, str): + result = "".join(str(chunk) for chunk in result) + + payload: dict[str, Any] = { + "success": True, + "response": str(result), + "provider": { + "name": "agi", + "base_provider": getattr(choice, "name", None), + "base_model": getattr(choice, "model", None), + }, + } + if include_reasoning_summary: + payload["reasoning"] = provider.get_reasoning_summary() + return payload diff --git a/ai-projects/lmstudio-mcp/lmstudio_mcp_server.py b/ai-projects/lmstudio-mcp/lmstudio_mcp_server.py index a7a6c4e42..3fd077fea 100644 --- a/ai-projects/lmstudio-mcp/lmstudio_mcp_server.py +++ b/ai-projects/lmstudio-mcp/lmstudio_mcp_server.py @@ -7,6 +7,7 @@ - Querying available models - Sending chat completions to local LM Studio instance - Managing model context and parameters +- AGI analyze/reason helpers for multi-agent routing """ import asyncio @@ -16,29 +17,29 @@ import sys from typing import Any, Dict, List, Optional +MCP_AVAILABLE = True try: from mcp.server import Server # type: ignore from mcp.server.stdio import stdio_server # type: ignore from mcp.types import TextContent, Tool # type: ignore except ImportError: - print("Error: MCP package not installed.") - print("Install with: pip install 'mcp>=0.9.0'") - sys.exit(1) + MCP_AVAILABLE = False + Server = None # type: ignore[assignment,misc] + stdio_server = None # type: ignore[assignment,misc] + TextContent = None # type: ignore[assignment,misc] + Tool = None # type: ignore[assignment,misc] try: import httpx except ImportError: - print("Error: httpx package not installed.") - print("Install with: pip install httpx") - sys.exit(1) + httpx = None # type: ignore[assignment] + +from agi_mcp_tools import run_agi_analyze, run_agi_reason # Setup logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -# Initialize MCP server -app = Server("lmstudio-mcp") - # Default configuration DEFAULT_BASE_URL = "http://127.0.0.1:1234/v1" DEFAULT_MODEL = "local-model" @@ -57,6 +58,9 @@ def __init__( max_tokens: int = DEFAULT_MAX_TOKENS, timeout: float = 30.0, ): + if httpx is None: + raise RuntimeError("httpx package not installed. Install with: pip install httpx") + self.base_url = base_url.rstrip("/") self.model = model self.temperature = temperature @@ -128,28 +132,26 @@ async def chat_completion( data = response.json() if stream: - # For streaming, return the raw response data return { "success": True, "stream": True, "response": data, "model": model, } - else: - # Extract the message from non-streaming response - choice = data.get("choices", [{}])[0] - message = choice.get("message", {}).get("content", "") - stop_reason = choice.get("finish_reason", "unknown") - return { - "success": True, - "stream": False, - "message": message, - "stop_reason": stop_reason, - "model": model, - "usage": data.get("usage", {}), - "response": data, - } + choice = data.get("choices", [{}])[0] + message = choice.get("message", {}).get("content", "") + stop_reason = choice.get("finish_reason", "unknown") + + return { + "success": True, + "stream": False, + "message": message, + "stop_reason": stop_reason, + "model": model, + "usage": data.get("usage", {}), + "response": data, + } except httpx.HTTPError as e: return { "success": False, @@ -162,7 +164,6 @@ async def chat_completion( async def get_server_status(self) -> Dict[str, Any]: """Get server status information.""" try: - # Try to get models as a status check response = await self.client.get(f"{self.base_url}/models") response.raise_for_status() data = response.json() @@ -194,7 +195,6 @@ def get_client() -> LMStudioClient: if _client is None: base_url = os.getenv("LMSTUDIO_BASE_URL", DEFAULT_BASE_URL) model = os.getenv("LMSTUDIO_MODEL", DEFAULT_MODEL) - # Ensure environment defaults are strings before casting temperature = float(os.getenv("LMSTUDIO_TEMPERATURE", str(DEFAULT_TEMPERATURE))) max_tokens = int(os.getenv("LMSTUDIO_MAX_TOKENS", str(DEFAULT_MAX_TOKENS))) @@ -207,147 +207,172 @@ def get_client() -> LMStudioClient: return _client -@app.list_tools() -async def list_tools() -> List[Tool]: - """List available tools.""" - return [ - Tool( - name="list_models", - description="List all available models on the LM Studio server", - inputSchema={ - "type": "object", - "properties": {}, - "required": [], - }, - ), - Tool( - name="chat_completion", - description="Send a chat completion request to LM Studio", - inputSchema={ - "type": "object", - "properties": { - "messages": { - "type": "array", - "items": { - "type": "object", - "properties": { - "role": { - "type": "string", - "enum": ["system", "user", "assistant"], - "description": "Message role", - }, - "content": { - "type": "string", - "description": "Message content", +def _register_mcp_handlers(server: "Server") -> None: + @server.list_tools() + async def list_tools() -> List[Tool]: + """List available tools.""" + return [ + Tool( + name="list_models", + description="List all available models on the LM Studio server", + inputSchema={ + "type": "object", + "properties": {}, + "required": [], + }, + ), + Tool( + name="chat_completion", + description="Send a chat completion request to LM Studio", + inputSchema={ + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": ["system", "user", "assistant"], + "description": "Message role", + }, + "content": { + "type": "string", + "description": "Message content", + }, }, + "required": ["role", "content"], }, - "required": ["role", "content"], + "description": "List of messages for the chat", + }, + "model": { + "type": "string", + "description": "Model ID (uses default if not specified)", + }, + "temperature": { + "type": "number", + "description": "Sampling temperature (0.0-2.0, default 0.7)", + }, + "max_tokens": { + "type": "integer", + "description": "Maximum tokens in response (default 2048)", }, - "description": "List of messages for the chat", - }, - "model": { - "type": "string", - "description": "Model ID (uses default if not specified)", }, - "temperature": { - "type": "number", - "description": "Sampling temperature (0.0-2.0, default 0.7)", + "required": ["messages"], + }, + ), + Tool( + name="server_status", + description="Get LM Studio server status and configuration", + inputSchema={ + "type": "object", + "properties": {}, + "required": [], + }, + ), + Tool( + name="agi_analyze", + description="Analyze a query with the AGI provider (intent, domain, routing preview)", + inputSchema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "User query to analyze", + } }, - "max_tokens": { - "type": "integer", - "description": "Maximum tokens in response (default 2048)", + "required": ["query"], + }, + ), + Tool( + name="agi_reason", + description="Run full AGI reasoning completion for a query or message list", + inputSchema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Single-turn user query", + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": {"type": "string"}, + "content": {"type": "string"}, + }, + "required": ["role", "content"], + }, + "description": "Optional chat-style message history", + }, + "include_reasoning_summary": { + "type": "boolean", + "description": "Include AGI reasoning summary metadata", + }, }, }, - "required": ["messages"], - }, - ), - Tool( - name="server_status", - description="Get LM Studio server status and configuration", - inputSchema={ - "type": "object", - "properties": {}, - "required": [], - }, - ), - ] - - -@app.call_tool() -async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: - """Call a tool.""" - client = get_client() + ), + ] - try: - if name == "list_models": - result = await client.list_models() - return [ - TextContent( - type="text", - text=json.dumps(result, indent=2), - ) - ] - - elif name == "chat_completion": - messages = arguments.get("messages", []) - if not messages: - return [ - TextContent( - type="text", - text=json.dumps({"error": "No messages provided"}, indent=2), + @server.call_tool() + async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: + """Call a tool.""" + client = get_client() + + try: + if name == "list_models": + result = await client.list_models() + elif name == "chat_completion": + messages = arguments.get("messages", []) + if not messages: + result = {"error": "No messages provided"} + else: + result = await client.chat_completion( + messages=messages, + model=arguments.get("model"), + temperature=arguments.get("temperature"), + max_tokens=arguments.get("max_tokens"), + stream=False, ) - ] - - model = arguments.get("model") - temperature = arguments.get("temperature") - max_tokens = arguments.get("max_tokens") - - result = await client.chat_completion( - messages=messages, - model=model, - temperature=temperature, - max_tokens=max_tokens, - stream=False, - ) - return [ - TextContent( - type="text", - text=json.dumps(result, indent=2), - ) - ] - - elif name == "server_status": - result = await client.get_server_status() - return [ - TextContent( - type="text", - text=json.dumps(result, indent=2), + elif name == "server_status": + result = await client.get_server_status() + elif name == "agi_analyze": + result = run_agi_analyze(str(arguments.get("query", ""))) + elif name == "agi_reason": + result = run_agi_reason( + query=arguments.get("query"), + messages=arguments.get("messages"), + include_reasoning_summary=bool( + arguments.get("include_reasoning_summary", True) + ), ) - ] + else: + result = {"error": f"Unknown tool: {name}"} - else: - return [ - TextContent( - type="text", - text=json.dumps({"error": f"Unknown tool: {name}"}, indent=2), - ) - ] + return [TextContent(type="text", text=json.dumps(result, indent=2))] + except Exception as e: + logger.error(f"Tool execution error: {e}") + return [TextContent(type="text", text=json.dumps({"error": str(e)}, indent=2))] - except Exception as e: - logger.error(f"Tool execution error: {e}") - return [ - TextContent( - type="text", - text=json.dumps({"error": str(e)}, indent=2), - ) - ] + +if MCP_AVAILABLE: + app = Server("lmstudio-mcp") + _register_mcp_handlers(app) +else: + app = None async def main(): """Run the MCP server.""" + if not MCP_AVAILABLE: + print("Error: MCP package not installed.") + print("Install with: pip install 'mcp>=0.9.0'") + sys.exit(1) + client = get_client() - # Check connection on startup logger.info(f"Connecting to LM Studio at {client.base_url}") connected = await client.check_connection() @@ -359,7 +384,6 @@ async def main(): logger.warning(f"⚠ Could not connect to LM Studio at {client.base_url}") logger.info("Make sure LM Studio is running and the local server is enabled.") - # Start MCP server async with stdio_server() as (read_stream, write_stream): await app.run(read_stream, write_stream, app.create_initialization_options()) diff --git a/apps/chat/chat.js b/apps/chat/chat.js index 598bd8e43..2ab82afdc 100644 --- a/apps/chat/chat.js +++ b/apps/chat/chat.js @@ -5,6 +5,9 @@ console.log('chat.js loaded - v2025-11-21-qai - Provider: QAI auto-detect with q const AI_BASE = ''; const API_BASE = `/api/chat`; const STREAM_API = `/api/chat/stream`; +const AGI_STREAM_API = `/api/agi/stream`; +const AGI_REASON_API = `/api/agi/reason`; +const AGI_STATUS_API = `/api/agi/status`; const STATUS_API = `/api/ai/status`; const QUANTUM_CLASSIFY_API = '/api/quantum/classify'; const QUANTUM_CIRCUIT_API = '/api/quantum/circuit'; @@ -17,6 +20,7 @@ let isProcessing = false; let messageCounter = 0; let currentProvider = 'auto'; // Always use auto-detect for best available let quantumMode = false; // Quantum enhancement toggle +let agiMode = false; // AGI reasoning pipeline toggle let systemStatus = null; let retryCount = 0; const MAX_RETRIES = 3; @@ -59,6 +63,7 @@ const exportButton = document.getElementById('exportButton'); const importButton = document.getElementById('importButton'); const toggleThemeButton = document.getElementById('toggleThemeButton'); const quantumModeButton = document.getElementById('quantumModeButton'); +const agiModeButton = document.getElementById('agiModeButton'); const quantumPanel = document.getElementById('quantumPanel'); const quantumPanelClose = document.getElementById('quantumPanelClose'); const quantumIndicator = document.getElementById('quantumIndicator'); @@ -184,8 +189,9 @@ document.addEventListener('DOMContentLoaded', () => { }); exportButton.addEventListener('click', exportChat); importButton.addEventListener('click', importChat); - toggleThemeButton.addEventListener('click', toggleTheme); - quantumModeButton.addEventListener('click', toggleQuantumMode); + if (toggleThemeButton) toggleThemeButton.addEventListener('click', toggleTheme); + if (quantumModeButton) quantumModeButton.addEventListener('click', toggleQuantumMode); + if (agiModeButton) agiModeButton.addEventListener('click', toggleAgiMode); if (quantumPanelClose) { quantumPanelClose.addEventListener('click', () => { quantumPanel.style.display = 'none'; @@ -264,8 +270,8 @@ async function sendMessage() { const text = messageInput.value.trim(); if (!text || isProcessing) return; - // Perform quantum analysis if enabled - if (quantumMode) { + // Perform quantum analysis if enabled (disabled while AGI mode is active) + if (quantumMode && !agiMode) { updateStatus('Performing quantum analysis...'); await performQuantumAnalysis(text); } @@ -287,8 +293,15 @@ async function sendMessage() { const typingIndicator = showTypingIndicator(); try { - // Choose streaming or one-shot - if (streamEnabled) { + // Choose AGI, streaming, or one-shot chat backends + if (agiMode) { + if (streamEnabled) { + if (cancelStreamBtn) cancelStreamBtn.style.display = 'inline-block'; + await streamAgiResponse(typingIndicator); + } else { + await agiOneShotResponse(typingIndicator); + } + } else if (streamEnabled) { if (cancelStreamBtn) cancelStreamBtn.style.display = 'inline-block'; await streamResponse(typingIndicator); } else { @@ -492,6 +505,194 @@ async function streamResponse(typingIndicator) { } } +function extractAgiOutputText(delta) { + if (!delta || typeof delta !== 'object') return ''; + if (delta.type === 'output') return String(delta.data || ''); + return ''; +} + +function renderAgiDeltaHtml(delta) { + if (typeof globalThis.AGIStreamUtils !== 'undefined' && globalThis.AGIStreamUtils.prettyPrintDelta) { + return globalThis.AGIStreamUtils.prettyPrintDelta(delta); + } + if (!delta || typeof delta !== 'object') return ''; + return '
' + String(JSON.stringify(delta)) + '
'; +} + +async function agiOneShotResponse(typingIndicator) { + const apiMessages = sanitizeConversationMessages(systemPrompt ? + [{ role: 'system', content: systemPrompt }, ...messages] : + messages); + + const response = await fetch(AGI_REASON_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: apiMessages, + include_reasoning_summary: true, + temperature: temperature, + max_output_tokens: maxOutputTokens, + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText || response.statusText}`); + } + + const data = await response.json(); + retryCount = 0; + typingIndicator.remove(); + + const assistantMessage = data.response || 'No AGI response received.'; + addMessage('assistant', assistantMessage, true); + if (!isSyntheticCompactionPlaceholder(assistantMessage)) { + messages.push({ role: 'assistant', content: assistantMessage }); + } + updateMessageCount(); + providerInfo.textContent = 'AGI' + (data.provider?.base_provider ? ` (${String(data.provider.base_provider).toUpperCase()})` : ''); + updateStatus('Ready (AGI)'); + saveToStorage(); + messageInput.disabled = false; + sendButton.disabled = false; + isProcessing = false; + messageInput.focus(); +} + +async function streamAgiResponse(typingIndicator) { + const assistantDiv = document.createElement('div'); + assistantDiv.className = 'message assistant'; + const contentDiv = document.createElement('div'); + contentDiv.className = 'message-content'; + contentDiv.innerHTML = ''; + assistantDiv.appendChild(contentDiv); + chatMessages.appendChild(assistantDiv); + chatMessages.scrollTop = chatMessages.scrollHeight; + + activeAbortController = new AbortController(); + + const apiMessages = sanitizeConversationMessages(systemPrompt ? + [{ role: 'system', content: systemPrompt }, ...messages] : + messages); + + try { + const response = await fetch(AGI_STREAM_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: apiMessages, + temperature: temperature, + max_output_tokens: maxOutputTokens, + }), + signal: activeAbortController.signal + }); + + if (!response.ok || !response.body) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + typingIndicator.remove(); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let reasoningHtml = ''; + let outputText = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const blocks = buffer.split('\n\n'); + buffer = blocks.pop() || ''; + + blocks.forEach(function (block) { + if (!block.trim()) return; + const lines = block.split('\n'); + let eventName = 'message'; + const dataLines = []; + lines.forEach(function (line) { + if (line.indexOf('event: ') === 0) { + eventName = line.slice(6).trim(); + } else if (line.indexOf('data: ') === 0) { + dataLines.push(line.slice(6)); + } + }); + + const dataStr = dataLines.join('\n').trim(); + if (!dataStr || dataStr === '[DONE]') return; + if (eventName === 'error') { + throw new Error(dataStr); + } + if (eventName === 'meta') { + try { + const meta = JSON.parse(dataStr); + providerInfo.textContent = 'AGI' + (meta.base_provider ? ` (${String(meta.base_provider).toUpperCase()})` : ''); + } catch (e) { /* ignore malformed meta */ } + return; + } + + let payload; + try { + payload = JSON.parse(dataStr); + } catch (e) { + return; + } + + const delta = payload && payload.delta; + if (!delta) return; + + const chunkText = extractAgiOutputText(delta); + if (chunkText) { + outputText += chunkText; + } else if (delta.type && delta.type !== 'output') { + reasoningHtml += renderAgiDeltaHtml(delta); + } + + contentDiv.innerHTML = reasoningHtml + '
' + outputText.replace(/&/g, '&').replace(//g, '>') + '
'; + chatMessages.scrollTop = chatMessages.scrollHeight; + }); + } + + if (outputText) { + try { + contentDiv.innerHTML = (reasoningHtml ? reasoningHtml : '') + marked.parse(outputText); + contentDiv.querySelectorAll('pre code').forEach((block) => { + hljs.highlightElement(block); + addCopyButton(block.parentElement); + }); + } catch (e) { + contentDiv.textContent = outputText; + } + } + + if (!isSyntheticCompactionPlaceholder(outputText)) { + messages.push({ role: 'assistant', content: outputText }); + } + updateMessageCount(); + retryCount = 0; + updateStatus('Ready (AGI stream)'); + saveToStorage(); + } catch (error) { + typingIndicator.remove(); + assistantDiv.remove(); + if (error.name === 'AbortError') { + addMessage('system', '❌ AGI streaming cancelled by user.'); + updateStatus('Cancelled'); + } else { + throw error; + } + } finally { + if (cancelStreamBtn) cancelStreamBtn.style.display = 'none'; + activeAbortController = null; + messageInput.disabled = false; + sendButton.disabled = false; + isProcessing = false; + messageInput.focus(); + } +} + function addMessage(role, content, useMarkdown = false) { const messageDiv = document.createElement('div'); messageDiv.className = `message ${role}`; @@ -839,6 +1040,7 @@ function saveToStorage() { try { localStorage.setItem('chatMessages', JSON.stringify(sanitizeConversationMessages(messages))); localStorage.setItem('chatStream', streamEnabled ? '1' : '0'); + localStorage.setItem('chatAgiMode', agiMode ? '1' : '0'); localStorage.setItem('chatTemp', String(temperature)); localStorage.setItem('chatMaxTokens', String(maxOutputTokens)); localStorage.setItem('chatSystemPrompt', systemPrompt || ''); @@ -852,6 +1054,7 @@ function loadFromStorage() { const saved = localStorage.getItem('chatMessages'); const savedTheme = localStorage.getItem('theme'); const savedStream = localStorage.getItem('chatStream'); + const savedAgiMode = localStorage.getItem('chatAgiMode'); const savedTemp = localStorage.getItem('chatTemp'); const savedMax = localStorage.getItem('chatMaxTokens'); const savedSys = localStorage.getItem('chatSystemPrompt'); @@ -876,7 +1079,15 @@ function loadFromStorage() { // Restore settings streamEnabled = savedStream === '1'; - streamToggle.checked = streamEnabled; + if (streamToggle) streamToggle.checked = streamEnabled; + if (savedAgiMode === '1') { + agiMode = true; + if (agiModeButton) { + agiModeButton.textContent = '🧠 AGI ON'; + agiModeButton.classList.add('active'); + } + currentProvider = 'agi'; + } if (savedTemp) { temperature = parseFloat(savedTemp); if (!isNaN(temperature)) { @@ -904,6 +1115,31 @@ function loadFromStorage() { // Quantum Mode Functions // ============================================================================= +function toggleAgiMode() { + agiMode = !agiMode; + + if (agiMode) { + if (quantumMode) toggleQuantumMode(); + if (agiModeButton) { + agiModeButton.textContent = '🧠 AGI ON'; + agiModeButton.classList.add('active'); + } + currentProvider = 'agi'; + updateStatus('AGI reasoning enabled'); + providerInfo.textContent = 'AGI'; + } else { + if (agiModeButton) { + agiModeButton.textContent = '🧠 AGI OFF'; + agiModeButton.classList.remove('active'); + } + currentProvider = 'auto'; + updateStatus('AGI reasoning disabled'); + fetchSystemStatus(); + } + + saveToStorage(); +} + function toggleQuantumMode() { quantumMode = !quantumMode; diff --git a/apps/chat/index.html b/apps/chat/index.html index 349d65ab8..f588b5f15 100644 --- a/apps/chat/index.html +++ b/apps/chat/index.html @@ -980,6 +980,25 @@ background: linear-gradient(135deg, #00d2ff 0%, #3a7bd5 100%); } + .agi-mode-btn { + background: rgba(255, 255, 255, 0.15); + border: none; + color: white; + padding: 4px 10px; + border-radius: 8px; + cursor: pointer; + font-size: 12px; + transition: background 0.2s; + } + + .agi-mode-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.28); + } + + .agi-mode-btn.active { + background: rgba(56, 189, 248, 0.45); + } + .quantum-indicator { color: #667eea; font-weight: bold; @@ -2977,6 +2996,8 @@

πŸ€– QAI Chat

+ + style="display:none;font-size:12px;color:#667eea;text-decoration:underline;cursor:pointer">Download

Server environment

@@ -3025,10 +3036,26 @@

Quick terminal test commands

-
+
-

πŸ€– QAI Chat

-
+
+

πŸ€– QAI Chat

+ +
+
Connecting...
+
@@ -3066,6 +3097,15 @@

πŸ€– QAI Chat

Image preview
+
+
+ Ready +
+
+ Messages: 0 + Chars: 0 +
+
@@ -3075,6 +3115,12 @@

πŸ€– QAI Chat

// Allow chat.js to wire up vision and avatar features // The embedded controller will handle streaming chat separately window.ARIA_EMBEDDED = true; + if (window.ARIA_STAGE_API_BASE == null) { + window.ARIA_STAGE_API_BASE = /\/api\/chat-web/i.test(window.location.pathname) + ? window.location.origin + : 'http://127.0.0.1:8080'; + } + window.ARIA_STAGE_BRIDGE_ENABLED = window.ARIA_STAGE_BRIDGE_ENABLED !== false; // Pupil cursor tracking - both eyes document.addEventListener('DOMContentLoaded', function () { @@ -3157,23 +3203,20 @@

πŸ€– QAI Chat

}); }); - - + + + - - + diff --git a/function_app.py b/function_app.py index d2139be45..f300e65ff 100644 --- a/function_app.py +++ b/function_app.py @@ -384,6 +384,70 @@ def serve_chat_global_upgrade_css(req: func.HttpRequest) -> func.HttpResponse: return func.HttpResponse(f"/* Error: {str(e)} */", status_code=500, mimetype="text/css") +# ============================================================================= +# Aria stage proxy β€” forwards /api/aria/* to the 3D stage server (port 8080) +# ============================================================================= + +ARIA_STAGE_BASE_URL = os.getenv( + "ARIA_STAGE_BASE_URL", "http://127.0.0.1:8080").rstrip("/") + + +def _proxy_aria_request(req: func.HttpRequest, subpath: str) -> func.HttpResponse: + """Forward a request to the Aria stage HTTP API.""" + import requests + + url = f"{ARIA_STAGE_BASE_URL}/api/aria/{subpath}" + try: + if req.method.upper() == "GET": + resp = requests.get(url, params=dict(req.params), timeout=10) + else: + body = req.get_body() + resp = requests.request( + req.method.upper(), + url, + data=body, + headers={"Content-Type": "application/json"}, + timeout=30, + ) + content_type = resp.headers.get("Content-Type", "application/json") + return func.HttpResponse( + resp.content, + status_code=resp.status_code, + mimetype=content_type.split(";")[0].strip(), + headers=create_cors_response_headers(), + ) + except Exception as exc: # noqa: BLE001 + logging.warning("Aria stage proxy failed for %s: %s", subpath, exc) + return func.HttpResponse( + json.dumps({"status": "error", "error": f"Aria stage unavailable: {exc}"}), + status_code=502, + mimetype="application/json", + headers=create_cors_response_headers(), + ) + + +@app.route(route="aria/state", methods=["GET"], auth_level=func.AuthLevel.ANONYMOUS) +def aria_state_proxy(req: func.HttpRequest) -> func.HttpResponse: + """Proxy GET /api/aria/state to the Aria stage server.""" + return _proxy_aria_request(req, "state") + + +@app.route(route="aria/execute", methods=["POST", "OPTIONS"], auth_level=func.AuthLevel.ANONYMOUS) +def aria_execute_proxy(req: func.HttpRequest) -> func.HttpResponse: + """Proxy POST /api/aria/execute to the Aria stage server.""" + if req.method.upper() == "OPTIONS": + return func.HttpResponse("", status_code=200, headers=create_cors_response_headers()) + return _proxy_aria_request(req, "execute") + + +@app.route(route="aria/command", methods=["POST", "OPTIONS"], auth_level=func.AuthLevel.ANONYMOUS) +def aria_command_proxy(req: func.HttpRequest) -> func.HttpResponse: + """Proxy POST /api/aria/command to the Aria stage server.""" + if req.method.upper() == "OPTIONS": + return func.HttpResponse("", status_code=200, headers=create_cors_response_headers()) + return _proxy_aria_request(req, "command") + + # ============================================================================= # Chat API - Backend for AI interactions # ============================================================================= @@ -2951,6 +3015,9 @@ def detect_conflict(versions): "/api/agi/stream", "/api/agi/status", "/api/agi/persistence", + "/api/aria/state", + "/api/aria/execute", + "/api/aria/command", "/api/vision/infer", "/api/vision/batch-infer", "/api/image/generate", @@ -3036,6 +3103,12 @@ def ai_routes(req: func.HttpRequest) -> func.HttpResponse: "methods": ["POST"], "authLevel": "anonymous"}, {"route": "agi/persistence", "methods": ["GET"], "authLevel": "anonymous"}, + {"route": "aria/state", + "methods": ["GET"], "authLevel": "anonymous"}, + {"route": "aria/execute", + "methods": ["POST", "OPTIONS"], "authLevel": "anonymous"}, + {"route": "aria/command", + "methods": ["POST", "OPTIONS"], "authLevel": "anonymous"}, {"route": "chat", "methods": [ "POST", "OPTIONS"], "authLevel": "anonymous"}, { diff --git a/local.settings.json.example b/local.settings.json.example index 613113f83..f320c9478 100644 --- a/local.settings.json.example +++ b/local.settings.json.example @@ -30,6 +30,8 @@ "DEFAULT_AI_PROVIDER": "auto", "# Aria web UI LLM provider (ollama|lmstudio|auto|local|azure|agi) - set azure to match Azure OpenAI; falls back to DEFAULT_AI_PROVIDER": "", "ARIA_LLM_PROVIDER": "", + "# Aria 3D stage proxy target for /api/aria/* routes on the Functions host": "", + "ARIA_STAGE_BASE_URL": "http://127.0.0.1:8080", "# AGI reasoning persistence (JSONL audit log written to data_out/agi_reasoning.jsonl by default)": "", "QAI_AGI_PERSIST": "true", "QAI_AGI_PERSIST_PATH": "data_out/agi_reasoning.jsonl" diff --git a/scripts/sync_docs_chat.py b/scripts/sync_docs_chat.py index 9b3693c6a..f8e8ea8f5 100644 --- a/scripts/sync_docs_chat.py +++ b/scripts/sync_docs_chat.py @@ -12,11 +12,12 @@ def sync_docs_chat() -> None: - """Copy chat.js and AGI stream utilities into docs/chat.""" + """Copy chat web assets into docs/chat.""" DST.mkdir(parents=True, exist_ok=True) static_dst = DST / "static" static_dst.mkdir(parents=True, exist_ok=True) + shutil.copy2(SRC / "index.html", DST / "index.html") shutil.copy2(SRC / "chat.js", DST / "chat.js") shutil.copy2(SRC / "static" / "agi_stream_utils.js", static_dst / "agi_stream_utils.js") diff --git a/tests/test_chat_web_embedded_script.py b/tests/test_chat_web_embedded_script.py index 407805bcf..5ae5e850d 100644 --- a/tests/test_chat_web_embedded_script.py +++ b/tests/test_chat_web_embedded_script.py @@ -45,6 +45,7 @@ def test_embedded_controller_stage_bridge_and_tag_handlers(): assert "function ariaLook" in script assert "ARIA_STAGE_API_BASE" in script assert "ARIA_STAGE_BRIDGE_ENABLED" in script + assert "window.location.origin" in script assert "characterHeldProp" in html assert "expression-smile" in html assert "[aria:expression:" in script or "setAriaExpression" in script diff --git a/tests/test_function_app_endpoints.py b/tests/test_function_app_endpoints.py index 824b61b8d..31138a48e 100644 --- a/tests/test_function_app_endpoints.py +++ b/tests/test_function_app_endpoints.py @@ -694,6 +694,66 @@ def test_serve_agi_stream_utils(self, app_module): assert resp.mimetype == "application/javascript" +# =========================================================================== +# Aria stage proxy β€” /api/aria/* +# =========================================================================== +class TestAriaStageProxy: + def test_aria_execute_proxy_forwards_post(self, app_module, monkeypatch): + captured: dict = {} + + class _FakeResponse: + content = b'{"status":"ok","tags":["[aria:gesture:wave]"]}' + status_code = 200 + headers = {"Content-Type": "application/json"} + + def _fake_request(method, url, data=None, headers=None, timeout=None): + captured["method"] = method + captured["url"] = url + captured["data"] = data + captured["headers"] = headers + return _FakeResponse() + + import requests + + monkeypatch.setattr(requests, "request", _fake_request) + + req = _mock_request( + "POST", + body={"command": "[aria:gesture:wave]", "auto_execute": True}, + ) + resp = app_module.aria_execute_proxy(req) + + assert resp.status_code == 200 + assert captured["url"].endswith("/api/aria/execute") + data = json.loads(resp.get_body()) + assert data["status"] == "ok" + + def test_aria_state_proxy_forwards_get(self, app_module, monkeypatch): + captured: dict = {} + + class _FakeResponse: + content = b'{"aria":{"x":50,"y":50},"objects":{}}' + status_code = 200 + headers = {"Content-Type": "application/json"} + + def _fake_get(url, params=None, timeout=None): + captured["url"] = url + captured["params"] = params + return _FakeResponse() + + import requests + + monkeypatch.setattr(requests, "get", _fake_get) + + req = _mock_request("GET") + resp = app_module.aria_state_proxy(req) + + assert resp.status_code == 200 + assert captured["url"].endswith("/api/aria/state") + data = json.loads(resp.get_body()) + assert "aria" in data + + # =========================================================================== # AGI endpoint tests β€” /api/agi/analyze and /api/agi/status # =========================================================================== From 66ca8bdba3c637536a5eee12bff822739e353f0f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Jun 2026 23:48:00 +0000 Subject: [PATCH 14/25] fix(ci): resolve PR #462 deterministic check failures Align quantum integration tests with allowed autorun job names, fix yamllint/shellcheck issues in agi-smoke workflow, escape summary backticks in contract gate, and tighten AGI stream utils test regex. Co-authored-by: Bryan --- .github/workflows/agi-smoke.yml | 9 +++++---- .github/workflows/integration-contract-gate.yml | 2 +- apps/chat/chat.js | 1 - tests/js/test_agi_stream_utils.mjs | 2 +- tests/test_lmstudio_mcp_agi_tools.py | 1 - tests/test_quantum_integration.py | 10 +++++----- 6 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/agi-smoke.yml b/.github/workflows/agi-smoke.yml index 70718180e..90f765ff6 100644 --- a/.github/workflows/agi-smoke.yml +++ b/.github/workflows/agi-smoke.yml @@ -2,7 +2,7 @@ name: AGI smoke tests on: pull_request: - branches: [ main ] + branches: [main] workflow_dispatch: permissions: @@ -12,7 +12,7 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" HARDEN_RUNNER_SHA: 0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.2 CHECKOUT_SHA: 11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - SETUP_PYTHON_SHA: a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + SETUP_PYTHON_SHA: a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 UPLOAD_ARTIFACT_SHA: 65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 AGI_SMOKE_TESTS: >- tests/test_agi_smoke.py @@ -60,8 +60,9 @@ jobs: timeout-minutes: 15 run: | set -euo pipefail - pytest --collect-only ${AGI_SMOKE_TESTS} -q - pytest ${AGI_SMOKE_TESTS} \ + read -r -a _smoke_tests <<< "${AGI_SMOKE_TESTS}" + pytest --collect-only "${_smoke_tests[@]}" -q + pytest "${_smoke_tests[@]}" \ -v --tb=short --timeout=30 --maxfail=1 \ --junitxml=agi-smoke-junit.xml diff --git a/.github/workflows/integration-contract-gate.yml b/.github/workflows/integration-contract-gate.yml index 6fe4b78c5..66fa6fd1c 100644 --- a/.github/workflows/integration-contract-gate.yml +++ b/.github/workflows/integration-contract-gate.yml @@ -317,6 +317,6 @@ jobs: echo '```' } >> "${GITHUB_STEP_SUMMARY}" else - echo "_No orchestrator status file produced (`${status_file}` missing or empty)._" \ + echo "_No orchestrator status file produced (\`${status_file}\` missing or empty)._" \ >> "${GITHUB_STEP_SUMMARY}" fi diff --git a/apps/chat/chat.js b/apps/chat/chat.js index d86c972a0..54d009db3 100644 --- a/apps/chat/chat.js +++ b/apps/chat/chat.js @@ -7,7 +7,6 @@ const API_BASE = `/api/chat`; const STREAM_API = `/api/chat/stream`; const AGI_STREAM_API = `/api/agi/stream`; const AGI_REASON_API = `/api/agi/reason`; -const AGI_STATUS_API = `/api/agi/status`; const STATUS_API = `/api/ai/status`; const QUANTUM_CLASSIFY_API = '/api/quantum/classify'; const QUANTUM_CIRCUIT_API = '/api/quantum/circuit'; diff --git a/tests/js/test_agi_stream_utils.mjs b/tests/js/test_agi_stream_utils.mjs index fa105ff0a..52c47f03a 100644 --- a/tests/js/test_agi_stream_utils.mjs +++ b/tests/js/test_agi_stream_utils.mjs @@ -47,7 +47,7 @@ describe('AGIStreamUtils', () => { it('prettyPrintDelta renders known delta types safely', () => { const html = prettyPrintDelta({ type: 'output', data: '' }); assert.match(html, /agi-output/); - assert.doesNotMatch(html, /