diff --git a/.azure/PRE-DEPLOYMENT-CHECKLIST.md b/.azure/PRE-DEPLOYMENT-CHECKLIST.md index f9689336c..5b4085d5d 100644 --- a/.azure/PRE-DEPLOYMENT-CHECKLIST.md +++ b/.azure/PRE-DEPLOYMENT-CHECKLIST.md @@ -180,7 +180,7 @@ ## ✅ Sign-Off | Role | Name | Date | Signature | -|------|------|------|-----------| +| --- | --- | --- | --- | | Release Lead | — | 2026-06-01 | ✓ | | Architecture | — | 2026-06-01 | ✓ | | QA | — | 2026-06-01 | ✓ | diff --git a/.azure/PRODUCTION-RUNBOOK.md b/.azure/PRODUCTION-RUNBOOK.md index 5011c2b6d..5eb3ff41b 100644 --- a/.azure/PRODUCTION-RUNBOOK.md +++ b/.azure/PRODUCTION-RUNBOOK.md @@ -417,7 +417,7 @@ az monitor metrics alert create \ ## Contact & Escalation | Role | Contact | Availability | -|------|---------|--------------| +| --- | --- | --- | | On-Call Engineer | #oncall Slack | 24/7 | | Platform Lead | — | Business hours | | Architecture | — | Business hours + on-call | diff --git a/.github/hooks/scripts/requirements_security_gate.py b/.github/hooks/scripts/requirements_security_gate.py index 286cb130b..3ca5ff948 100644 --- a/.github/hooks/scripts/requirements_security_gate.py +++ b/.github/hooks/scripts/requirements_security_gate.py @@ -27,7 +27,10 @@ import subprocess import sys import tempfile -import tomllib +try: + import tomllib +except ModuleNotFoundError: # Python < 3.11 + import tomli as tomllib from typing import Any _REQ_PATTERN = re.compile(r"requirements[^/]*\.txt$|pyproject\.toml$", re.IGNORECASE) diff --git a/.github/workflows/agi-prune-cron.yml b/.github/workflows/agi-prune-cron.yml index 26845e49e..b5ebf3643 100644 --- a/.github/workflows/agi-prune-cron.yml +++ b/.github/workflows/agi-prune-cron.yml @@ -69,10 +69,10 @@ jobs: DRY=${PRUNE_DRY_RUN:-true} ARGS=() if [ -n "${QAI_AGI_PERSIST_DB:-}" ]; then - ARGS+=("--sqlite" "${QAI_AGI_PERSIST_DB}") + ARGS="$ARGS --sqlite ${QAI_AGI_PERSIST_DB} --keep-rows ${KEEP}" fi if [ -n "${QAI_AGI_PERSIST_PATH:-}" ]; then - ARGS+=("--jsonl" "${QAI_AGI_PERSIST_PATH}") + ARGS="$ARGS --jsonl ${QAI_AGI_PERSIST_PATH} --keep-last ${KEEP}" fi if [ -z "${QAI_AGI_PERSIST_DB:-}" ] && [ -z "${QAI_AGI_PERSIST_PATH:-}" ]; then echo "No persistence target configured (QAI_AGI_PERSIST_DB or QAI_AGI_PERSIST_PATH). Skipping." diff --git a/.github/workflows/agi-smoke.yml b/.github/workflows/agi-smoke.yml index e9f4f429c..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,8 +12,19 @@ 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 + tests/test_local_agi_sse_integration.py + 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_lmstudio_mcp_agi_tools.py + tests/test_agi_stream_utils_js.py + tests/test_function_app_endpoints.py::TestChatWebAssets + tests/test_function_app_endpoints.py::TestAgiEndpoints concurrency: group: agi-smoke-${{ github.event.pull_request.number || github.ref }} @@ -49,20 +60,10 @@ jobs: timeout-minutes: 15 run: | set -euo pipefail - pytest --collect-only \ - tests/test_agi_smoke.py \ - tests/test_local_agi_sse_integration.py \ - tests/test_agi_persistence_endpoint.py \ - tests/test_agi_persistence_auth.py \ - tests/test_agi_prune.py \ - -q - pytest \ - tests/test_agi_smoke.py \ - tests/test_local_agi_sse_integration.py \ - tests/test_agi_persistence_endpoint.py \ - tests/test_agi_persistence_auth.py \ - tests/test_agi_prune.py \ - -v --tb=short --timeout=10 --maxfail=1 \ + 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 - name: Upload AGI smoke artifacts diff --git a/.github/workflows/api-health-smoke.yml b/.github/workflows/api-health-smoke.yml index 9241f53f2..f52db591a 100644 --- a/.github/workflows/api-health-smoke.yml +++ b/.github/workflows/api-health-smoke.yml @@ -173,18 +173,16 @@ jobs: echo "| strict smoke output | \`data_out/integration_smoke/status-strict.json\` |" } >> "$GITHUB_STEP_SUMMARY" else - echo "| integration_smoke.py --strict-endpoints | ❌ failed |" >> "$GITHUB_STEP_SUMMARY" + echo "| integration_smoke.py --strict-endpoints | ⊘ skipped (non-scheduled) |" fi - else - echo "| integration_smoke.py --strict-endpoints | ⊘ skipped (non-scheduled) |" >> "$GITHUB_STEP_SUMMARY" - fi - # contract_tests - if [ "${{ steps.contract_tests.outcome }}" = "success" ]; then - echo "| ci_orchestrator --integration-contract-tests | ✅ passed |" >> "$GITHUB_STEP_SUMMARY" - else - echo "| ci_orchestrator --integration-contract-tests | ❌ failed |" >> "$GITHUB_STEP_SUMMARY" - fi + # contract_tests + if [ "${{ steps.contract_tests.outcome }}" = "success" ]; then + echo "| ci_orchestrator --integration-contract-tests | ✅ passed |" + else + echo "| ci_orchestrator --integration-contract-tests | ❌ failed |" + fi + } >> "$GITHUB_STEP_SUMMARY" # Evaluate overall status and fail if any required test failed if [ "${{ steps.integration_smoke.outcome }}" != "success" ] || [ "${{ steps.contract_tests.outcome }}" != "success" ]; then @@ -202,5 +200,7 @@ jobs: exit 1 fi - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "### ✅ All API health checks passed." >> "$GITHUB_STEP_SUMMARY" + { + echo "" + echo "### ✅ All API health checks passed." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/aria-bot-tests.yml b/.github/workflows/aria-bot-tests.yml index bdb05c0f3..126a179b1 100644 --- a/.github/workflows/aria-bot-tests.yml +++ b/.github/workflows/aria-bot-tests.yml @@ -6,6 +6,9 @@ on: pull_request: branches: ["main", "master"] +permissions: + contents: read + jobs: test: name: Run tests @@ -13,22 +16,25 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 with: - python-version: '3.10' + python-version: '3.11' - name: Install dependencies run: | + set -euo pipefail python -m pip install --upgrade pip if [ -f requirements.txt ]; then python -m pip install -r requirements.txt - else - python -m pip install pytest fi + python -m pip install pytest - name: Run pytest run: | - python -m pytest -q tests/test_aria_bot.py + set -euo pipefail + python -m pytest tests -q --maxfail=5 diff --git a/.github/workflows/aria-tests.yml b/.github/workflows/aria-tests.yml index 74b033f75..2bff2f5b7 100644 --- a/.github/workflows/aria-tests.yml +++ b/.github/workflows/aria-tests.yml @@ -114,7 +114,8 @@ jobs: uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 with: egress-policy: audit - disable-sudo: true + # Playwright --with-deps installs browser system packages via apt (sudo). + disable-sudo: false - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 @@ -237,7 +238,7 @@ jobs: libxfixes3 \ libxrandr2 \ libgbm1 \ - libasound2 + libasound2t64 - name: Install Python dependencies run: | @@ -315,7 +316,7 @@ jobs: ports: - 4444:4444 - 5900:5900 - options: --shm-size=2gb + options: --shm-size=2gb --add-host=host.docker.internal:host-gateway steps: - name: Harden runner uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 @@ -350,6 +351,7 @@ jobs: echo $! > /tmp/aria_server.pid env: PYTHONPATH: ${{ github.workspace }} + ARIA_HOST: 0.0.0.0 - name: Wait for server run: | @@ -392,7 +394,7 @@ jobs: pytest tests/test_ui_selenium.py -v --tb=short env: PYTHONPATH: ${{ github.workspace }} - ARIA_SERVER_URL: http://localhost:8080 + ARIA_SERVER_URL: http://host.docker.internal:8080 SELENIUM_REMOTE_URL: http://localhost:4444/wd/hub - name: Stop Aria server diff --git a/.github/workflows/auto-assign-reviewers.yml b/.github/workflows/auto-assign-reviewers.yml index fb49f1cc3..a6ce297d6 100644 --- a/.github/workflows/auto-assign-reviewers.yml +++ b/.github/workflows/auto-assign-reviewers.yml @@ -43,9 +43,6 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - // Required helpers - const core = require('@actions/core'); - const pr = context.payload.pull_request; if (!pr) { core.info('No pull request context found; exiting.'); diff --git a/.github/workflows/autonomous-evolver.yml b/.github/workflows/autonomous-evolver.yml index 4140176f8..b6068abd3 100644 --- a/.github/workflows/autonomous-evolver.yml +++ b/.github/workflows/autonomous-evolver.yml @@ -48,7 +48,7 @@ jobs: - name: Validate numeric inputs run: | set -euo pipefail - + validate_threshold() { local value=$1 local name=$2 @@ -67,7 +67,7 @@ jobs: exit 1 fi } - + validate_threshold "${{ inputs.fitness_threshold }}" "fitness_threshold" validate_threshold "${{ inputs.stability_threshold }}" "stability_threshold" echo "✓ All inputs validated successfully" @@ -110,20 +110,20 @@ jobs: // Parse and validate inputs const fitness = Math.random() * 100; const stability = Math.random() * 100; - + const parsedFitnessThreshold = Number(process.env.FITNESS_THRESHOLD); const parsedStabilityThreshold = Number(process.env.STABILITY_THRESHOLD); - + if (!Number.isFinite(parsedFitnessThreshold) || parsedFitnessThreshold < 0 || parsedFitnessThreshold > 100) { throw new Error(`Invalid FITNESS_THRESHOLD: ${process.env.FITNESS_THRESHOLD}. Must be a number between 0 and 100.`); } if (!Number.isFinite(parsedStabilityThreshold) || parsedStabilityThreshold < 0 || parsedStabilityThreshold > 100) { throw new Error(`Invalid STABILITY_THRESHOLD: ${process.env.STABILITY_THRESHOLD}. Must be a number between 0 and 100.`); } - + const fitnessThreshold = parsedFitnessThreshold; const stabilityThreshold = parsedStabilityThreshold; - + const forceEvolutionStr = (process.env.FORCE_EVOLUTION || 'false').toLowerCase().trim(); if (!['true', 'false'].includes(forceEvolutionStr)) { throw new Error(`Invalid FORCE_EVOLUTION: ${process.env.FORCE_EVOLUTION}. Must be 'true' or 'false'.`); @@ -150,7 +150,7 @@ jobs: out.push(`fitness=${fitness.toFixed(4)}`); out.push(`stability=${stability.toFixed(4)}`); out.push(`reason=${reason}`); - + fs.appendFileSync(githubOutput, out.join('\n') + '\n'); console.log('✓ Evaluation outputs written successfully'); } catch (error) { diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0b5ba69dd..f8a706c36 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -311,6 +311,7 @@ jobs: permissions: contents: write pull-requests: write + workflows: write steps: - name: Harden runner @@ -329,19 +330,21 @@ jobs: REF_NAME: ${{ github.ref_name }} run: | set -euo pipefail - ref_value="" - repo_value="" - can_push_value="false" - if [ "$EVENT_NAME" = "pull_request" ]; then - ref_value="$PR_HEAD_REF" - repo_value="$PR_HEAD_REPO" - if [ "$PR_HEAD_REPO" = "$REPOSITORY" ]; then - can_push_value="true" + { + if [ "$EVENT_NAME" = "pull_request" ]; then + echo "ref=$PR_HEAD_REF" + echo "repo=$PR_HEAD_REPO" + if [ "$PR_HEAD_REPO" = "$REPOSITORY" ]; then + echo "can_push=true" + else + echo "can_push=false" + fi + else + echo "ref=$REF_NAME" + echo "repo=$REPOSITORY" + echo "can_push=false" fi - else - ref_value="$REF_NAME" - repo_value="$REPOSITORY" - fi + } >> "$GITHUB_OUTPUT" { echo "ref=$ref_value" diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 295db9690..9cbf58f5d 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -227,7 +227,7 @@ jobs: else echo "MISSING: $f" >&3 fi - done + done >> "$REPORT" echo >&3 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index bbc147e95..aa6314d84 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -85,7 +85,8 @@ jobs: uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 with: egress-policy: audit - disable-sudo: true + # Playwright --with-deps installs browser system packages via apt (sudo). + disable-sudo: false - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: persist-credentials: false diff --git a/.github/workflows/integration-contract-gate.yml b/.github/workflows/integration-contract-gate.yml index 30cd03f7e..93c50223b 100644 --- a/.github/workflows/integration-contract-gate.yml +++ b/.github/workflows/integration-contract-gate.yml @@ -157,7 +157,7 @@ jobs: - name: Start local Functions host (for endpoint checks) id: start-func - if: runner.os == 'Linux' + if: runner.os == 'Linux' && steps.mode.outputs.strict == 'true' env: INTEGRATION_AI_STATUS_ENDPOINT: ${{ env.INTEGRATION_AI_STATUS_ENDPOINT }} RETRY_COUNT: ${{ env.RETRY_COUNT }} @@ -183,7 +183,7 @@ jobs: echo "AI status endpoint reachable: ${INTEGRATION_AI_STATUS_ENDPOINT}" exit 0 fi - ((++i)) + i=$((i + 1)) echo "Attempt ${i}/${RETRY_COUNT} — endpoint not ready yet; sleeping ${RETRY_INTERVAL}s" sleep "${RETRY_INTERVAL}" done diff --git a/SECURITY.md b/SECURITY.md index 2ff7161df..5e0820d36 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,13 +24,13 @@ Thank you for helping keep **Aria** and its users safe. This policy explains how Security fixes are provided only for versions listed below. If you’re running an unsupported release, please [upgrade](https://github.com/Bryan-Roe/Aria/releases) before reporting a vulnerability. -| Version | Supported | Notes | -| --------------- | ------------------ | --------------------------------------------- | -| latest (`main`) | :white_check_mark: | Active development branch | -| 5.1.x | :white_check_mark: | Current stable release | -| 5.0.x | :x: | End of life | -| 4.0.x | :white_check_mark: | Long-term support (critical fixes only) | -| < 4.0 | :x: | Unsupported — please upgrade | +| Version | Supported | Notes | +| --- | --- | --- | +| latest (`main`) | :white_check_mark: | Active development branch | +| 5.1.x | :white_check_mark: | Current stable release | +| 5.0.x | :x: | End of life | +| 4.0.x | :white_check_mark: | Long-term support (critical fixes only) | +| < 4.0 | :x: | Unsupported — please upgrade | Unsure which version you’re using? Review the [release notes](https://github.com/Bryan-Roe/Aria/releases). @@ -68,12 +68,12 @@ To help us confirm, triage, and fix the issue faster, please include: We strive to handle reports using a coordinated process and keep reporters informed. -| Stage | Target Response Time | -| ------------------------ | ----------------------------------------------------- | -| Initial acknowledgment | Within **3 business days** | -| Triage & assessment | Within **7 business days** | -| Progress updates | At least every **7 days** until resolution | -| Fix/disclosure | Typically within **90 days**, depending on severity | +| Stage | Target Response Time | +| --- | --- | +| Initial acknowledgment | Within **3 business days** | +| Triage & assessment | Within **7 business days** | +| Progress updates | At least every **7 days** until resolution | +| Fix/disclosure | Typically within **90 days**, depending on severity | After triage, you’ll be notified if the report is accepted, declined, or needs more info. Accepted reports proceed to remediation and coordinated disclosure. diff --git a/SMOKE_TESTS_AGI_ENDPOINTS.md b/SMOKE_TESTS_AGI_ENDPOINTS.md index d9a9cf9c0..3a3857648 100644 --- a/SMOKE_TESTS_AGI_ENDPOINTS.md +++ b/SMOKE_TESTS_AGI_ENDPOINTS.md @@ -87,9 +87,9 @@ The `/api/agi/status` endpoint response includes: ## Troubleshooting -| Issue | Solution | -| ------------------------------------ | --------------------------------------------------------------- | -| `curl: (7) Failed to connect` | Ensure `func host start` is running on port 7071 | -| `agent_tools` field missing | Check `agi_provider._AGENT_REGISTRY` is accessible | -| Tool names not sorted | Verify `sorted(set(...))` logic in `function_app.py` agi_status | -| Non-string tools in list | Check `agi_provider.py` tool configuration structure | +| Issue | Solution | +| --- | --- | +| `curl: (7) Failed to connect` | Ensure `func host start` is running on port 7071 | +| `agent_tools` field missing | Check `agi_provider._AGENT_REGISTRY` is accessible | +| Tool names not sorted | Verify `sorted(set(...))` logic in `function_app.py` agi_status | +| Non-string tools in list | Check `agi_provider.py` tool configuration structure | diff --git a/ai-projects/chat-cli/src/agi_provider.py b/ai-projects/chat-cli/src/agi_provider.py index 3f6b8a938..bc69b2852 100644 --- a/ai-projects/chat-cli/src/agi_provider.py +++ b/ai-projects/chat-cli/src/agi_provider.py @@ -349,6 +349,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": [], @@ -725,7 +738,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 ---------- @@ -831,6 +844,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 [ @@ -1129,6 +1162,10 @@ def _chain_of_thought(self, query: str, analysis: Dict[str, Any], messages: List thoughts.append("AI/ML context: distinguish training, inference, and evaluation concerns.") elif domain == "technical": thoughts.append("Technical context: prefer concrete, runnable code examples.") + elif domain == "infrastructure": + thoughts.append( + "Infrastructure context: map runtime topology, auth, and rollback before proposing changes." + ) # Surface which specialist will handle this query. selected_agent = analysis.get("selected_agent") @@ -1300,6 +1337,13 @@ def _build_agi_system_prompt(self, analysis: Dict[str, Any], reasoning_chain: Li "Recommend concrete, actionable behavioural adjustments.", "Close with a one-sentence executive summary of the overall insight.", ] + elif selected_agent == "infrastructure-specialist": + lines += [ + "You are acting as the Infrastructure Specialist.", + "Map deployment topology, CI/CD stages, secrets handling, and rollback paths.", + "Prefer the smallest safe change with explicit verification steps.", + "Call out environment-specific constraints (Azure, GitHub Actions, containers).", + ] else: # General / fallback — keep existing aria domain guidance if domain == "aria": @@ -1379,6 +1423,7 @@ def _generate_response( "debate-specialist": 0.6, "hypothesis-specialist": 0.5, "reflection-specialist": 0.4, + "infrastructure-specialist": 0.25, } original_temp = getattr(provider, "temperature", None) if selected_agent in _AGENT_TEMPERATURES: @@ -1779,7 +1824,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/ai-projects/lmstudio-mcp/agi_mcp_tools.py b/ai-projects/lmstudio-mcp/agi_mcp_tools.py new file mode 100644 index 000000000..8fa9e9d05 --- /dev/null +++ b/ai-projects/lmstudio-mcp/agi_mcp_tools.py @@ -0,0 +1,135 @@ +"""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 _normalize_agi_stream_delta(chunk) -> dict[str, Any]: + if isinstance(chunk, dict): + return chunk + return {"type": "output", "data": str(chunk)} + + +def _provider_payload(provider, choice) -> dict[str, Any]: + base = getattr(provider, "_base_provider_choice", None) + if base is not None: + base_provider = getattr(base, "name", None) + base_model = getattr(base, "model", None) + else: + base_provider = getattr(choice, "name", None) + base_model = getattr(choice, "model", None) + return { + "name": "agi", + "base_provider": base_provider, + "base_model": base_model, + "wrapper_model": getattr(choice, "model", None), + } + + +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": _provider_payload(provider, choice), + } + + +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": _provider_payload(provider, choice), + } + if include_reasoning_summary: + payload["reasoning"] = provider.get_reasoning_summary() + return payload + + +def run_agi_stream( + query: str | None = None, + messages: list[dict[str, Any]] | None = None, + *, + include_deltas: bool = True, +) -> dict[str, Any]: + """Run AGI streaming completion and return structured deltas plus full text.""" + chat_messages = _build_messages(query, messages) + create_agi_provider = _load_agi_factory() + provider, choice = create_agi_provider() + gen = provider.complete(chat_messages, stream=True) + + _non_output_types = frozenset({"analysis", "step", "payload", "error"}) + deltas: list[dict[str, Any]] = [] + text_parts: list[str] = [] + for chunk in gen: + delta = _normalize_agi_stream_delta(chunk) + if include_deltas: + deltas.append(delta) + if delta.get("type") not in _non_output_types: + text_parts.append(str(delta.get("data", ""))) + + payload: dict[str, Any] = { + "success": True, + "response": "".join(text_parts), + "provider": _provider_payload(provider, choice), + } + if include_deltas: + payload["deltas"] = deltas + return payload diff --git a/ai-projects/lmstudio-mcp/lmstudio_mcp_server.py b/ai-projects/lmstudio-mcp/lmstudio_mcp_server.py index a7a6c4e42..ffe604fd2 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, run_agi_stream # 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,207 @@ 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)", + "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", + } }, - "temperature": { - "type": "number", - "description": "Sampling temperature (0.0-2.0, default 0.7)", + "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", + }, }, - "max_tokens": { - "type": "integer", - "description": "Maximum tokens in response (default 2048)", + }, + ), + Tool( + name="agi_stream", + description="Run AGI streaming completion and return structured deltas plus full text", + 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_deltas": { + "type": "boolean", + "description": "Include structured stream deltas in the response", + }, }, }, - "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() + 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) + ), ) - ] - - elif name == "server_status": - result = await client.get_server_status() - return [ - TextContent( - type="text", - text=json.dumps(result, indent=2), + elif name == "agi_stream": + result = run_agi_stream( + query=arguments.get("query"), + messages=arguments.get("messages"), + include_deltas=bool(arguments.get("include_deltas", 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 +419,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/aria/aria_controller.js b/apps/aria/aria_controller.js index f921a47ac..9949d9673 100644 --- a/apps/aria/aria_controller.js +++ b/apps/aria/aria_controller.js @@ -449,6 +449,7 @@ const expressions = { }; function log(message, isError = false) { + if (!logContainer) return; const entry = document.createElement('div'); entry.className = 'log-entry'; if (isError) entry.classList.add('log-error'); @@ -1474,11 +1475,13 @@ function stopIdleAnimation() { // Limb movement helpers function moveArm(arm, angle, duration = 500) { + if (!arm) return; arm.style.transition = `transform ${duration}ms ease-in-out`; arm.style.transform = `rotate(${angle}deg)`; } function moveLeg(leg, angle, duration = 500) { + if (!leg) return; leg.style.transition = `transform ${duration}ms ease-in-out`; leg.style.transform = `rotate(${angle}deg)`; } diff --git a/apps/aria/server.py b/apps/aria/server.py index 14b577f59..fcc60a373 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/apps/chat/chat.js b/apps/chat/chat.js index 598bd8e43..7514f8efc 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_ANALYZE_API = `/api/agi/analyze`; 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'); @@ -85,13 +90,68 @@ let uploadedImageBase64 = null; let ariaAvatarGenerated = false; let ariaAvatarUrl = null; +function emitEmbeddedChatEvent(name, detail) { + if (!window.ARIA_EMBEDDED) return; + try { + document.dispatchEvent(new CustomEvent(name, { detail: detail || {} })); + } catch (e) { + /* ignore */ + } +} + +function notifyEmbeddedAssistant(text) { + if (!text) return; + emitEmbeddedChatEvent('aria-chat-assistant', { text: String(text) }); +} + +function initEmbeddedTransport() { + console.log('chat.js: ARIA_EMBEDDED — wiring chat transport (send + AGI)'); + + if (messageInput) { + messageInput.addEventListener('input', () => { + messageInput.style.height = 'auto'; + messageInput.style.height = Math.min(messageInput.scrollHeight, 150) + 'px'; + }); + messageInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }); + } + + if (sendButton) sendButton.addEventListener('click', sendMessage); + if (agiModeButton) agiModeButton.addEventListener('click', toggleAgiMode); + if (cancelStreamBtn) { + cancelStreamBtn.addEventListener('click', function () { + if (activeAbortController) { + activeAbortController.abort(); + updateStatus('Streaming cancelled'); + } + cancelStreamBtn.style.display = 'none'; + }); + } + + loadFromStorage(); + // Embedded page has no stream toggle UI — prefer AGI SSE streaming. + streamEnabled = true; + fetchSystemStatus(); + + window.__ariaChatTransport = { + sendMessage: sendMessage, + getAgiMode: function () { + return agiMode; + }, + toggleAgiMode: toggleAgiMode, + }; +} + // Initialize document.addEventListener('DOMContentLoaded', () => { - // When the Aria interactive page embeds chat, it provides its own - // send/receive wiring and should avoid double-wiring the controls - // in this script. A page can opt-in by setting window.ARIA_EMBEDDED=true + // Embedded Aria page keeps character/lip-sync in index.html but uses + // chat.js for send routing (including AGI stream) to avoid drift. if (window && window.ARIA_EMBEDDED) { - console.log('chat.js: ARIA_EMBEDDED detected — skipping default UI wiring'); + initEmbeddedTransport(); return; } if (cancelStreamBtn) { @@ -137,7 +197,7 @@ document.addEventListener('DOMContentLoaded', () => { } }); - sendButton.addEventListener('click', sendMessage); + if (sendButton) sendButton.addEventListener('click', sendMessage); // Vision upload handlers if (visionUploadButton) { @@ -174,54 +234,69 @@ document.addEventListener('DOMContentLoaded', () => { } }, 2000); - newChatButton.addEventListener('click', () => { - console.log('New Chat button clicked'); - newChat(); - }); - clearButton.addEventListener('click', () => { - console.log('Clear button clicked'); - clearChat(false); - }); - exportButton.addEventListener('click', exportChat); - importButton.addEventListener('click', importChat); - toggleThemeButton.addEventListener('click', toggleTheme); - quantumModeButton.addEventListener('click', toggleQuantumMode); - if (quantumPanelClose) { + if (newChatButton) { + newChatButton.addEventListener('click', () => { + console.log('New Chat button clicked'); + newChat(); + }); + } + if (clearButton) { + clearButton.addEventListener('click', () => { + console.log('Clear button clicked'); + clearChat(false); + }); + } + if (exportButton) exportButton.addEventListener('click', exportChat); + if (importButton) importButton.addEventListener('click', importChat); + if (toggleThemeButton) toggleThemeButton.addEventListener('click', toggleTheme); + if (quantumModeButton) quantumModeButton.addEventListener('click', toggleQuantumMode); + if (agiModeButton) agiModeButton.addEventListener('click', toggleAgiMode); + if (quantumPanelClose && quantumPanel) { quantumPanelClose.addEventListener('click', () => { quantumPanel.style.display = 'none'; }); } - streamToggle.addEventListener('change', (e) => { - streamEnabled = !!e.target.checked; - saveToStorage(); - }); - tempSlider.addEventListener('input', (e) => { - temperature = parseFloat(e.target.value); - tempValue.textContent = temperature.toFixed(2); - }); - tempSlider.addEventListener('change', () => saveToStorage()); - maxTokensInput.addEventListener('change', (e) => { - const val = parseInt(e.target.value, 10); - if (!isNaN(val)) { - maxOutputTokens = Math.max(64, Math.min(40960, val)); - e.target.value = String(maxOutputTokens); + if (streamToggle) { + streamToggle.addEventListener('change', (e) => { + streamEnabled = !!e.target.checked; saveToStorage(); - } - }); - toggleSystemButton.addEventListener('click', () => { - const show = systemPromptBox.style.display !== 'block'; - systemPromptBox.style.display = show ? 'block' : 'none'; - }); - systemPromptInput.addEventListener('input', (e) => { - systemPrompt = e.target.value; - saveToStorage(); - }); + }); + } + if (tempSlider) { + tempSlider.addEventListener('input', (e) => { + temperature = parseFloat(e.target.value); + if (tempValue) tempValue.textContent = temperature.toFixed(2); + }); + tempSlider.addEventListener('change', () => saveToStorage()); + } + if (maxTokensInput) { + maxTokensInput.addEventListener('change', (e) => { + const val = parseInt(e.target.value, 10); + if (!isNaN(val)) { + maxOutputTokens = Math.max(64, Math.min(40960, val)); + e.target.value = String(maxOutputTokens); + saveToStorage(); + } + }); + } + if (toggleSystemButton && systemPromptBox) { + toggleSystemButton.addEventListener('click', () => { + const show = systemPromptBox.style.display !== 'block'; + systemPromptBox.style.display = show ? 'block' : 'none'; + }); + } + if (systemPromptInput) { + systemPromptInput.addEventListener('input', (e) => { + systemPrompt = e.target.value; + saveToStorage(); + }); + } // Load saved conversations and settings loadFromStorage(); // Focus input - messageInput.focus(); + if (messageInput) messageInput.focus(); // Fetch system status on load fetchSystemStatus(); @@ -246,7 +321,7 @@ function updateStatusFromSystem() { if (!systemStatus) return; // Show success message if everything looks good - if (systemStatus.status === 'ok') { + if (systemStatus.status === 'ok' && providerInfo) { const provider = systemStatus.data?.active_provider || 'local'; const model = systemStatus.data?.model || 'unknown'; updateStatus(`Ready - ${provider.toUpperCase()}`); @@ -264,14 +339,17 @@ async function sendMessage() { const text = messageInput.value.trim(); if (!text || isProcessing) return; - // Perform quantum analysis if enabled - if (quantumMode) { + emitEmbeddedChatEvent('aria-chat-send', { text }); + + // Perform quantum analysis if enabled (disabled while AGI mode is active) + if (quantumMode && !agiMode) { updateStatus('Performing quantum analysis...'); await performQuantumAnalysis(text); } // Add user message to UI addMessage('user', text); + emitEmbeddedChatEvent('aria-chat-message', { role: 'user', content: text }); messageInput.value = ''; messageInput.style.height = 'auto'; messageInput.disabled = true; @@ -287,8 +365,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 { @@ -373,11 +458,12 @@ async function oneShotResponse(typingIndicator) { messages.push({ role: 'assistant', content: assistantMessage }); } updateMessageCount(); - if (data.provider && data.model) { + if (data.provider && data.model && providerInfo) { providerInfo.textContent = `${data.provider.toUpperCase()} - ${data.model}`; } updateStatus('Ready'); saveToStorage(); + notifyEmbeddedAssistant(assistantMessage); messageInput.disabled = false; sendButton.disabled = false; isProcessing = false; @@ -471,6 +557,7 @@ async function streamResponse(typingIndicator) { retryCount = 0; updateStatus('Ready'); saveToStorage(); + notifyEmbeddedAssistant(fullText); } catch (error) { typingIndicator.remove(); assistantDiv.remove(); @@ -492,6 +579,247 @@ async function streamResponse(typingIndicator) { } } +function extractAgiOutputText(delta) { + if (typeof delta === 'string') return delta; + if (delta?.type === 'output') return String(delta.data || ''); + return ''; +} + +function formatAgiProviderLabel(providerMeta) { + if (!providerMeta) return 'AGI'; + const base = providerMeta.base_provider; + const model = providerMeta.base_model; + if (base && base !== 'agi') { + return 'AGI (' + String(base).toUpperCase() + (model ? ' · ' + model : '') + ')'; + } + return 'AGI'; +} + +async function previewAgiRouting() { + const lastUser = [...messages].reverse().find(function (m) { return m.role === 'user'; }); + const query = lastUser && lastUser.content ? String(lastUser.content).trim() : ''; + if (!query) { + updateStatus('AGI reasoning enabled'); + return; + } + + try { + const response = await fetch(AGI_ANALYZE_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: query }), + }); + if (!response.ok) { + updateStatus('AGI reasoning enabled'); + return; + } + const data = await response.json(); + const agent = (data.routing && data.routing.selected_agent) || 'general'; + const domain = (data.analysis && data.analysis.domain) || 'general'; + updateStatus('AGI enabled → ' + agent + ' (' + domain + ')'); + if (providerInfo) { + providerInfo.textContent = formatAgiProviderLabel(data.provider); + } + } catch (e) { + updateStatus('AGI reasoning enabled'); + } +} + +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.'; + let displayMessage = assistantMessage; + if (data.reasoning && data.reasoning.last_agent_used) { + const agent = data.reasoning.last_agent_used; + const chains = data.reasoning.total_reasoning_chains; + const chainSuffix = chains ? ' (' + chains + ' chain' + (chains === 1 ? '' : 's') + ')' : ''; + displayMessage += '\n\n---\n*AGI routing: ' + agent + chainSuffix + '*'; + } + addMessage('assistant', displayMessage, true); + if (!isSyntheticCompactionPlaceholder(assistantMessage)) { + messages.push({ role: 'assistant', content: assistantMessage }); + } + updateMessageCount(); + if (providerInfo) { + providerInfo.textContent = formatAgiProviderLabel(data.provider); + } + updateStatus('Ready (AGI)'); + saveToStorage(); + notifyEmbeddedAssistant(assistantMessage); + 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); + if (providerInfo) { + providerInfo.textContent = formatAgiProviderLabel(meta); + } + } 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(); + notifyEmbeddedAssistant(outputText); + } 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}`; @@ -658,12 +986,19 @@ function toggleTheme() { document.body.classList.toggle('dark-theme'); const isDark = document.body.classList.contains('dark-theme'); localStorage.setItem('theme', isDark ? 'dark' : 'light'); - toggleThemeButton.textContent = isDark ? '☀️ Light' : '🌙 Dark'; + if (toggleThemeButton) { + toggleThemeButton.textContent = isDark ? '☀️ Light' : '🌙 Dark'; + } } function updateMessageCount() { const userMessages = messages.filter(m => m.role === 'user').length; - messageCount.textContent = userMessages; + const countEl = messageCount || document.getElementById('msgCount'); + if (countEl) { + countEl.textContent = countEl.id === 'msgCount' + ? `Messages: ${userMessages}` + : userMessages; + } } // Aria AI Avatar functions @@ -839,6 +1174,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,15 +1188,16 @@ 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'); if (savedTheme === 'dark') { document.body.classList.add('dark-theme'); - toggleThemeButton.textContent = '☀️ Light'; - } else { - toggleThemeButton.textContent = '🌙 Dark'; + if (toggleThemeButton) toggleThemeButton.textContent = '☀️ Light'; + } else if (toggleThemeButton) { + toggleThemeButton.textContent = '🌙 Dark'; } if (saved) { @@ -876,22 +1213,30 @@ function loadFromStorage() { // Restore settings streamEnabled = savedStream === '1'; - streamToggle.checked = streamEnabled; - if (savedTemp) { + if (streamToggle) streamToggle.checked = streamEnabled; + if (savedAgiMode === '1') { + agiMode = true; + if (agiModeButton) { + agiModeButton.textContent = '🧠 AGI ON'; + agiModeButton.classList.add('active'); + } + currentProvider = 'agi'; + } + if (savedTemp && tempSlider) { temperature = parseFloat(savedTemp); if (!isNaN(temperature)) { tempSlider.value = String(temperature); - tempValue.textContent = temperature.toFixed(2); + if (tempValue) tempValue.textContent = temperature.toFixed(2); } } - if (savedMax) { + if (savedMax && maxTokensInput) { const v = parseInt(savedMax, 10); if (!isNaN(v)) { maxOutputTokens = v; maxTokensInput.value = String(v); } } - if (savedSys) { + if (savedSys && systemPromptInput) { systemPrompt = savedSys; systemPromptInput.value = systemPrompt; } @@ -904,24 +1249,54 @@ 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'; + if (providerInfo) providerInfo.textContent = 'AGI'; + previewAgiRouting(); + emitEmbeddedChatEvent('aria-agi-enabled', {}); + } else { + if (agiModeButton) { + agiModeButton.textContent = '🧠 AGI OFF'; + agiModeButton.classList.remove('active'); + } + currentProvider = 'auto'; + updateStatus('AGI reasoning disabled'); + fetchSystemStatus(); + } + + saveToStorage(); +} + function toggleQuantumMode() { quantumMode = !quantumMode; if (quantumMode) { - quantumModeButton.textContent = '🔬 Quantum ON'; - quantumModeButton.classList.add('active'); - quantumIndicator.style.display = 'flex'; - quantumPanel.style.display = 'block'; + if (quantumModeButton) { + quantumModeButton.textContent = '🔬 Quantum ON'; + quantumModeButton.classList.add('active'); + } + if (quantumIndicator) quantumIndicator.style.display = 'flex'; + if (quantumPanel) quantumPanel.style.display = 'block'; currentProvider = 'quantum'; updateStatus('Quantum mode enabled'); // Fetch quantum info fetchQuantumInfo(); } else { - quantumModeButton.textContent = '🔬 Quantum OFF'; - quantumModeButton.classList.remove('active'); - quantumIndicator.style.display = 'none'; - quantumPanel.style.display = 'none'; + if (quantumModeButton) { + quantumModeButton.textContent = '🔬 Quantum OFF'; + quantumModeButton.classList.remove('active'); + } + if (quantumIndicator) quantumIndicator.style.display = 'none'; + if (quantumPanel) quantumPanel.style.display = 'none'; currentProvider = 'auto'; updateStatus('Quantum mode disabled'); } diff --git a/apps/chat/index.html b/apps/chat/index.html index 349d65ab8..40ecd6d60 100644 --- a/apps/chat/index.html +++ b/apps/chat/index.html @@ -58,15 +58,17 @@ flex-direction: column; overflow: hidden; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - opacity: 1; - pointer-events: auto; - z-index: 10000; + opacity: 0; + visibility: hidden; + pointer-events: none; + z-index: 10002; } /* Speech bubble pointer removed - bottom centered chat */ .chat-container.visible { opacity: 1; + visibility: visible; pointer-events: auto; transform: translateX(-50%); } @@ -980,6 +982,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; @@ -2187,6 +2208,76 @@ border-top-color: #3a3a3a; } + /* Expression overlays (canonical [aria:expression:*] tags) */ + .anime-character.expression-smile .character-mouth, + .anime-character.expression-happy .character-mouth { + height: 14px; + border-radius: 0 0 18px 18px; + background: #e85d8a; + } + + .anime-character.expression-sad .character-head { + transform: rotate(8deg); + } + + .anime-character.expression-sad .character-mouth { + height: 8px; + border-radius: 18px 18px 0 0; + margin-top: 6px; + } + + .anime-character.expression-surprised .eye { + transform: scaleY(1.18); + } + + .anime-character.expression-surprised .character-mouth { + width: 16px; + height: 16px; + border-radius: 50%; + margin-left: 32px; + } + + .anime-character.expression-thinking .character-head { + transform: rotate(-6deg); + } + + .anime-character.expression-wink .eye.left { + transform: scaleY(0.15); + } + + .anime-character.expression-confused .character-head { + animation: ariaConfusedTilt 900ms ease-in-out 1; + } + + @keyframes ariaConfusedTilt { + 0%, 100% { transform: rotate(0deg); } + 25% { transform: rotate(-8deg); } + 75% { transform: rotate(8deg); } + } + + .character-held-prop { + position: absolute; + right: -8px; + top: 72px; + font-size: 22px; + line-height: 1; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.25)); + opacity: 0; + transform: scale(0.6); + transition: opacity 0.25s ease, transform 0.25s ease; + pointer-events: none; + z-index: 12; + } + + .character-held-prop.visible { + opacity: 1; + transform: scale(1); + } + + .anime-character.facing-left { + transform: scaleX(-1); + } + /* Interactive Objects */ .game-object { position: fixed; @@ -2731,7 +2822,7 @@ display: none; } - + @@ -2809,6 +2900,7 @@
Hi! I'm Aria! ✨
+ @@ -2944,7 +3036,7 @@

Quick terminal test commands

-
+

🤖 QAI Chat

@@ -2977,6 +3069,8 @@

🤖 QAI Chat

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

Server environment

@@ -3006,10 +3036,26 @@

Quick terminal test commands

-
+
-

🤖 QAI Chat

-
+
+

🤖 QAI Chat

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

🤖 QAI Chat

Image preview
+
+
+ Ready +
+
+ Messages: 0 + Chars: 0 +
+
@@ -3053,7 +3114,13 @@

🤖 QAI Chat

+ + - - + diff --git a/docs/chat/static/agi_stream_utils.js b/docs/chat/static/agi_stream_utils.js new file mode 100644 index 000000000..aadc405fd --- /dev/null +++ b/docs/chat/static/agi_stream_utils.js @@ -0,0 +1,66 @@ +/* AGI SSE stream utilities for client-side consumption. + * Provides parsing and pretty-print helpers for SSE `data: {"delta": ...}` + * events emitted by /api/agi/stream. + */ + +(function (global) { + function safeJsonParse(s) { + try { + return JSON.parse(s); + } catch (e) { + return null; + } + } + + function parseSSEText(text) { + if (typeof text !== 'string') return []; + var events = text.split('\n\n').filter(function (e) { return e.trim(); }); + var deltas = []; + events.forEach(function (ev) { + var lines = ev.split('\n'); + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.indexOf('data: ') === 0) { + var jsonPart = line.slice(6); + var payload = safeJsonParse(jsonPart); + if (payload && payload.delta) deltas.push(payload.delta); + } + } + }); + return deltas; + } + + function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function prettyPrintDelta(delta) { + if (!delta || typeof delta !== 'object') return ''; + var t = delta.type; + var d = delta.data; + switch (t) { + case 'analysis': + return '
' + escapeHtml(String(d)) + '
'; + case 'step': + return '
' + escapeHtml(JSON.stringify(d, null, 2)) + '
'; + case 'output': + return '' + escapeHtml(String(d)) + ''; + case 'payload': + return '
' + escapeHtml(JSON.stringify(d, null, 2)) + '
'; + case 'error': + return '
Error: ' + escapeHtml(String(d)) + '
'; + default: + return '
' + escapeHtml(JSON.stringify(delta)) + '
'; + } + } + + global.AGIStreamUtils = { + parseSSEText: parseSSEText, + prettyPrintDelta: prettyPrintDelta, + }; +})(typeof window !== 'undefined' ? window : (typeof global !== 'undefined' ? global : this)); diff --git a/docs/deployment/DEPLOY_CHAT_TO_AZURE.md b/docs/deployment/DEPLOY_CHAT_TO_AZURE.md index 49ee0c9bc..eeb6c511d 100644 --- a/docs/deployment/DEPLOY_CHAT_TO_AZURE.md +++ b/docs/deployment/DEPLOY_CHAT_TO_AZURE.md @@ -24,8 +24,8 @@ Use the helper script from the repo root: ``` Outputs: -- Chat UI: https://chat-web-app-123.azurewebsites.net/api/chat-web -- Health: https://chat-web-app-123.azurewebsites.net/api/ai/status +- Chat UI: `https://.azurewebsites.net/api/chat-web` +- Health: `https://.azurewebsites.net/api/ai/status` If you use OpenAI instead of Azure OpenAI: ```powershell diff --git a/function_app.py b/function_app.py index 68660b688..197187a63 100644 --- a/function_app.py +++ b/function_app.py @@ -2,6 +2,7 @@ # QAI Azure Functions Application # ============================================================================= import importlib.util as _iu +import hmac import json import logging import os @@ -311,6 +312,143 @@ def serve_chat_js(req: func.HttpRequest) -> func.HttpResponse: return func.HttpResponse(f"// Error: {str(e)}", status_code=500, mimetype="application/javascript") +@app.route(route="chat-web/static/agi_stream_utils.js", methods=["GET"], auth_level=func.AuthLevel.ANONYMOUS) +def serve_agi_stream_utils(req: func.HttpRequest) -> func.HttpResponse: + """Serve AGI SSE parsing utilities for chat-web clients.""" + try: + js_path = ( + Path(__file__).resolve().parent / "apps" / "chat" / "static" / "agi_stream_utils.js" + ) + + if js_path.exists(): + with open(js_path, "r", encoding="utf-8") as f: + js_content = f.read() + + return func.HttpResponse( + js_content, + status_code=200, + mimetype="application/javascript", + headers={ + "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", + "Pragma": "no-cache", + "Expires": "0", + }, + ) + + return func.HttpResponse( + f"// Error: JavaScript file not found at {js_path}", + status_code=404, + mimetype="application/javascript", + ) + except Exception as e: + logging.error(f"Error serving agi_stream_utils.js: {str(e)}") + return func.HttpResponse(f"// Error: {str(e)}", status_code=500, mimetype="application/javascript") + + +@app.route(route="chat-web/global-upgrade.js", methods=["GET"], auth_level=func.AuthLevel.ANONYMOUS) +def serve_chat_global_upgrade_js(req: func.HttpRequest) -> func.HttpResponse: + """Serve shared global-upgrade script for chat-web.""" + try: + js_path = Path(__file__).resolve().parent / "apps" / "global-upgrade.js" + if not js_path.exists(): + return func.HttpResponse("// Error: global-upgrade.js not found", status_code=404, mimetype="application/javascript") + with open(js_path, "r", encoding="utf-8") as f: + js_content = f.read() + return func.HttpResponse( + js_content, + status_code=200, + mimetype="application/javascript", + headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}, + ) + except Exception as e: + logging.error(f"Error serving global-upgrade.js: {str(e)}") + return func.HttpResponse(f"// Error: {str(e)}", status_code=500, mimetype="application/javascript") + + +@app.route(route="chat-web/global-upgrade.css", methods=["GET"], auth_level=func.AuthLevel.ANONYMOUS) +def serve_chat_global_upgrade_css(req: func.HttpRequest) -> func.HttpResponse: + """Serve shared global-upgrade stylesheet for chat-web.""" + try: + css_path = Path(__file__).resolve().parent / "apps" / "global-upgrade.css" + if not css_path.exists(): + return func.HttpResponse("/* Error: global-upgrade.css not found */", status_code=404, mimetype="text/css") + with open(css_path, "r", encoding="utf-8") as f: + css_content = f.read() + return func.HttpResponse( + css_content, + status_code=200, + mimetype="text/css", + headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}, + ) + except Exception as e: + logging.error(f"Error serving global-upgrade.css: {str(e)}") + 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 # ============================================================================= @@ -619,6 +757,30 @@ def _create_agi_provider_for_api( return provider, provider_choice +def _agi_provider_metadata(provider, provider_choice) -> dict: + """Return AGI wrapper metadata with the detected base provider exposed.""" + base = getattr(provider, "_base_provider_choice", None) + if base is not None: + base_provider = getattr(base, "name", None) + base_model = getattr(base, "model", None) + else: + base_provider = getattr(provider_choice, "name", None) + base_model = getattr(provider_choice, "model", None) + return { + "name": "agi", + "base_provider": base_provider, + "base_model": base_model, + "wrapper_model": getattr(provider_choice, "model", None), + } + + +def _normalize_agi_stream_delta(chunk) -> dict: + """Normalize AGI stream chunks to structured delta objects for SSE clients.""" + if isinstance(chunk, dict): + return chunk + return {"type": "output", "data": str(chunk)} + + @app.route(route="agi/analyze", methods=["POST"], auth_level=func.AuthLevel.ANONYMOUS) def agi_analyze(req: func.HttpRequest) -> func.HttpResponse: """Analyze a query using AGI reasoning classifier and agent routing preview.""" @@ -646,11 +808,7 @@ def agi_analyze(req: func.HttpRequest) -> func.HttpResponse: "selected_agent": selected_agent, "agent_score": float(agent_score), }, - "provider": { - "name": "agi", - "base_provider": getattr(provider_choice, "name", None), - "base_model": getattr(provider_choice, "model", None), - }, + "provider": _agi_provider_metadata(provider, provider_choice), } return func.HttpResponse( @@ -725,14 +883,17 @@ def agi_status(req: func.HttpRequest) -> func.HttpResponse: except Exception: agent_tools = {} + # MCP bridge tools (registered in lmstudio_mcp_server, not _AGENT_REGISTRY). + agent_tools["mcp-agi"] = sorted(["agi_analyze", "agi_reason", "agi_stream"]) + + provider_meta = {"name": "agi", "base_provider": None, "base_model": None, "wrapper_model": None} + if available: + provider_meta = _agi_provider_metadata(provider, provider_choice) + payload = { "status": "ok", "available": available, - "provider": { - "name": "agi", - "base_provider": getattr(provider_choice, "name", None), - "base_model": getattr(provider_choice, "model", None), - }, + "provider": provider_meta, "reasoning": summary, "agent_tools": agent_tools, "endpoints": [ @@ -740,6 +901,7 @@ def agi_status(req: func.HttpRequest) -> func.HttpResponse: "/api/agi/reason", "/api/agi/stream", "/api/agi/status", + "/api/agi/persistence", ], } @@ -796,11 +958,7 @@ def agi_reason(req: func.HttpRequest) -> func.HttpResponse: "status": "ok", "query": query, "response": str(result), - "provider": { - "name": "agi", - "base_provider": getattr(provider_choice, "name", None), - "base_model": getattr(provider_choice, "model", None), - }, + "provider": _agi_provider_metadata(provider, provider_choice), } if include_summary: payload["reasoning"] = provider.get_reasoning_summary() @@ -870,17 +1028,14 @@ def agi_stream(req: func.HttpRequest) -> func.HttpResponse: def _sse_iterable(): try: - pre = { - "provider": "agi", - "base_provider": getattr(provider_choice, "name", None), - "base_model": getattr(provider_choice, "model", None), - } + pre = _agi_provider_metadata(provider, provider_choice) yield (f"event: meta\n" f"data: {json.dumps(pre)}\n\n").encode("utf-8") for chunk in gen: if not chunk: continue - payload = json.dumps({"delta": chunk}) + delta = _normalize_agi_stream_delta(chunk) + payload = json.dumps({"delta": delta}) yield (f"data: {payload}\n\n").encode("utf-8") yield b"data: [DONE]\n\n" @@ -947,7 +1102,10 @@ def agi_persistence(req: func.HttpRequest) -> func.HttpResponse: provided_token = provided_token.split(" ", 1)[1] except Exception: provided_token = None - if provided_token != token_required: + if not ( + isinstance(provided_token, str) + and hmac.compare_digest(provided_token, token_required) + ): return func.HttpResponse( json.dumps({"status": "error", "error": "unauthorized"}), status_code=401, @@ -974,9 +1132,8 @@ 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") - + jsonl_enabled = os.getenv("QAI_AGI_PERSIST", "true").lower() in ("1", "true", "yes") + default_jsonl_path = _default_agi_persist_jsonl_path() if sqlite_path: try: from shared.agi_persistence_sqlite import SQLiteAGIPersistence @@ -1000,9 +1157,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): @@ -1015,9 +1171,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), - status_code=200, + 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(), ) @@ -1451,6 +1614,16 @@ 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(chunks) -> bytes: + """Backward-compatible alias for tests and callers expecting the PR name.""" + return _sse_body_bytes(chunks) + + def _sse_body_bytes(chunks) -> bytes: """Coerce SSE chunks into bytes for Azure Functions HttpResponse bodies.""" if isinstance(chunks, bytes): @@ -2852,6 +3025,7 @@ def detect_conflict(versions): public_endpoints = [ "/api/chat-web", "/api/chat-web/chat.js", + "/api/chat-web/static/agi_stream_utils.js", "/api/chat", "/api/chat/stream", "/api/health", @@ -2863,6 +3037,10 @@ def detect_conflict(versions): "/api/agi/reason", "/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", @@ -2946,6 +3124,14 @@ def ai_routes(req: func.HttpRequest) -> func.HttpResponse: "methods": ["POST"], "authLevel": "anonymous"}, {"route": "agi/stream", "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"}, { @@ -2960,6 +3146,11 @@ def ai_routes(req: func.HttpRequest) -> func.HttpResponse: "methods": ["GET"], "authLevel": "anonymous", }, + { + "route": "chat-web/static/agi_stream_utils.js", + "methods": ["GET"], + "authLevel": "anonymous", + }, ] payload = {"count": len(routes), "functions": routes} return func.HttpResponse( @@ -4921,7 +5112,6 @@ def _err(): return _sse_response(_err(), status_code=200) - @app.route(route="referrals/leaderboard", methods=["GET"], auth_level=func.AuthLevel.ANONYMOUS) def referral_leaderboard(req: func.HttpRequest) -> func.HttpResponse: """ diff --git a/functions/http_ai_status/__init__.py b/functions/http_ai_status/__init__.py index f419eb95c..d38a17363 100644 --- a/functions/http_ai_status/__init__.py +++ b/functions/http_ai_status/__init__.py @@ -240,8 +240,14 @@ def main(req: func.HttpRequest) -> func.HttpResponse: "endpoints": [ "/api/chat-web", "/api/chat-web/chat.js", + "/api/chat-web/static/agi_stream_utils.js", "/api/chat", "/api/ai/status", + "/api/agi/analyze", + "/api/agi/reason", + "/api/agi/stream", + "/api/agi/status", + "/api/agi/persistence", ], "status": "ok", } diff --git a/functions/http_chat_web/function_app.py b/functions/http_chat_web/function_app.py index 016f34026..46ca0b2bc 100644 --- a/functions/http_chat_web/function_app.py +++ b/functions/http_chat_web/function_app.py @@ -5,10 +5,8 @@ from shared.http_utils import serve_static_file -# Ensure repository root is on sys.path so shared utilities can be imported as a package -repo_root = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(repo_root)) - +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) app = func.FunctionApp() @@ -16,7 +14,7 @@ @app.route(route="chat-web", methods=["GET"], auth_level=func.AuthLevel.ANONYMOUS) def serve_chat_web(req: func.HttpRequest) -> func.HttpResponse: """Serve the chat web interface""" - html_path = Path(__file__).resolve().parent.parent / "apps" / "chat" / "index.html" + html_path = REPO_ROOT / "apps" / "chat" / "index.html" content, status_code, headers = serve_static_file(html_path, "text/html", use_cache_headers=True) return func.HttpResponse(content, status_code=status_code, mimetype="text/html", headers=headers) @@ -25,7 +23,7 @@ def serve_chat_web(req: func.HttpRequest) -> func.HttpResponse: @app.route(route="chat-web/chat.js", methods=["GET"], auth_level=func.AuthLevel.ANONYMOUS) def serve_chat_js(req: func.HttpRequest) -> func.HttpResponse: """Serve the chat JavaScript file""" - js_path = Path(__file__).resolve().parent.parent / "apps" / "chat" / "chat.js" + js_path = REPO_ROOT / "apps" / "chat" / "chat.js" content, status_code, headers = serve_static_file(js_path, "application/javascript", use_cache_headers=True) return func.HttpResponse( @@ -34,3 +32,19 @@ def serve_chat_js(req: func.HttpRequest) -> func.HttpResponse: mimetype="application/javascript", headers=headers, ) + + +@app.route(route="chat-web/static/agi_stream_utils.js", methods=["GET"], auth_level=func.AuthLevel.ANONYMOUS) +def serve_agi_stream_utils(req: func.HttpRequest) -> func.HttpResponse: + """Serve AGI SSE parsing utilities for chat-web clients.""" + js_path = REPO_ROOT / "apps" / "chat" / "static" / "agi_stream_utils.js" + content, status_code, headers = serve_static_file( + js_path, "application/javascript", use_cache_headers=True + ) + + return func.HttpResponse( + content, + status_code=status_code, + mimetype="application/javascript", + headers=headers, + ) diff --git a/local.settings.json.example b/local.settings.json.example index 2827db0c9..f320c9478 100644 --- a/local.settings.json.example +++ b/local.settings.json.example @@ -28,7 +28,12 @@ "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": "", + "# 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/logs/lora_signals.jsonl b/logs/lora_signals.jsonl index 94bdd4129..8e75ce747 100644 --- a/logs/lora_signals.jsonl +++ b/logs/lora_signals.jsonl @@ -2,7 +2,6 @@ {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} -{"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} @@ -37,16 +36,6 @@ {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} -<<<<<<< Updated upstream -{"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} -{"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} -{"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} -{"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} -{"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} -{"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} -{"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} -<<<<<<< HEAD -{"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} @@ -65,11 +54,6 @@ {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} -{"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} -{"signal": "train", "payload": {"goal": "improve"}} -======= -======= ->>>>>>> a40f84a20 (hhh) {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} @@ -101,17 +85,11 @@ {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} -<<<<<<< HEAD -{"signal": "train", "payload": {"goal": "improve"}} -{"signal": "train", "payload": {"goal": "improve"}} -{"signal": "train", "payload": {"goal": "improve"}} -======= {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} ->>>>>>> a40f84a20 (hhh) {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} @@ -119,14 +97,11 @@ {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} -<<<<<<< HEAD -======= {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} ->>>>>>> a40f84a20 (hhh) {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} @@ -134,66 +109,46 @@ {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} -<<<<<<< HEAD -{"signal": "train", "payload": {"goal": "improve"}} -{"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} -{"signal": "train", "payload": {"goal": "improve"}} -{"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} -{"signal": "train", "payload": {"goal": "improve"}} -{"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} -{"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} -{"signal": "train", "payload": {"goal": "improve"}} -{"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} -{"signal": "train", "payload": {"goal": "improve"}} -======= {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} ->>>>>>> a40f84a20 (hhh) {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} -<<<<<<< HEAD -======= {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} ->>>>>>> a40f84a20 (hhh) {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} -<<<<<<< HEAD -======= {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} ->>>>>>> a40f84a20 (hhh) {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} -<<<<<<< HEAD +{"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} -======= {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} @@ -284,5 +239,3 @@ {"signal": "train", "payload": {"goal": "improve"}} {"signal": "train", "payload": {"goal": "Refine runtime behavior based on: Balance near-term execution with mid-term improvement. No history. Generate a simple useful system improvement goal.", "source": "self_assess"}} {"signal": "train", "payload": {"goal": "improve"}} ->>>>>>> Stashed changes ->>>>>>> a40f84a20 (hhh) diff --git a/processes.json b/processes.json index 7b663592b..0967ef424 100644 --- a/processes.json +++ b/processes.json @@ -1,12 +1 @@ -{ -<<<<<<< Updated upstream - "aria": 17031, - "training": 17079, - "quantum": 17081, - "evaluation": 17102 -} -======= - "aria": 2742, - "quantum": 3142 -} ->>>>>>> Stashed changes +{} diff --git a/pyproject.toml b/pyproject.toml index a3a453d10..f9bc28e95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ include = ["shared*"] [tool.pytest.ini_options] testpaths = ["tests"] +norecursedirs = ["ai-projects", "datasets", "data_out", "mount", ".venv", "venv", "node_modules"] addopts = "-q --tb=short" asyncio_mode = "auto" markers = [ diff --git a/requirements-dev.txt b/requirements-dev.txt index 4ccc73c15..2b9677eba 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,4 +8,4 @@ isort>=6.1.0 ruff>=0.15.18 mypy>=1.19.1 pre-commit>=4.3.0 -# Add any additional dev tools below +tomli>=2.0.1; python_version < "3.11" diff --git a/scripts/integration_smoke.py b/scripts/integration_smoke.py index a2a6a3690..2784f5309 100644 --- a/scripts/integration_smoke.py +++ b/scripts/integration_smoke.py @@ -38,13 +38,21 @@ "/api/ai/status", "/api/chat", "/api/chat-web", + "/api/chat-web/static/agi_stream_utils.js", "/api/tts", "/api/quantum/run", + "/api/agi/status", + "/api/agi/analyze", + "/api/agi/reason", + "/api/agi/stream", } _REQUIRED_AI_ROUTE_NAMES = { "ai/status", "chat", "chat-web", + "agi/status", + "agi/analyze", + "agi/reason", "agi/stream", } @@ -328,6 +336,14 @@ def _probe_agi_endpoints(strict: bool) -> List[StepResult]: "required_key": "analysis", "sse": False, }, + { + "name": "functions_agi_reason_endpoint", + "url": "http://localhost:7071/api/agi/reason", + "method": "POST", + "payload": {"query": "integration smoke reason"}, + "required_key": "response", + "sse": False, + }, { "name": "functions_agi_stream_endpoint", "url": "http://localhost:7071/api/agi/stream", @@ -429,6 +445,44 @@ def _probe_agi_endpoints(strict: bool) -> List[StepResult]: return results +def _probe_chat_web_assets(strict: bool) -> StepResult: + """Verify AGI stream utilities are served for chat-web clients.""" + name = "functions_chat_web_agi_stream_utils" + start = time.perf_counter() + url = "http://localhost:7071/api/chat-web/static/agi_stream_utils.js" + try: + req = Request(url, method="GET") + with urlopen(req, timeout=LOCAL_DEV_ADAPTER_REQUEST_TIMEOUT_SEC) as resp: + body = resp.read().decode("utf-8", errors="replace") + if "AGIStreamUtils" not in body: + raise ValueError("missing_AGIStreamUtils_marker") + duration = round(time.perf_counter() - start, 2) + return StepResult( + name=name, + status="succeeded", + critical=strict, + duration_sec=duration, + detail="has_marker=AGIStreamUtils", + ) + except (URLError, TimeoutError, OSError, ValueError): + duration = round(time.perf_counter() - start, 2) + if strict: + return StepResult( + name=name, + status="failed", + critical=True, + duration_sec=duration, + detail=f"endpoint_unreachable={url}", + ) + return StepResult( + name=name, + status="skipped", + critical=False, + duration_sec=duration, + detail="functions host not running (non-strict mode)", + ) + + def _probe_functions_endpoint(strict: bool) -> StepResult: name = "functions_ai_status_endpoint" start = time.perf_counter() @@ -718,6 +772,7 @@ def run_smoke(strict_endpoints: bool) -> Dict[str, Any]: steps.append(_probe_functions_endpoint(strict_endpoints)) steps.append(_probe_ai_routes_endpoint(strict_endpoints)) steps.extend(_probe_agi_endpoints(strict_endpoints)) + steps.append(_probe_chat_web_assets(strict_endpoints)) total = len(steps) succeeded = sum(1 for s in steps if s.status == "succeeded") diff --git a/scripts/resource_monitor.py b/scripts/resource_monitor.py index 8399a91f4..85f34b220 100644 --- a/scripts/resource_monitor.py +++ b/scripts/resource_monitor.py @@ -185,6 +185,7 @@ def _collect_gpu() -> List[Dict[str, Any]]: return gpus except ( FileNotFoundError, + PermissionError, subprocess.CalledProcessError, subprocess.TimeoutExpired, ): diff --git a/scripts/sync_docs_chat.py b/scripts/sync_docs_chat.py new file mode 100644 index 000000000..f8e8ea8f5 --- /dev/null +++ b/scripts/sync_docs_chat.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Sync apps/chat assets into docs/chat for GitHub Pages parity.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SRC = REPO_ROOT / "apps" / "chat" +DST = REPO_ROOT / "docs" / "chat" + + +def sync_docs_chat() -> None: + """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") + + +def main() -> int: + sync_docs_chat() + print(f"Synced chat assets: {SRC} -> {DST}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/shared/agi_persistence_sqlite.py b/shared/agi_persistence_sqlite.py index d944be2c7..9b97d8eca 100644 --- a/shared/agi_persistence_sqlite.py +++ b/shared/agi_persistence_sqlite.py @@ -63,7 +63,7 @@ def write_reasoning_chain(self, chain: List[Dict[str, Any]], meta: Optional[Dict def add_reasoning_chain(self, chain: List[Dict[str, Any]]) -> str: """Backwards-compatible alias for :meth:`write_reasoning_chain`.""" - + return self.write_reasoning_chain(chain) def add_message(self, message: Dict[str, Any]) -> str: """Persist one message row and return its generated id.""" eid = uuid.uuid4().hex diff --git a/tests/js/test_agi_stream_utils.mjs b/tests/js/test_agi_stream_utils.mjs new file mode 100644 index 000000000..d77bd356c --- /dev/null +++ b/tests/js/test_agi_stream_utils.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import vm from 'node:vm'; +import { describe, it } from 'node:test'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const UTILS_PATH = path.resolve(__dirname, '../../apps/chat/static/agi_stream_utils.js'); + +function loadAgiStreamUtils() { + const source = fs.readFileSync(UTILS_PATH, 'utf8'); + const context = { global: {} }; + vm.runInContext(source, vm.createContext(context)); + assert.ok(context.global.AGIStreamUtils, 'AGIStreamUtils should attach to global'); + return context.global.AGIStreamUtils; +} + +describe('AGIStreamUtils', () => { + const { parseSSEText, prettyPrintDelta } = loadAgiStreamUtils(); + + it('parseSSEText extracts delta payloads from SSE blocks', () => { + const sse = [ + 'event: meta', + 'data: {"provider":"agi"}', + '', + 'data: {"delta":{"type":"analysis","data":"intent=coding"}}', + '', + 'data: {"delta":{"type":"output","data":"Hello"}}', + '', + 'data: [DONE]', + '', + ].join('\n'); + + const deltas = parseSSEText(sse); + assert.equal(deltas.length, 2); + assert.equal(deltas[0].type, 'analysis'); + assert.equal(deltas[1].type, 'output'); + assert.equal(deltas[1].data, 'Hello'); + }); + + it('parseSSEText ignores malformed JSON lines', () => { + const deltas = parseSSEText('data: not-json\n\n'); + assert.equal(deltas.length, 0); + }); + + it('prettyPrintDelta renders known delta types safely', () => { + const html = prettyPrintDelta({ type: 'output', data: '' }); + assert.match(html, /agi-output/); + assert.doesNotMatch(html, / { + const html = prettyPrintDelta({ type: 'unknown', data: { foo: 'bar' } }); + assert.match(html, /agi-unknown/); + assert.match(html, /foo/); + }); + + it('parseSSEText handles normalized string output deltas', () => { + const sse = 'data: {"delta":{"type":"output","data":"Hi"}}\n\n'; + const deltas = parseSSEText(sse); + assert.equal(deltas.length, 1); + assert.equal(deltas[0].type, 'output'); + assert.equal(deltas[0].data, 'Hi'); + }); +}); 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_persistence_sqlite.py b/tests/test_agi_persistence_sqlite.py index 12ec2b297..0e6f8581d 100644 --- a/tests/test_agi_persistence_sqlite.py +++ b/tests/test_agi_persistence_sqlite.py @@ -23,5 +23,12 @@ def test_agi_persistence_sqlite(tmp_path, monkeypatch): assert last.get("type") == "reasoning_chain" assert isinstance(last.get("chain"), list) + alias_id = provider.persistence.add_reasoning_chain( + [{"step_type": "analyze", "content": "alias test", "confidence": 1.0, "metadata": {}}] + ) + assert alias_id + alias_entries = provider.persistence.read_last(5) + assert any(entry.get("id") == alias_id for entry in alias_entries) + # cleanup monkeypatch.delenv("QAI_AGI_PERSIST_DB", raising=False) diff --git a/tests/test_agi_provider.py b/tests/test_agi_provider.py index ec8f2e180..29de8673d 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() @@ -348,6 +368,23 @@ def test_chain_of_thought(self): assert len(thoughts) > 0 assert any("quantum" in t.lower() for t in thoughts) + def test_chain_of_thought_infrastructure_domain(self): + """Infrastructure domain should add deployment-aware reasoning hints.""" + mock_provider = MockBaseProvider() + agi = AGIProvider(base_provider=mock_provider) + + analysis = { + "intent": "question", + "domain": "infrastructure", + "complexity": "moderate", + "summary": "Moderate question query about infrastructure", + } + messages = [{"role": "user", "content": "How do I roll back a bad deploy?"}] + + thoughts = agi._chain_of_thought("How do I roll back a bad deploy?", analysis, messages) + + assert any("Infrastructure context" in t for t in thoughts) + def test_self_reflection_aria_movement(self): """Test self-reflection adds Aria movement tags when needed.""" mock_provider = MockBaseProvider() @@ -1048,6 +1085,11 @@ def test_reflection_specialist_persona(self): assert "Reflection Specialist" in prompt assert "lessons" in prompt.lower() or "adjustments" in prompt.lower() + def test_infrastructure_specialist_persona(self): + prompt = self._build_prompt("infrastructure-specialist") + assert "Infrastructure Specialist" in prompt + assert "rollback" in prompt.lower() or "ci/cd" in prompt.lower() + class TestNewSpecialistTemperatures: """Tests that new specialists have dedicated temperature settings. @@ -1069,6 +1111,7 @@ class TestNewSpecialistTemperatures: "debate-specialist": 0.6, "hypothesis-specialist": 0.5, "reflection-specialist": 0.4, + "infrastructure-specialist": 0.25, } def test_all_new_specialists_have_temperature_entries(self): @@ -1097,6 +1140,10 @@ def test_code_and_reasoning_chain_are_most_deterministic(self): for agent in ("code-specialist", "reasoning-chain-specialist"): assert self._EXPECTED_TEMPERATURES[agent] <= 0.3, f"{agent} should be <= 0.3" + def test_infrastructure_specialist_temperature_is_low(self): + """infrastructure-specialist should stay deterministic for deployment advice.""" + assert self._EXPECTED_TEMPERATURES["infrastructure-specialist"] <= 0.3 + def test_temperature_settings_exercised_during_complete(self): """Running complete with a summarize query exercises the temperature code path.""" mock = MockBaseProvider() diff --git a/tests/test_agi_smoke.py b/tests/test_agi_smoke.py index 9c28d7886..89f931e82 100644 --- a/tests/test_agi_smoke.py +++ b/tests/test_agi_smoke.py @@ -65,6 +65,16 @@ def test_agi_status_exposes_lmstudio_agent_tools(app_module): }.issubset(lmstudio_tools) +def test_agi_status_exposes_mcp_agi_tools(app_module): + req = _mock_request("GET") + resp = app_module.agi_status(req) + assert resp.status_code == 200 + + data = json.loads(resp.get_body()) + mcp_tools = set((data.get("agent_tools") or {}).get("mcp-agi") or []) + assert {"agi_analyze", "agi_reason", "agi_stream"}.issubset(mcp_tools) + + def test_agi_analyze_requires_query_or_messages(app_module): req = _mock_request("POST", body={}) resp = app_module.agi_analyze(req) @@ -83,6 +93,14 @@ def test_agi_stream_requires_query_or_messages(app_module): 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" + def test_agi_status_response_schema(app_module): """Guard test: ensure agi/status response shape remains stable.""" req = _mock_request("GET") @@ -139,4 +157,4 @@ def test_agi_status_agent_tools_deterministic(app_module): for agent_name in tools1: assert tools1[agent_name] == tools2[agent_name] # Verify no duplicates (set size equals list size) - assert len(set(tools1[agent_name])) == len(tools1[agent_name]) + assert len(set(tools1[agent_name])) == len(tools1[agent_name]) \ No newline at end of file diff --git a/tests/test_agi_stream_utils_js.py b/tests/test_agi_stream_utils_js.py new file mode 100644 index 000000000..9a0ff1e69 --- /dev/null +++ b/tests/test_agi_stream_utils_js.py @@ -0,0 +1,29 @@ +"""Run Node-based unit tests for apps/chat/static/agi_stream_utils.js.""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +JS_TEST = REPO_ROOT / "tests" / "js" / "test_agi_stream_utils.mjs" + + +@pytest.mark.unit +def test_agi_stream_utils_js_harness(): + node = shutil.which("node") + if node is None: + pytest.skip("node executable not available") + + proc = subprocess.run( + [node, "--test", str(JS_TEST)], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + check=False, + ) + + assert proc.returncode == 0, proc.stdout + proc.stderr diff --git a/tests/test_aria_server.py b/tests/test_aria_server.py index 9d4d27cd3..998d8c47b 100644 --- a/tests/test_aria_server.py +++ b/tests/test_aria_server.py @@ -845,6 +845,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_chat_web_embedded_script.py b/tests/test_chat_web_embedded_script.py new file mode 100644 index 000000000..5ae5e850d --- /dev/null +++ b/tests/test_chat_web_embedded_script.py @@ -0,0 +1,60 @@ +"""Validate chat-web embedded controller script parses cleanly.""" + +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +INDEX_HTML = REPO_ROOT / "apps" / "chat" / "index.html" +MARKER = "// Anime Character Controller with AI Chat" + + +def _extract_embedded_script(html: str) -> str: + start = html.index(MARKER) + end = html.index("", start) + return html[start:end] + + +def test_embedded_controller_script_has_balanced_structure(): + script = _extract_embedded_script(INDEX_HTML.read_text(encoding="utf-8")) + assert script.count("{") == script.count("}") + assert "Send + AGI routing delegated to chat.js" in script + assert "aria-chat-assistant" in script + assert "moveAriaToPercent" in script + assert "function ariaSpin" in script + assert "sendBubbleMessage" in script + assert "__ariaChatTransport" in script + + +def test_embedded_controller_parses_canonical_aria_tags(): + html = INDEX_HTML.read_text(encoding="utf-8") + assert "[aria:position:" in html or "moveAriaToNamedPosition" in html + assert "function parseAriaCommands" in html + script = _extract_embedded_script(html) + assert "moveAriaToNamedPosition" in script + assert "ariaSpin" in script + + +def test_embedded_controller_stage_bridge_and_tag_handlers(): + html = INDEX_HTML.read_text(encoding="utf-8") + script = _extract_embedded_script(html) + assert "function parseCanonicalAriaTags" in script + assert "function bridgeAssistantTextToStage" in script + assert "function setAriaExpression" in script + assert "function ariaPickup" in script + 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 + assert "[aria:pickup:" in script or "ariaPickup" in script + assert "[aria:look" in script or "ariaLook" in script + + +def test_chat_js_declares_embedded_transport(): + chat_js = (REPO_ROOT / "apps" / "chat" / "chat.js").read_text(encoding="utf-8") + assert "function initEmbeddedTransport()" in chat_js + assert "window.__ariaChatTransport" in chat_js + assert "emitEmbeddedChatEvent" in chat_js diff --git a/tests/test_function_app_endpoints.py b/tests/test_function_app_endpoints.py index 6d8c04627..6ad44c6ea 100644 --- a/tests/test_function_app_endpoints.py +++ b/tests/test_function_app_endpoints.py @@ -46,26 +46,6 @@ def _mock_request( return req -def _capture_sse_http_response(monkeypatch, app_module, captured: dict) -> None: - """Patch HttpResponse so SSE bodies are captured as bytes or generators.""" - import inspect - - import azure.functions as _af - - _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) - if isinstance(body, (bytes, bytearray)): - captured["sse_body"] = bytes(body) - return _real_HttpResponse(body, **kwargs) - - monkeypatch.setattr(app_module.func, "HttpResponse", _capturing_HttpResponse) - - def _install_fake_quantum_trainer_module( monkeypatch: pytest.MonkeyPatch, capture: dict | None = None, @@ -575,9 +555,6 @@ def test_chat_stream_whitespace_only_input_text_block_message(self, app_module): def test_chat_stream_guardrail_blocks_prompt_injection(self, app_module, monkeypatch): """POST /api/chat/stream should emit safe fallback SSE when prompt is blocked.""" - captured: dict = {"sse_body": b""} - _capture_sse_http_response(monkeypatch, app_module, captured) - req = _mock_request( "POST", body={ @@ -591,14 +568,13 @@ def test_chat_stream_guardrail_blocks_prompt_injection(self, app_module, monkeyp ) 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.""" - 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 @@ -608,7 +584,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}] - _capture_sse_http_response(monkeypatch, app_module, captured) monkeypatch.setattr(app_module, "generate_embedding", _fake_embedding) monkeypatch.setattr( app_module, "fetch_similar_messages", _fake_similar) @@ -624,7 +599,7 @@ def _fake_similar(query_emb, top_k=5, session_id=None, min_similarity=0.0): 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:"): @@ -643,8 +618,6 @@ def _fake_similar(query_emb, top_k=5, session_id=None, min_similarity=0.0): def test_chat_stream_emits_done_sentinel(self, app_module, monkeypatch): """POST /api/chat/stream should terminate SSE with data: [DONE].""" - captured: dict = {"sse_body": b""} - class _FakeProvider: def complete(self, messages, stream=False): assert stream is True @@ -666,8 +639,6 @@ def complete(self, messages, stream=False): lambda query_emb, top_k=5, session_id=None, min_similarity=0.0: [], ) - _capture_sse_http_response(monkeypatch, app_module, captured) - req = _mock_request( "POST", body={"messages": [{"role": "user", "content": "say hi"}]}, @@ -675,7 +646,7 @@ def complete(self, messages, stream=False): 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 @@ -686,6 +657,80 @@ def test_tts_no_text(self, app_module): assert resp.status_code in (400, 500) +# =========================================================================== +# Chat web static assets +# =========================================================================== +class TestChatWebAssets: + def test_serve_agi_stream_utils(self, app_module): + req = _mock_request("GET") + resp = app_module.serve_agi_stream_utils(req) + + assert resp.status_code == 200 + body = resp.get_body().decode("utf-8") + assert "AGIStreamUtils" in body + 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 # =========================================================================== @@ -710,7 +755,7 @@ def _select_agent(self, analysis): "create_agi_provider", lambda **kwargs: ( _FakeAgiProvider(), - types.SimpleNamespace(name="local", model="local-echo"), + types.SimpleNamespace(name="agi", model="agi-local-local-echo"), ), ) @@ -726,6 +771,26 @@ def _select_agent(self, analysis): assert data["analysis"]["intent"] == "coding" assert data["routing"]["selected_agent"] == "code-specialist" assert data["provider"]["name"] == "agi" + assert data["provider"]["wrapper_model"] == "agi-local-local-echo" + + def test_agi_provider_metadata_uses_base_choice(self, app_module): + provider = types.SimpleNamespace( + _base_provider_choice=types.SimpleNamespace(name="local", model="local-echo") + ) + wrapper = types.SimpleNamespace(name="agi", model="agi-local-local-echo") + meta = app_module._agi_provider_metadata(provider, wrapper) + assert meta["name"] == "agi" + assert meta["base_provider"] == "local" + assert meta["base_model"] == "local-echo" + assert meta["wrapper_model"] == "agi-local-local-echo" + + def test_normalize_agi_stream_delta_wraps_strings(self, app_module): + delta = app_module._normalize_agi_stream_delta("Hello") + assert delta == {"type": "output", "data": "Hello"} + assert app_module._normalize_agi_stream_delta({"type": "analysis", "data": "x"}) == { + "type": "analysis", + "data": "x", + } def test_agi_analyze_validation_error_when_missing_query(self, app_module): req = _mock_request("POST", body={}) @@ -738,6 +803,8 @@ def test_agi_analyze_validation_error_when_missing_query(self, app_module): def test_agi_status_returns_reasoning_summary(self, app_module, monkeypatch): class _FakeAgiProvider: + _base_provider_choice = types.SimpleNamespace(name="azure", model="gpt-4o") + def get_reasoning_summary(self): return { "total_reasoning_chains": 3, @@ -755,7 +822,7 @@ def get_reasoning_summary(self): "create_agi_provider", lambda **kwargs: ( _FakeAgiProvider(), - types.SimpleNamespace(name="azure", model="gpt-4o"), + types.SimpleNamespace(name="agi", model="agi-azure-gpt-4o"), ), ) @@ -767,6 +834,8 @@ def get_reasoning_summary(self): assert data["status"] == "ok" assert data["available"] is True assert data["provider"]["name"] == "agi" + assert data["provider"]["base_provider"] == "azure" + assert data["provider"]["base_model"] == "gpt-4o" assert data["reasoning"]["total_reasoning_chains"] == 3 agent_tools = data.get("agent_tools") or {} lmstudio_tools = set(agent_tools.get("lmstudio-specialist") or []) @@ -778,6 +847,8 @@ def get_reasoning_summary(self): def test_agi_reason_returns_response_and_summary(self, app_module, monkeypatch): class _FakeAgiProvider: + _base_provider_choice = types.SimpleNamespace(name="local", model="local-echo") + def __init__(self): self.goals = [] @@ -806,7 +877,7 @@ def get_reasoning_summary(self): "create_agi_provider", lambda **kwargs: ( _FakeAgiProvider(), - types.SimpleNamespace(name="openai", model="gpt-test"), + types.SimpleNamespace(name="agi", model="agi-local-local-echo"), ), ) @@ -824,6 +895,8 @@ def get_reasoning_summary(self): assert data["status"] == "ok" assert data["response"] == "Here is a reasoned response" assert data["reasoning"]["active_goals"] == ["be concise"] + assert data["provider"]["base_provider"] == "local" + assert data["provider"]["base_model"] == "local-echo" def test_agi_reason_validation_error_when_missing_input(self, app_module): req = _mock_request("POST", body={}) @@ -835,9 +908,9 @@ 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): - captured: dict = {"sse_body": b""} - class _FakeAgiProvider: + _base_provider_choice = types.SimpleNamespace(name="local", model="local-echo") + def complete(self, messages, stream=False): assert stream is True yield "Hello" @@ -855,8 +928,6 @@ def set_goal(self, _goal: str): ), ) - _capture_sse_http_response(monkeypatch, app_module, captured) - req = _mock_request( "POST", body={"query": "stream a short response", "goals": ["be concise"]}, @@ -864,9 +935,12 @@ def set_goal(self, _goal: str): 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 '"base_provider": "local"' in body_text + assert '"type": "output"' in body_text + assert '"data": "Hello"' in body_text + assert '"data": " world"' in body_text assert "data: [DONE]" in body_text def test_agi_stream_validation_error_when_missing_input(self, app_module): diff --git a/tests/test_integration_smoke_schema.py b/tests/test_integration_smoke_schema.py index 40eb6668c..4d6e95224 100644 --- a/tests/test_integration_smoke_schema.py +++ b/tests/test_integration_smoke_schema.py @@ -68,6 +68,28 @@ def _fake_run_command(name, cmd, *, critical=True, timeout=180): ) ], ) + monkeypatch.setattr( + smoke_module, + "_probe_ai_routes_endpoint", + lambda strict: smoke_module.StepResult( + name="functions_ai_routes_endpoint", + status="skipped", + critical=False, + duration_sec=0.0, + detail="functions host not running (non-strict mode)", + ), + ) + monkeypatch.setattr( + smoke_module, + "_probe_chat_web_assets", + lambda strict: smoke_module.StepResult( + name="functions_chat_web_agi_stream_utils", + status="skipped", + critical=False, + duration_sec=0.0, + detail="functions host not running (non-strict mode)", + ), + ) monkeypatch.setattr( smoke_module, "resolve_existing_config_path", @@ -200,8 +222,13 @@ def test_validate_ai_status_payload_requires_core_keys_and_endpoints() -> None: "/api/ai/status", "/api/chat", "/api/chat-web", + "/api/chat-web/static/agi_stream_utils.js", "/api/tts", "/api/quantum/run", + "/api/agi/status", + "/api/agi/analyze", + "/api/agi/reason", + "/api/agi/stream", ], } ) @@ -222,6 +249,9 @@ def test_probe_ai_routes_endpoint_uses_functions_contract(monkeypatch: pytest.Mo {"route": "ai/status"}, {"route": "chat"}, {"route": "chat-web"}, + {"route": "agi/status"}, + {"route": "agi/analyze"}, + {"route": "agi/reason"}, {"route": "agi/stream"}, ], }, @@ -231,4 +261,34 @@ def test_probe_ai_routes_endpoint_uses_functions_contract(monkeypatch: pytest.Mo assert result.status == "succeeded" assert result.name == "functions_ai_routes_endpoint" - assert "routes=4" in result.detail + assert "routes=7" in result.detail + + +@pytest.mark.unit +def test_probe_chat_web_assets_requires_agi_stream_utils_marker( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeResponse: + def __init__(self, body: str): + self._body = body.encode("utf-8") + + def read(self) -> bytes: + return self._body + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + monkeypatch.setattr( + smoke_module, + "urlopen", + lambda *_args, **_kwargs: _FakeResponse("global.AGIStreamUtils = {}"), + ) + + result = smoke_module._probe_chat_web_assets(strict=True) + + assert result.status == "succeeded" + assert result.name == "functions_chat_web_agi_stream_utils" + assert "AGIStreamUtils" in result.detail diff --git a/tests/test_lmstudio_agi_integration.py b/tests/test_lmstudio_agi_integration.py index 513be5b8d..fbddfc2e9 100644 --- a/tests/test_lmstudio_agi_integration.py +++ b/tests/test_lmstudio_agi_integration.py @@ -34,10 +34,11 @@ def test_agent_registration(): print("✓ Agent registration test passed") -def test_provider_detection(): +def test_provider_detection(monkeypatch): """Test that detect_provider can create LMStudioProvider.""" from chat_providers import LMStudioProvider, detect_provider + monkeypatch.setenv("LMSTUDIO_MODEL", "local-model") with patch("chat_providers.OpenAI") as mock_openai_cls: mock_openai_cls.return_value = MagicMock() provider, choice = detect_provider(explicit="lmstudio") @@ -77,6 +78,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 +99,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), ] diff --git a/tests/test_lmstudio_agi_integration_impl.py b/tests/test_lmstudio_agi_integration_impl.py index 8d892a41e..0b423d4b4 100644 --- a/tests/test_lmstudio_agi_integration_impl.py +++ b/tests/test_lmstudio_agi_integration_impl.py @@ -45,6 +45,7 @@ def fake_urlopen(request, timeout=None, *a, **k): captured["timeout"] = timeout return _FakeResp(200) + monkeypatch.delenv("LMSTUDIO_BASE_URL", raising=False) monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) assert mod._check_lmstudio_available() is True diff --git a/tests/test_lmstudio_mcp_agi_tools.py b/tests/test_lmstudio_mcp_agi_tools.py new file mode 100644 index 000000000..a40eac0ce --- /dev/null +++ b/tests/test_lmstudio_mcp_agi_tools.py @@ -0,0 +1,106 @@ +"""Unit tests for AGI MCP helper tools.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +REPO_ROOT = Path(__file__).parent.parent +LMSTUDIO_MCP_DIR = REPO_ROOT / "ai-projects" / "lmstudio-mcp" +if str(LMSTUDIO_MCP_DIR) not in sys.path: + sys.path.insert(0, str(LMSTUDIO_MCP_DIR)) + +from agi_mcp_tools import run_agi_analyze, run_agi_reason, run_agi_stream # noqa: E402 + + +class _FakeAgiProvider: + def _analyze_query(self, query: str): + assert query == "design a safer routing layer" + return { + "complexity": "complex", + "intent": "coding", + "domain": "ai", + "confidence": 0.9, + } + + def _select_agent(self, analysis): + return "code-specialist", 0.87 + + def complete(self, messages, stream=False): + assert stream is False + return "Reasoned AGI response" + + def get_reasoning_summary(self): + return {"total_reasoning_chains": 1, "available_agents": ["code-specialist"]} + + +def test_run_agi_analyze_returns_routing(monkeypatch): + def fake_factory(**kwargs): + return _FakeAgiProvider(), SimpleNamespace(name="local", model="local-echo") + + monkeypatch.setattr("agi_mcp_tools._load_agi_factory", lambda: fake_factory) + + payload = run_agi_analyze("design a safer routing layer") + + assert payload["success"] is True + assert payload["routing"]["selected_agent"] == "code-specialist" + assert payload["provider"]["name"] == "agi" + + +def test_run_agi_reason_requires_query_or_messages(): + with pytest.raises(ValueError, match="Provide either"): + run_agi_reason() + + +def test_run_agi_reason_with_query(monkeypatch): + def fake_factory(**kwargs): + return _FakeAgiProvider(), SimpleNamespace(name="local", model="local-echo") + + monkeypatch.setattr("agi_mcp_tools._load_agi_factory", lambda: fake_factory) + + payload = run_agi_reason(query="hello agi") + + assert payload["success"] is True + assert payload["response"] == "Reasoned AGI response" + assert payload["reasoning"]["total_reasoning_chains"] == 1 + + +def test_run_agi_stream_returns_structured_deltas(monkeypatch): + class _StreamingProvider: + _base_provider_choice = SimpleNamespace(name="local", model="local-echo") + + def complete(self, messages, stream=False): + assert stream is True + yield "Hello" + yield {"type": "analysis", "data": "intent=coding"} + yield " world" + + def fake_factory(**kwargs): + return _StreamingProvider(), SimpleNamespace(name="agi", model="agi-local-local-echo") + + monkeypatch.setattr("agi_mcp_tools._load_agi_factory", lambda: fake_factory) + + payload = run_agi_stream(query="stream this") + + assert payload["success"] is True + assert payload["response"] == "Hello world" + assert payload["provider"]["base_provider"] == "local" + delta_types = {delta.get("type") for delta in payload["deltas"]} + assert "output" in delta_types + assert "analysis" in delta_types + + +def test_lmstudio_mcp_server_exports_client_without_exit(): + import importlib.util + + spec_path = LMSTUDIO_MCP_DIR / "lmstudio_mcp_server.py" + spec = importlib.util.spec_from_file_location("lmstudio_mcp_server_client_export", spec_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + assert hasattr(module, "LMStudioClient") + assert hasattr(module, "get_client")