ruslanmv commited on
Commit
8d60e33
·
0 Parent(s):

First commit

Browse files
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ assets/*.png filter=lfs diff=lfs merge=lfs -text
.github/workflows/sync-to-hf-space.yml ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # .github/workflows/sync-to-hf-space.yml
2
+ name: Sync to Hugging Face Space (a2a-validator)
3
+
4
+ on:
5
+ push:
6
+ branches: ["main", "master"]
7
+ workflow_dispatch: {} # optional manual run
8
+
9
+ jobs:
10
+ sync-to-hub:
11
+ runs-on: ubuntu-latest
12
+ concurrency:
13
+ group: hf-space-sync-a2a-validator
14
+ cancel-in-progress: false
15
+
16
+ # Hard-wire your Space coordinates here
17
+ env:
18
+ HF_USERNAME: ruslanmv
19
+ SPACE_NAME: a2a-validator
20
+
21
+ steps:
22
+ - name: Checkout
23
+ uses: actions/checkout@v4
24
+ with:
25
+ fetch-depth: 0
26
+ lfs: true
27
+
28
+ - name: Prepare Git
29
+ run: |
30
+ git config user.name "github-actions[bot]"
31
+ git config user.email "github-actions[bot]@users.noreply.github.com"
32
+ git lfs install --local
33
+
34
+ - name: Force push GitHub → Hugging Face Space (GitHub is source of truth)
35
+ env:
36
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
37
+ run: |
38
+ set -e
39
+ if [ -z "$HF_TOKEN" ]; then
40
+ echo "❌ Missing HF_TOKEN secret. Add it under: Settings → Secrets and variables → Actions → New repository secret."
41
+ exit 1
42
+ fi
43
+
44
+ # Build the authenticated remote URL *inside* the step so the secret expands.
45
+ REMOTE_URL="https://${HF_USERNAME}:${HF_TOKEN}@huggingface.co/spaces/${HF_USERNAME}/${SPACE_NAME}"
46
+
47
+ echo "🔁 Forcing HEAD → Space main (GitHub is the source of truth)..."
48
+ git push --force "$REMOTE_URL" HEAD:main
49
+
50
+ # Best-effort push for LFS objects (won't fail the job if none)
51
+ git lfs push --all "$REMOTE_URL" main || true
52
+
53
+ echo "✅ Sync complete: GitHub HEAD is now Space main."
.gitignore ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python artifacts
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+
11
+ # Virtual environments
12
+ .venv/
13
+ venv/
14
+ ENV/
15
+
16
+ # Environment files
17
+ .env
18
+
19
+ # Test & coverage reports
20
+ .cache/
21
+ .pytest_cache/
22
+ .mypy_cache/
23
+ htmlcov/
24
+ .coverage
25
+
26
+ # IDE & OS files
27
+ .idea/
28
+ .vscode/
29
+ .DS_Store
30
+ Thumbs.db
31
+
32
+ # RAG index files
33
+ .faiss/
34
+ /backup
35
+ copy *.*
36
+ * copy.*
37
+ * copy *.py
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1
2
+ FROM python:3.11-slim
3
+
4
+ # --- base env ---
5
+ ENV PYTHONDONTWRITEBYTECODE=1 \
6
+ PYTHONUNBUFFERED=1 \
7
+ PIP_NO_CACHE_DIR=1
8
+
9
+ # --- system deps ---
10
+ RUN apt-get update \
11
+ && apt-get install -y --no-install-recommends ca-certificates curl \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ # --- app dir ---
15
+ WORKDIR /app
16
+
17
+ # --- python deps (cache friendly layer) ---
18
+ COPY requirements.txt ./
19
+ RUN pip install --upgrade pip && pip install -r requirements.txt
20
+
21
+ # --- copy app ---
22
+ COPY . .
23
+
24
+ # Hugging Face sets $PORT at runtime; keep a sane default for local runs
25
+ ENV PORT=7860
26
+ EXPOSE 7860
27
+
28
+ # Optional: run as non-root
29
+ # RUN useradd -ms /bin/bash appuser && chown -R appuser:appuser /app
30
+ # USER appuser
31
+
32
+ # --- start (shell form so $PORT expands) ---
33
+ # --proxy-headers is helpful behind HF’s proxy
34
+ CMD uvicorn app.main:app --host 0.0.0.0 --port $PORT --proxy-headers
LICENSE ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
Makefile ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ====================================================================================
2
+ #
3
+ # M A T R I X - A I ::: C O N T R O L P R O G R A M
4
+ # "Know thyself."
5
+ #
6
+ # Access programs with: make help
7
+ #
8
+ # ====================================================================================
9
+
10
+ # System & Colors
11
+ BRIGHT_GREEN := $(shell tput -T screen setaf 10)
12
+ DIM_GREEN := $(shell tput -T screen setaf 2)
13
+ RESET := $(shell tput -T screen sgr0)
14
+
15
+ # Python / Venv
16
+ SYS_PYTHON := python3
17
+ VENV_DIR := .venv
18
+ PYTHON := $(VENV_DIR)/bin/python
19
+ PIP := $(PYTHON) -m pip
20
+
21
+ # App
22
+ APP_MODULE := app.main:app
23
+ PORT := 7860
24
+
25
+ # Docker / HF Spaces
26
+ IMG_NAME := a2a-validator:local
27
+ SPACE_URL ?= https://huggingface.co/spaces/ruslanmv/a2a-validator
28
+
29
+ # Files & Dirs
30
+ REQ := requirements.txt
31
+ TEST_DIR := tests
32
+
33
+ .DEFAULT_GOAL := help
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Help
37
+ # ---------------------------------------------------------------------------
38
+ help:
39
+ @echo
40
+ @echo "$(BRIGHT_GREEN)M A T R I X - A I ::: C O N T R O L P R O G R A M$(RESET)"
41
+ @echo
42
+ @printf "$(BRIGHT_GREEN) %-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "PROGRAM" "DESCRIPTION"
43
+ @printf "$(BRIGHT_GREEN) %-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "----------------------" "--------------------------------------------------------"
44
+ @echo
45
+ @echo "$(BRIGHT_GREEN)Environment$(RESET)"
46
+ @printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "venv" "Create virtualenv (.venv)"
47
+ @printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "install" "Install deps into venv (incremental)"
48
+ @echo
49
+ @echo "$(BRIGHT_GREEN)Quality$(RESET)"
50
+ @printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "lint" "ruff check"
51
+ @printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "fmt" "black + ruff fix"
52
+ @printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "test" "pytest"
53
+ @echo
54
+ @echo "$(BRIGHT_GREEN)Run$(RESET)"
55
+ @printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "run" "Run uvicorn (PORT=$(PORT))"
56
+ @printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "run-hot" "Run with --reload"
57
+ @echo
58
+ @echo "$(BRIGHT_GREEN)Docker$(RESET)"
59
+ @printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "docker-build" "Build local image ($(IMG_NAME))"
60
+ @printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "docker-run" "Run local container (maps $(PORT))"
61
+ @echo
62
+ @echo "$(BRIGHT_GREEN)HF Spaces helpers$(RESET)"
63
+ @printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "space-url" "Echo the Space URL (set SPACE_URL=...)"
64
+ @echo
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Env
68
+ # ---------------------------------------------------------------------------
69
+ $(VENV_DIR)/bin/activate:
70
+ @test -d $(VENV_DIR) || $(SYS_PYTHON) -m venv $(VENV_DIR)
71
+
72
+ venv: $(VENV_DIR)/bin/activate
73
+ @echo "$(DIM_GREEN)-> Upgrading pip/setuptools/wheel$(RESET)"
74
+ @$(PIP) install -U pip setuptools wheel >/dev/null
75
+
76
+ install: venv
77
+ @echo "$(DIM_GREEN)-> Installing deps$(RESET)"
78
+ @$(PIP) install -r $(REQ)
79
+ @echo "$(BRIGHT_GREEN)OK$(RESET)"
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Quality
83
+ # ---------------------------------------------------------------------------
84
+ lint: venv
85
+ @$(PYTHON) -m ruff check app tests || true
86
+
87
+ fmt: venv
88
+ @$(PYTHON) -m black app tests || true
89
+ @$(PYTHON) -m ruff check --fix app tests || true
90
+
91
+ test: venv
92
+ @$(PYTHON) -m pytest -q --disable-warnings --maxfail=1 || true
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Run
96
+ # ---------------------------------------------------------------------------
97
+ run: install
98
+ @PORT=$(PORT) $(VENV_DIR)/bin/uvicorn $(APP_MODULE) --host 0.0.0.0 --port $(PORT)
99
+
100
+ run-hot: install
101
+ @PORT=$(PORT) $(VENV_DIR)/bin/uvicorn $(APP_MODULE) --host 0.0.0.0 --port $(PORT) --reload
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Docker
105
+ # ---------------------------------------------------------------------------
106
+ docker-build:
107
+ @docker build -t $(IMG_NAME) .
108
+
109
+ docker-run:
110
+ @docker run --rm -it -p $(PORT):$(PORT) -e PORT=$(PORT) $(IMG_NAME)
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # HF Helpers
114
+ # ---------------------------------------------------------------------------
115
+ space-url:
116
+ @echo "Space: $(SPACE_URL)"
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Clean
120
+ # ---------------------------------------------------------------------------
121
+ clean:
122
+ @rm -rf .venv __pycache__ .pytest_cache .ruff_cache .mypy_cache dist build *.egg-info
123
+
124
+ .PHONY: help venv install lint fmt test run run-hot kb kb-force docker-build docker-run space-url clean
README.md ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: A2A Validator
3
+ emoji: 🔬
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # A2A Validator 🔬
11
+ ![](assets/2025-10-05-00-49-00.png)
12
+ **A2A Validator** is a focused web app for testing AI agents that speak the **A2A (Agent-to-Agent) protocol**. Paste an agent URL, connect, and the app will fetch the agent’s discovery document, validate it against the protocol, and provide a real-time message terminal. It’s a tool designed for the build/integration loop: tight feedback, zero ceremony.
13
+
14
+ The backend runs on **FastAPI** and **Socket.IO**, with a lightweight HTML/JS frontend. The core validator checks both the static **Agent Card** and the live events an agent emits. If the optional `a2a-sdk` is present, it's used for card resolution and streaming; if not, the app gracefully falls back to a "plain HTTP" mode for basic card linting.
15
+
16
+ ---
17
+
18
+ ## ✨ Features
19
+
20
+ * **Inspector UI**: A clean interface at `/validator` to enter an agent URL and optional custom HTTP headers for auth or tenancy.
21
+ * **Smart Card Resolution**: Forgivingly handles root URLs by following redirects and probing common well-known paths (e.g., `/.well-known/agent.json`).
22
+ * **Inline Validation**: Pretty-prints the agent's JSON card and displays any validation errors or warnings directly in the UI.
23
+ * **Live Chat Terminal**: When the `a2a-sdk` is installed, a real-time terminal connects via Socket.IO to stream messages to and from the agent.
24
+ * **Real-time Message Linting**: Live messages are marked with ✅ (compliant) or ⚠️ (non-compliant). Click any message to view the raw JSON.
25
+ * **Integrated Debug Console**: A resizable console shows raw server-side request/response logs, minimizing the need for browser DevTools.
26
+
27
+ ---
28
+
29
+ ## 🚀 Quick Start
30
+
31
+ Create a virtual environment, install dependencies, and run the server:
32
+
33
+ ```bash
34
+ # 1. Create venv and activate
35
+ python3 -m venv .venv
36
+ source .venv/bin/activate
37
+
38
+ # 2. Install dependencies for editable mode
39
+ pip install -e .
40
+
41
+ # 3. Run the development server
42
+ make run
43
+ ````
44
+
45
+ or
46
+ ```bash
47
+ uvicorn app.main:app --host 0.0.0.0 --port 7860 --reload
48
+ ```
49
+
50
+ Point your browser to the Inspector UI:
51
+
52
+ ➡️ **http://localhost:7860/validator**
53
+
54
+ You can also test the card fetch endpoint directly from your terminal:
55
+
56
+ ```bash
57
+ curl -s -X POST localhost:7860/agent-card \
58
+ -H 'content-type: application/json' \
59
+ -d '{"url":"http://localhost:8080/","sid":"test"}' | jq
60
+ ```
61
+
62
+ -----
63
+
64
+ ## 🛠️ How It Works
65
+
66
+ The validator checks for common issues that break interoperability.
67
+
68
+ * **Agent Card Validation**: It checks for required fields (`name`, `description`, `url`, `capabilities`, `skills`, etc.), correct types, a semver-like version string, and a valid absolute URL.
69
+ * **Message Validation**: During a live session, it validates incoming events based on their `kind`. For example, `task` events must have an `id` and `status.state`, while `message` events must have a `parts` array and the correct `role`.
70
+
71
+ ### Architecture
72
+
73
+ ```mermaid
74
+ flowchart LR
75
+ subgraph Browser
76
+ UI[Inspector UI]
77
+ end
78
+
79
+ subgraph Server [FastAPI App]
80
+ API[/POST /agent-card<br/>GET /validator/]
81
+ WS[[Socket.IO]]
82
+ end
83
+
84
+ subgraph Agent [A2A Agent]
85
+ RPC[(JSON-RPC Endpoint)]
86
+ end
87
+
88
+ UI -- HTTP & WebSocket --> Server
89
+ Server -- HTTP & JSON-RPC --> Agent
90
+ ```
91
+
92
+ ### Sequence Diagram
93
+
94
+ ```mermaid
95
+ sequenceDiagram
96
+ participant U as UI
97
+ participant S as Server
98
+ participant A as Agent
99
+
100
+ U->>S: POST /agent-card { url, sid }
101
+ S->>A: Resolve + GET card (follows redirects)
102
+ A-->>S: Card JSON
103
+ S->>S: Validate Card
104
+ S-->>U: { card, validation_errors }
105
+
106
+ U->>S: socket 'initialize_client'
107
+ S-->>U: 'client_initialized'
108
+ U->>S: socket 'send_message' { message }
109
+ S->>A: JSON-RPC call / stream
110
+ A-->>S: Agent responses
111
+ S->>S: Validate each response
112
+ S-->>U: 'agent_response' with validation notes
113
+ ```
114
+
115
+ -----
116
+
117
+ ## ⚙️ Configuration & Endpoints
118
+
119
+ The application is designed to work out of the box with minimal setup.
120
+
121
+ * `GET /validator`: Serves the Inspector UI.
122
+ * `POST /agent-card`: The API endpoint for fetching and validating an Agent Card.
123
+ * `/socket.io`: The path for real-time WebSocket connections.
124
+ * `/healthz` & `/readyz`: Standard health check endpoints.
125
+
126
+ Static assets are served from `app/static` and templates from `app/templates`. The main UI is `validator.html`, which extends a base template. The server creates an alias for `/agent-card` at the root, so the frontend script works correctly even when the validator is embedded in a larger application.
127
+
128
+ -----
129
+
130
+ ## 🔍 Troubleshooting
131
+
132
+ * **307 Temporary Redirect**: If you paste a root URL (e.g., `http://my-agent/`) and see this, it often means the agent is redirecting to a docs page. The validator will automatically try to find the card at common paths like `/.well-known/agent.json`. If it still fails, try pasting the direct URL to the agent card JSON file.
133
+ * **404 Not Found on `/agent-card`**: Ensure the API route alias is correctly configured in your `app/main.py` file. This is crucial if the `validator_service` router is mounted behind a prefix.
134
+ * **"A2A SDK not installed"**: This is not an error but a feature. The app is running in its lightweight, HTTP-only mode. To enable chat and streaming tests, install the `a2a-sdk` package (`pip install "a2a-sdk[http-server]"`).
135
+
136
+ -----
137
+
138
+ ## 📜 License
139
+
140
+ This project is licensed under the **Apache-2.0 License**. Use it, tweak it, ship it.
app/__init__.py ADDED
File without changes
app/bootstrap.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/bootstrap.py
2
+ """
3
+ App bootstrap: load .env and configure logging as early as possible.
4
+ This module should be imported once at process start (import side-effects).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from dotenv import load_dotenv
10
+
11
+ # Load environment from configs/.env if present (non-fatal if missing)
12
+ load_dotenv(dotenv_path=os.path.join("configs", ".env"))
13
+
14
+ # Configure logging after env is loaded so LOG_LEVEL is respected
15
+ try:
16
+ from app.core.logging import setup_logging # noqa: E402
17
+ setup_logging()
18
+ except Exception as e:
19
+ # Fallback to a minimal logger if our setup helper isn't available for any reason
20
+ import logging as _logging
21
+ _logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper())
22
+ _logging.getLogger(__name__).warning("Fallback logging configured: %s", e)
app/core/__init__.py ADDED
File without changes
app/core/config.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import os, yaml
3
+ from pydantic import BaseModel, AnyHttpUrl
4
+ from typing import Optional, List
5
+
6
+ class ModelCfg(BaseModel):
7
+ # HF Router defaults (used when we reach the router)
8
+ name: str = "HuggingFaceH4/zephyr-7b-beta"
9
+ fallback: str = "mistralai/Mistral-7B-Instruct-v0.2"
10
+ max_new_tokens: int = 256
11
+ temperature: float = 0.2
12
+ provider: Optional[str] = None # HF Router provider tag (e.g., "featherless-ai")
13
+
14
+ # New: provider-specific default models
15
+ groq_model: str = "llama-3.1-8b-instant"
16
+ gemini_model: str = "gemini-2.5-flash"
17
+
18
+ class LimitsCfg(BaseModel):
19
+ rate_per_min: int = 60
20
+ cache_size: int = 256
21
+
22
+ class RagCfg(BaseModel):
23
+ index_dataset: Optional[str] = None
24
+ top_k: int = 4
25
+
26
+ class MatrixHubCfg(BaseModel):
27
+ base_url: AnyHttpUrl = "https://api.matrixhub.io"
28
+
29
+ class SecurityCfg(BaseModel):
30
+ admin_token: Optional[str] = None
31
+
32
+ class Settings(BaseModel):
33
+ model: ModelCfg = ModelCfg()
34
+ limits: LimitsCfg = LimitsCfg()
35
+ rag: RagCfg = RagCfg()
36
+ matrixhub: MatrixHubCfg = MatrixHubCfg()
37
+ security: SecurityCfg = SecurityCfg()
38
+
39
+ # New
40
+ provider_order: List[str] = ["groq", "gemini", "router"] # cascade order
41
+ chat_backend: str = "multi" # was "router"; "multi" enables cascade
42
+ chat_stream: bool = True
43
+
44
+ @staticmethod
45
+ def load() -> "Settings":
46
+ path = os.getenv("SETTINGS_FILE", "configs/settings.yaml")
47
+ data = {}
48
+ if os.path.exists(path):
49
+ with open(path, "r", encoding="utf-8") as f:
50
+ data = yaml.safe_load(f) or {}
51
+
52
+ settings = Settings.model_validate(data)
53
+
54
+ # Existing env overrides
55
+ if "MODEL_NAME" in os.environ:
56
+ settings.model.name = os.environ["MODEL_NAME"]
57
+ if "MODEL_FALLBACK" in os.environ:
58
+ settings.model.fallback = os.environ["MODEL_FALLBACK"]
59
+ if "MODEL_PROVIDER" in os.environ:
60
+ settings.model.provider = os.environ["MODEL_PROVIDER"]
61
+ if "ADMIN_TOKEN" in os.environ:
62
+ settings.security.admin_token = os.environ["ADMIN_TOKEN"]
63
+ if "RATE_LIMITS" in os.environ:
64
+ settings.limits.rate_per_min = int(os.environ["RATE_LIMITS"])
65
+ if "HF_CHAT_BACKEND" in os.environ:
66
+ settings.chat_backend = os.environ["HF_CHAT_BACKEND"].strip().lower()
67
+ if "CHAT_STREAM" in os.environ:
68
+ settings.chat_stream = os.environ["CHAT_STREAM"].lower() in ("1","true","yes","on")
69
+
70
+ # New env overrides
71
+ if "GROQ_MODEL" in os.environ:
72
+ settings.model.groq_model = os.environ["GROQ_MODEL"]
73
+ if "GEMINI_MODEL" in os.environ:
74
+ settings.model.gemini_model = os.environ["GEMINI_MODEL"]
75
+ if "PROVIDER_ORDER" in os.environ:
76
+ settings.provider_order = [p.strip().lower() for p in os.environ["PROVIDER_ORDER"].split(",") if p.strip()]
77
+
78
+ # Default to cascade
79
+ if settings.chat_backend not in ("multi", "router"):
80
+ settings.chat_backend = "multi"
81
+
82
+ return settings
app/core/inference/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .client import ChatClient, chat, get_client
2
+
3
+ __all__ = ["ChatClient", "chat", "get_client"]
app/core/inference/client.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/core/inference/client.py
2
+ from __future__ import annotations
3
+
4
+ """
5
+ Unified chat client module.
6
+
7
+ - Exposes a production-ready MultiProvider cascade client (GROQ → Gemini → HF Router),
8
+ via ChatClient / chat(...).
9
+ - Keeps the legacy RouterRequestsClient for direct access to the HF Router compatible
10
+ /v1/chat/completions endpoint, preserving backward compatibility.
11
+
12
+ This file assumes:
13
+ - app/bootstrap.py exists and loads configs/.env + sets up logging.
14
+ - app/core/config.py provides Settings (with provider_order, etc.).
15
+ - app/core/inference/providers.py implements MultiProviderChat orchestrator.
16
+ """
17
+
18
+ import os
19
+ import json
20
+ import time
21
+ import logging
22
+ from typing import Dict, List, Optional, Iterator, Tuple, Iterable, Union, Generator
23
+
24
+ # Ensure .env & logging before we load settings/providers
25
+ import app.bootstrap # noqa: F401
26
+
27
+ import requests
28
+
29
+ from app.core.config import Settings
30
+ from app.core.inference.providers import MultiProviderChat
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # -----------------------------
35
+ # Multi-provider cascade client
36
+ # -----------------------------
37
+
38
+ Message = Dict[str, str]
39
+
40
+ class ChatClient:
41
+ """
42
+ Unified chat client that executes the configured provider cascade.
43
+ Providers are tried in order (settings.provider_order). First success wins.
44
+ """
45
+ def __init__(self, settings: Settings | None = None):
46
+ self._settings = settings or Settings.load()
47
+ self._chain = MultiProviderChat(self._settings)
48
+
49
+ def chat(
50
+ self,
51
+ messages: Iterable[Message],
52
+ temperature: Optional[float] = None,
53
+ max_new_tokens: Optional[int] = None,
54
+ stream: Optional[bool] = None,
55
+ ) -> Union[str, Generator[str, None, None]]:
56
+ """
57
+ Execute a chat completion using the provider cascade.
58
+
59
+ Args:
60
+ messages: Iterable of {"role": "system|user|assistant", "content": "..."}
61
+ temperature: Optional override for sampling temperature.
62
+ max_new_tokens: Optional override for max tokens.
63
+ stream: If None, uses settings.chat_stream. If True, returns a generator of text chunks.
64
+
65
+ Returns:
66
+ str (non-stream) or generator[str] (stream)
67
+ """
68
+ use_stream = self._settings.chat_stream if stream is None else bool(stream)
69
+ return self._chain.chat(
70
+ messages,
71
+ temperature=temperature,
72
+ max_new_tokens=max_new_tokens,
73
+ stream=use_stream,
74
+ )
75
+
76
+ # Backward-compatible helpers
77
+ _default_client: ChatClient | None = None
78
+
79
+ def _get_default() -> ChatClient:
80
+ global _default_client
81
+ if _default_client is None:
82
+ _default_client = ChatClient()
83
+ return _default_client
84
+
85
+ def chat(
86
+ messages: Iterable[Message],
87
+ temperature: Optional[float] = None,
88
+ max_new_tokens: Optional[int] = None,
89
+ stream: Optional[bool] = None,
90
+ ) -> Union[str, Generator[str, None, None]]:
91
+ """
92
+ Convenience function using a process-wide default ChatClient.
93
+ """
94
+ return _get_default().chat(messages, temperature=temperature, max_new_tokens=max_new_tokens, stream=stream)
95
+
96
+ def get_client(settings: Settings | None = None) -> ChatClient:
97
+ """
98
+ Factory for an explicit ChatClient bound to provided settings.
99
+ """
100
+ return ChatClient(settings)
101
+
102
+
103
+ # ------------------------------------------------------
104
+ # Legacy HF Router client (kept for backward compatibility)
105
+ # ------------------------------------------------------
106
+
107
+ ROUTER_URL = "https://router.huggingface.co/v1/chat/completions"
108
+
109
+ def _require_token() -> str:
110
+ tok = os.getenv("HF_TOKEN")
111
+ if not tok:
112
+ raise ValueError("HF_TOKEN is not set. Put it in .env or export it before starting.")
113
+ return tok
114
+
115
+ def _model_with_provider(model: str, provider: Optional[str]) -> str:
116
+ if provider and ":" not in model:
117
+ return f"{model}:{provider}"
118
+ return model
119
+
120
+ def _mk_messages(system_prompt: Optional[str], user_text: str) -> List[Dict[str, str]]:
121
+ msgs: List[Dict[str, str]] = []
122
+ if system_prompt:
123
+ msgs.append({"role": "system", "content": system_prompt})
124
+ msgs.append({"role": "user", "content": user_text})
125
+ return msgs
126
+
127
+ def _timeout_tuple(connect: float = 10.0, read: float = 60.0) -> Tuple[float, float]:
128
+ return (connect, read)
129
+
130
+ class RouterRequestsClient:
131
+ """
132
+ Simple requests-only client for HF Router Chat Completions.
133
+ Supports non-streaming (returns str) and streaming (yields token strings).
134
+
135
+ NOTE: New code should prefer ChatClient above. This class is preserved for any
136
+ legacy call sites that rely on direct HF Router access.
137
+ """
138
+ def __init__(
139
+ self,
140
+ model: str,
141
+ fallback: Optional[str] = None,
142
+ provider: Optional[str] = None,
143
+ max_retries: int = 2,
144
+ connect_timeout: float = 10.0,
145
+ read_timeout: float = 60.0
146
+ ):
147
+ self.model = model
148
+ self.fallback = fallback if fallback != model else None
149
+ self.provider = provider
150
+ self.headers = {"Authorization": f"Bearer {_require_token()}"}
151
+ self.max_retries = max(0, int(max_retries))
152
+ self.timeout = _timeout_tuple(connect_timeout, read_timeout)
153
+
154
+ # -------- Non-stream (single text) --------
155
+ def chat_nonstream(
156
+ self,
157
+ system_prompt: Optional[str],
158
+ user_text: str,
159
+ max_tokens: int,
160
+ temperature: float,
161
+ stop: Optional[List[str]] = None,
162
+ frequency_penalty: Optional[float] = None,
163
+ presence_penalty: Optional[float] = None,
164
+ ) -> str:
165
+ payload = {
166
+ "model": _model_with_provider(self.model, self.provider),
167
+ "messages": _mk_messages(system_prompt, user_text),
168
+ "temperature": float(max(0.0, temperature)),
169
+ "max_tokens": int(max_tokens),
170
+ "stream": False,
171
+ }
172
+ if stop:
173
+ payload["stop"] = stop
174
+ if frequency_penalty is not None:
175
+ payload["frequency_penalty"] = float(frequency_penalty)
176
+ if presence_penalty is not None:
177
+ payload["presence_penalty"] = float(presence_penalty)
178
+
179
+ text, ok = self._try_once(payload)
180
+ if ok:
181
+ return text
182
+
183
+ # fallback (if configured)
184
+ if self.fallback:
185
+ payload["model"] = _model_with_provider(self.fallback, self.provider)
186
+ text, ok = self._try_once(payload)
187
+ if ok:
188
+ return text
189
+
190
+ raise RuntimeError(f"Chat non-stream failed: model={self.model} fallback={self.fallback}")
191
+
192
+ def _try_once(self, payload: dict) -> Tuple[str, bool]:
193
+ last_err: Optional[Exception] = None
194
+ for attempt in range(self.max_retries + 1):
195
+ try:
196
+ r = requests.post(ROUTER_URL, headers=self.headers, json=payload, timeout=self.timeout)
197
+ if r.status_code >= 400:
198
+ logger.error("Router error %s: %s", r.status_code, r.text)
199
+ last_err = RuntimeError(f"{r.status_code}: {r.text}")
200
+ time.sleep(min(1.5 * (attempt + 1), 3.0))
201
+ continue
202
+ data = r.json()
203
+ return data["choices"][0]["message"]["content"], True
204
+ except Exception as e:
205
+ logger.error("Router request failure: %s", e)
206
+ last_err = e
207
+ time.sleep(min(1.5 * (attempt + 1), 3.0))
208
+ if last_err:
209
+ logger.error("Router exhausted retries: %s", last_err)
210
+ return "", False
211
+
212
+ # -------- Streaming (yield token deltas) --------
213
+ def chat_stream(
214
+ self,
215
+ system_prompt: Optional[str],
216
+ user_text: str,
217
+ max_tokens: int,
218
+ temperature: float,
219
+ stop: Optional[List[str]] = None,
220
+ frequency_penalty: Optional[float] = None,
221
+ presence_penalty: Optional[float] = None,
222
+ ) -> Iterator[str]:
223
+ payload = {
224
+ "model": _model_with_provider(self.model, self.provider),
225
+ "messages": _mk_messages(system_prompt, user_text),
226
+ "temperature": float(max(0.0, temperature)),
227
+ "max_tokens": int(max_tokens),
228
+ "stream": True,
229
+ }
230
+ if stop:
231
+ payload["stop"] = stop
232
+ if frequency_penalty is not None:
233
+ payload["frequency_penalty"] = float(frequency_penalty)
234
+ if presence_penalty is not None:
235
+ payload["presence_penalty"] = float(presence_penalty)
236
+
237
+ # primary
238
+ ok = False
239
+ for token in self._stream_once(payload):
240
+ ok = True
241
+ yield token
242
+ if ok:
243
+ return
244
+ # fallback stream if primary produced nothing (or died immediately)
245
+ if self.fallback:
246
+ payload["model"] = _model_with_provider(self.fallback, self.provider)
247
+ for token in self._stream_once(payload):
248
+ yield token
249
+
250
+ def _stream_once(self, payload: dict) -> Iterator[str]:
251
+ try:
252
+ with requests.post(ROUTER_URL, headers=self.headers, json=payload, stream=True, timeout=self.timeout) as r:
253
+ if r.status_code >= 400:
254
+ logger.error("Router stream error %s: %s", r.status_code, r.text)
255
+ return
256
+ for line in r.iter_lines(decode_unicode=True):
257
+ if not line:
258
+ continue
259
+ if not line.startswith("data:"):
260
+ continue
261
+ data = line[len("data:"):].strip()
262
+ if data == "[DONE]":
263
+ return
264
+ try:
265
+ obj = json.loads(data)
266
+ delta = obj["choices"][0]["delta"].get("content", "")
267
+ if delta:
268
+ yield delta
269
+ except Exception as e:
270
+ logger.warning("Stream JSON parse issue: %s | line=%r", e, line)
271
+ continue
272
+ except Exception as e:
273
+ logger.error("Stream request failure: %s", e)
274
+ return
275
+
276
+ # -------- Planning (non-stream) --------
277
+ def plan_nonstream(self, system_prompt: str, user_text: str,
278
+ max_tokens: int, temperature: float) -> str:
279
+ return self.chat_nonstream(system_prompt, user_text, max_tokens, temperature)
280
+
281
+
282
+ __all__ = [
283
+ "ChatClient",
284
+ "chat",
285
+ "get_client",
286
+ "RouterRequestsClient",
287
+ ]
app/core/inference/providers.py ADDED
@@ -0,0 +1,402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/core/inference/providers.py
2
+ from __future__ import annotations
3
+
4
+ """
5
+ Provider layer for multi-backend LLM chat with a production-ready cascade:
6
+
7
+ GROQ → Gemini → Hugging Face Inference Router (Zephyr → Mistral)
8
+
9
+ - Each provider implements a common .chat(...) interface that returns either:
10
+ * str (non-stream), or
11
+ * Generator[str, None, None] (streaming text chunks)
12
+
13
+ - MultiProviderChat orchestrates providers in a user-configurable order (Settings.provider_order)
14
+ and returns the first successful response.
15
+
16
+ - Robustness:
17
+ * .env + logging are loaded via app.bootstrap import side-effect
18
+ * Requests session has retries and timeouts
19
+ * Provider initialization gracefully skips when keys/SDKs are missing
20
+ * Streaming uses SSE for HF Router; Groq uses SDK streaming; Gemini yields one chunk
21
+ """
22
+
23
+ from typing import Any, Dict, Generator, Iterable, List, Optional, Union
24
+ import json
25
+ import logging
26
+ import os
27
+ import time
28
+
29
+ # Ensure .env + logging configured even if imported directly
30
+ import app.bootstrap # noqa: F401
31
+
32
+ import requests
33
+ from requests.adapters import HTTPAdapter
34
+ from urllib3.util.retry import Retry
35
+
36
+ # Optional SDKs; handled gracefully if absent
37
+ try:
38
+ from groq import Groq
39
+ except Exception: # pragma: no cover
40
+ Groq = None # type: ignore
41
+
42
+ try:
43
+ from google import genai
44
+ except Exception: # pragma: no cover
45
+ genai = None # type: ignore
46
+
47
+ from app.core.config import Settings
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ Message = Dict[str, str] # {"role": "system|user|assistant", "content": "..."}
52
+
53
+
54
+ # ---------- Errors ----------
55
+ class ProviderError(RuntimeError):
56
+ """Raised for provider-specific configuration/runtime errors."""
57
+
58
+
59
+ # ---------- Helpers ----------
60
+ def _ensure_messages(msgs: Iterable[Message]) -> List[Message]:
61
+ """
62
+ Normalize incoming messages to a strict [{"role": str, "content": str}, ...] list.
63
+ """
64
+ out: List[Message] = []
65
+ for m in msgs:
66
+ role = m.get("role", "user")
67
+ content = m.get("content", "")
68
+ out.append({"role": role, "content": content})
69
+ return out
70
+
71
+
72
+ def _requests_session_with_retries(
73
+ total: int = 3,
74
+ backoff: float = 0.3,
75
+ status_forcelist: Optional[List[int]] = None,
76
+ timeout: float = 60.0,
77
+ ) -> requests.Session:
78
+ """
79
+ Return a requests.Session configured with retries, connection pooling, and default timeouts.
80
+ """
81
+ status_forcelist = status_forcelist or [408, 429, 500, 502, 503, 504]
82
+ retry = Retry(
83
+ total=total,
84
+ read=total,
85
+ connect=total,
86
+ backoff_factor=backoff,
87
+ status_forcelist=status_forcelist,
88
+ allowed_methods=frozenset(["GET", "POST"]),
89
+ raise_on_status=False,
90
+ )
91
+ adapter = HTTPAdapter(max_retries=retry, pool_connections=10, pool_maxsize=10)
92
+ session = requests.Session()
93
+ session.mount("http://", adapter)
94
+ session.mount("https://", adapter)
95
+ # Store default timeout on session via a patched request method
96
+ session.request = _patch_request_with_timeout(session.request, timeout) # type: ignore
97
+ return session
98
+
99
+
100
+ def _patch_request_with_timeout(fn, timeout: float):
101
+ def wrapper(method, url, **kwargs):
102
+ if "timeout" not in kwargs:
103
+ kwargs["timeout"] = timeout
104
+ return fn(method, url, **kwargs)
105
+
106
+ return wrapper
107
+
108
+
109
+ # ---------- GROQ ----------
110
+ class GroqProvider:
111
+ """
112
+ Groq Chat Completions (OpenAI-compatible).
113
+ Requires:
114
+ - env: GROQ_API_KEY
115
+ - package: groq
116
+ """
117
+ name = "groq"
118
+
119
+ def __init__(self, model: str):
120
+ self.model = model
121
+ self.api_key = os.getenv("GROQ_API_KEY")
122
+ if not self.api_key:
123
+ raise ProviderError("GROQ_API_KEY is not set")
124
+ if Groq is None:
125
+ raise ProviderError("groq SDK not installed; add 'groq' to requirements.txt and pip install.")
126
+ # SDK reads key from env
127
+ self.client = Groq()
128
+
129
+ def chat(
130
+ self,
131
+ messages: Iterable[Message],
132
+ temperature: float,
133
+ max_new_tokens: int,
134
+ stream: bool,
135
+ ) -> Union[str, Generator[str, None, None]]:
136
+ msgs = _ensure_messages(messages)
137
+ try:
138
+ completion = self.client.chat.completions.create(
139
+ model=self.model,
140
+ messages=msgs,
141
+ temperature=float(temperature),
142
+ max_tokens=int(max_new_tokens),
143
+ top_p=1,
144
+ stream=bool(stream),
145
+ )
146
+ if stream:
147
+ def gen():
148
+ for chunk in completion:
149
+ try:
150
+ delta = chunk.choices[0].delta
151
+ part = getattr(delta, "content", None)
152
+ if part:
153
+ yield part
154
+ except Exception:
155
+ continue
156
+ return gen()
157
+ else:
158
+ # Non-streaming: return final message content
159
+ return completion.choices[0].message.content or ""
160
+ except Exception as e:
161
+ raise ProviderError(f"GROQ error: {e}") from e
162
+
163
+
164
+ # ---------- GEMINI ----------
165
+ class GeminiProvider:
166
+ """
167
+ Google Gemini via google-genai.
168
+ Requires:
169
+ - env: GOOGLE_API_KEY
170
+ - package: google-genai
171
+
172
+ Role mapping:
173
+ - system → system_instruction (joined)
174
+ - user → role 'user'
175
+ - assistant → role 'model'
176
+ """
177
+ name = "gemini"
178
+
179
+ def __init__(self, model: str):
180
+ self.model = model
181
+ self.api_key = os.getenv("GOOGLE_API_KEY")
182
+ if not self.api_key:
183
+ raise ProviderError("GOOGLE_API_KEY is not set")
184
+ if genai is None:
185
+ raise ProviderError("google-genai SDK not installed; add 'google-genai' to requirements.txt and pip install.")
186
+ self.client = genai.Client(api_key=self.api_key)
187
+
188
+ @staticmethod
189
+ def _split_system_and_messages(msgs: List[Message]) -> tuple[str, List[dict]]:
190
+ system_parts: List[str] = []
191
+ contents: List[dict] = []
192
+ for m in msgs:
193
+ role = m.get("role", "user")
194
+ text = m.get("content", "")
195
+ if role == "system":
196
+ system_parts.append(text)
197
+ else:
198
+ mapped = "user" if role == "user" else "model"
199
+ contents.append({"role": mapped, "parts": [{"text": text}]})
200
+ return ("\n".join(system_parts).strip(), contents)
201
+
202
+ def chat(
203
+ self,
204
+ messages: Iterable[Message],
205
+ temperature: float,
206
+ max_new_tokens: int,
207
+ stream: bool,
208
+ ) -> Union[str, Generator[str, None, None]]:
209
+ msgs = _ensure_messages(messages)
210
+ system_instruction, contents = self._split_system_and_messages(msgs)
211
+ try:
212
+ # Some versions of google-genai expose system_instruction; if not, we prepend.
213
+ kwargs: Dict[str, Any] = {
214
+ "model": self.model,
215
+ "contents": contents,
216
+ "generation_config": {
217
+ "temperature": float(temperature),
218
+ "max_output_tokens": int(max_new_tokens),
219
+ },
220
+ }
221
+ try:
222
+ resp = self.client.models.generate_content(system_instruction=system_instruction or None, **kwargs)
223
+ except TypeError:
224
+ # Fallback for older SDKs: inject system as first user turn
225
+ if system_instruction:
226
+ contents = [{"role": "user", "parts": [{"text": f"System: {system_instruction}"}]}] + contents
227
+ kwargs["contents"] = contents
228
+ resp = self.client.models.generate_content(**kwargs)
229
+
230
+ text = getattr(resp, "text", "") or ""
231
+
232
+ if stream:
233
+ # Fake streaming for API parity: one chunk
234
+ def gen():
235
+ yield text
236
+ return gen()
237
+ return text
238
+ except Exception as e:
239
+ raise ProviderError(f"Gemini error: {e}") from e
240
+
241
+
242
+ # ---------- HF Inference Router ----------
243
+ class HfRouterProvider:
244
+ """
245
+ Hugging Face Inference Router (OpenAI-like /v1/chat/completions).
246
+ Tries primary -> fallback model (both can include optional provider tag, e.g., "model:featherless-ai").
247
+
248
+ Requires:
249
+ - env: HF_TOKEN
250
+ - package: requests
251
+ """
252
+ name = "router"
253
+ BASE_URL = "https://router.huggingface.co/v1/chat/completions"
254
+
255
+ def __init__(self, primary_model: str, fallback_model: Optional[str], provider_tag: Optional[str]):
256
+ self.primary = primary_model
257
+ self.fallback = fallback_model
258
+ self.provider_tag = provider_tag
259
+ self.token = os.getenv("HF_TOKEN")
260
+ if not self.token:
261
+ raise ProviderError("HF_TOKEN is not set")
262
+ self.session = _requests_session_with_retries(total=3, backoff=0.5, timeout=60.0)
263
+
264
+ def _fmt_model(self, model: str) -> str:
265
+ return model if not self.provider_tag else f"{model}:{self.provider_tag}"
266
+
267
+ def _sse_stream(self, resp: requests.Response) -> Generator[str, None, None]:
268
+ for raw in resp.iter_lines(decode_unicode=True):
269
+ if not raw:
270
+ continue
271
+ if not raw.startswith("data:"):
272
+ continue
273
+ data = raw[5:].strip()
274
+ if data == "[DONE]":
275
+ break
276
+ try:
277
+ obj = json.loads(data)
278
+ except Exception:
279
+ continue
280
+ try:
281
+ delta = obj["choices"][0].get("delta", {})
282
+ content = delta.get("content")
283
+ if content:
284
+ yield content
285
+ except Exception:
286
+ continue
287
+
288
+ def _call_router(
289
+ self,
290
+ model: str,
291
+ messages: List[Message],
292
+ temperature: float,
293
+ max_new_tokens: int,
294
+ stream: bool,
295
+ ) -> Union[str, Generator[str, None, None]]:
296
+ headers = {
297
+ "Authorization": f"Bearer {self.token}",
298
+ "Content-Type": "application/json",
299
+ }
300
+ payload: Dict[str, Any] = {
301
+ "model": self._fmt_model(model),
302
+ "messages": messages,
303
+ "temperature": float(temperature),
304
+ "max_tokens": int(max_new_tokens),
305
+ "stream": bool(stream),
306
+ }
307
+ if stream:
308
+ with self.session.post(self.BASE_URL, headers=headers, json=payload, stream=True) as r:
309
+ if r.status_code >= 400:
310
+ raise ProviderError(f"HF Router HTTP {r.status_code}: {r.text[:300]}")
311
+ return self._sse_stream(r)
312
+ else:
313
+ r = self.session.post(self.BASE_URL, headers=headers, json=payload)
314
+ if r.status_code >= 400:
315
+ raise ProviderError(f"HF Router HTTP {r.status_code}: {r.text[:300]}")
316
+ obj = r.json()
317
+ try:
318
+ return obj["choices"][0]["message"]["content"]
319
+ except Exception as e:
320
+ raise ProviderError(f"HF Router response parsing error: {e}") from e
321
+
322
+ def chat(
323
+ self,
324
+ messages: Iterable[Message],
325
+ temperature: float,
326
+ max_new_tokens: int,
327
+ stream: bool,
328
+ ) -> Union[str, Generator[str, None, None]]:
329
+ msgs = _ensure_messages(messages)
330
+ try:
331
+ return self._call_router(self.primary, msgs, temperature, max_new_tokens, stream)
332
+ except Exception as e1:
333
+ logger.warning("HF primary model failed (%s): %s", self.primary, e1)
334
+ if self.fallback:
335
+ return self._call_router(self.fallback, msgs, temperature, max_new_tokens, stream)
336
+ raise
337
+
338
+
339
+ # ---------- Orchestrator ----------
340
+ class MultiProviderChat:
341
+ """
342
+ Tries providers in configured order. First success wins.
343
+ Skips misconfigured providers (missing key or SDK).
344
+ """
345
+ def __init__(self, settings: Settings):
346
+ m = settings.model
347
+ order = [p.strip().lower() for p in settings.provider_order]
348
+ self.providers: List[Any] = []
349
+
350
+ for p in order:
351
+ try:
352
+ if p == "groq":
353
+ self.providers.append(GroqProvider(m.groq_model))
354
+ elif p == "gemini":
355
+ self.providers.append(GeminiProvider(m.gemini_model))
356
+ elif p == "router":
357
+ self.providers.append(HfRouterProvider(m.name, m.fallback, m.provider))
358
+ else:
359
+ logger.warning("Unknown provider '%s' in provider_order; skipping.", p)
360
+ except ProviderError as e:
361
+ logger.warning("Provider '%s' not available: %s (will skip)", p, e)
362
+ continue
363
+
364
+ if not self.providers:
365
+ raise ProviderError("No providers are configured/available")
366
+
367
+ self.temperature = m.temperature
368
+ self.max_new_tokens = m.max_new_tokens
369
+
370
+ def chat(
371
+ self,
372
+ messages: Iterable[Message],
373
+ temperature: Optional[float] = None,
374
+ max_new_tokens: Optional[int] = None,
375
+ stream: bool = True,
376
+ ) -> Union[str, Generator[str, None, None]]:
377
+ temp = float(self.temperature if temperature is None else temperature)
378
+ mx = int(self.max_new_tokens if max_new_tokens is None else max_new_tokens)
379
+ last_err: Optional[Exception] = None
380
+
381
+ for provider in self.providers:
382
+ pname = getattr(provider, "name", provider.__class__.__name__)
383
+ t0 = time.time()
384
+ try:
385
+ result = provider.chat(messages, temp, mx, stream)
386
+ logger.info("Provider '%s' succeeded in %.2fs", pname, time.time() - t0)
387
+ return result
388
+ except Exception as e:
389
+ logger.warning("Provider '%s' failed: %s", pname, e)
390
+ last_err = e
391
+ continue
392
+
393
+ raise ProviderError(f"All providers failed. Last error: {last_err}")
394
+
395
+
396
+ __all__ = [
397
+ "ProviderError",
398
+ "GroqProvider",
399
+ "GeminiProvider",
400
+ "HfRouterProvider",
401
+ "MultiProviderChat",
402
+ ]
app/core/logging.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/core/logging.py
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ import uuid
7
+ from typing import Optional
8
+
9
+ _DEF_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
10
+ _DEF_DATEFMT = "%Y-%m-%dT%H:%M:%S%z"
11
+
12
+
13
+ def setup_logging(level: Optional[str] = None) -> None:
14
+ """
15
+ Idempotent logging setup.
16
+ - Honors LOG_LEVEL env (default INFO) unless an explicit level is passed.
17
+ - Avoids duplicate handlers if called multiple times.
18
+ - Tames noisy third-party loggers by default.
19
+ """
20
+ root = logging.getLogger()
21
+ if root.handlers:
22
+ return # already configured
23
+
24
+ log_level = (level or os.getenv("LOG_LEVEL", "INFO")).upper()
25
+ try:
26
+ parsed_level = getattr(logging, log_level)
27
+ except AttributeError:
28
+ parsed_level = logging.INFO
29
+
30
+ handler = logging.StreamHandler()
31
+ formatter = logging.Formatter(_DEF_FORMAT, datefmt=_DEF_DATEFMT)
32
+ handler.setFormatter(formatter)
33
+
34
+ root.setLevel(parsed_level)
35
+ root.addHandler(handler)
36
+
37
+ # Quiet noisy libs by default; adjust if you need more/less detail.
38
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
39
+ logging.getLogger("httpx").setLevel(logging.WARNING)
40
+ logging.getLogger("requests").setLevel(logging.WARNING)
41
+
42
+
43
+ def add_trace_id(request) -> None:
44
+ """
45
+ Injects a unique `trace_id` into request.state (works with FastAPI-style objects).
46
+ Duck-typed to avoid importing FastAPI here.
47
+ """
48
+ try:
49
+ state = getattr(request, "state", None)
50
+ if state is None:
51
+ # Some frameworks may not have .state; just skip silently.
52
+ return
53
+ if not hasattr(state, "trace_id"):
54
+ state.trace_id = str(uuid.uuid4())
55
+ except Exception:
56
+ # Never let logging helpers break the app.
57
+ return
app/core/prompts/__init__.py ADDED
File without changes
app/core/prompts/plan.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are MATRIX-AI Planner.
2
+
3
+ Return ONLY a single JSON object. Do not include backticks, code fences, Markdown, or any prose.
4
+ The JSON MUST match this schema exactly:
5
+
6
+ {
7
+ "plan_id": "<string>",
8
+ "steps": ["<string>", "..."],
9
+ "risk": "low" | "medium" | "high",
10
+ "explanation": "<string>"
11
+ }
12
+
13
+ Rules:
14
+ - Keep steps short, safe, and auditable (1–3 steps).
15
+ - Prefer low risk actions.
16
+ - Do not add any extra keys.
17
+ - Start your reply with '{' and end with '}'.
app/core/rag/__init__.py ADDED
File without changes
app/core/rag/build.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import json, os, re, time, math, logging
3
+ from pathlib import Path
4
+ from typing import Dict, List, Iterable, Tuple, Optional
5
+
6
+ import yaml
7
+ import requests
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+ # -------------------------
12
+ # Text cleaning & chunking
13
+ # -------------------------
14
+
15
+ _MD_FRONTMATTER = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL)
16
+
17
+ def normalize_text(text: str) -> str:
18
+ lines = [ln.strip() for ln in text.splitlines()]
19
+ cleaned = []
20
+ for ln in lines:
21
+ if not ln:
22
+ continue
23
+ if sum(ch.isalnum() for ch in ln) < 3:
24
+ continue
25
+ cleaned.append(ln)
26
+ s = "\n".join(cleaned)
27
+ s = re.sub(r"\n{3,}", "\n\n", s)
28
+ return s.strip()
29
+
30
+ def md_to_text(md: str) -> str:
31
+ md = re.sub(_MD_FRONTMATTER, "", md)
32
+ md = re.sub(r"```.*?```", "", md, flags=re.DOTALL) # drop fenced code
33
+ md = re.sub(r"!\[[^\]]*\]\([^)]+\)", "", md) # drop images
34
+ md = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", md) # links -> label
35
+ md = re.sub(r"^\s{0,3}#{1,6}\s*", "", md, flags=re.MULTILINE)
36
+ md = md.replace("`", "")
37
+ md = re.sub(r"^\s*[-*+]\s+", "• ", md, flags=re.MULTILINE)
38
+ md = re.sub(r"^\s*>\s?", "", md, flags=re.MULTILINE)
39
+ return normalize_text(md)
40
+
41
+ def chunk_text(text: str, max_chars: int = 800, overlap: int = 120) -> List[str]:
42
+ paras = [p.strip() for p in text.split("\n\n") if p.strip()]
43
+ out: List[str] = []
44
+ buf = ""
45
+ for p in paras:
46
+ if len(p) > max_chars:
47
+ i = 0
48
+ while i < len(p):
49
+ j = min(i + max_chars, len(p))
50
+ out.append(p[i:j])
51
+ i = j - overlap if j - overlap > i else j
52
+ continue
53
+ if len(buf) + 2 + len(p) <= max_chars:
54
+ buf = (buf + "\n\n" + p) if buf else p
55
+ else:
56
+ if buf:
57
+ out.append(buf)
58
+ buf = p
59
+ if buf:
60
+ out.append(buf)
61
+ return out
62
+
63
+ def write_jsonl(records: Iterable[Dict], out_path: Path) -> None:
64
+ out_path.parent.mkdir(parents=True, exist_ok=True)
65
+ with out_path.open("w", encoding="utf-8") as f:
66
+ for rec in records:
67
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
68
+
69
+ # -------------------------
70
+ # GitHub API helpers
71
+ # -------------------------
72
+
73
+ def gh_session() -> requests.Session:
74
+ s = requests.Session()
75
+ s.headers.update({
76
+ "Accept": "application/vnd.github+json",
77
+ "User-Agent": "matrix-ai-rag-builder/1.0",
78
+ })
79
+ tok = os.getenv("GITHUB_TOKEN")
80
+ if tok:
81
+ s.headers["Authorization"] = f"Bearer {tok}"
82
+ return s
83
+
84
+ def gh_get_json(url: str, sess: requests.Session, max_retries: int = 3) -> Dict | List:
85
+ backoff = 1.0
86
+ for attempt in range(max_retries):
87
+ r = sess.get(url, timeout=25)
88
+ if r.status_code == 403 and "rate limit" in r.text.lower():
89
+ log.warning("GitHub rate-limited; sleeping %.1fs", backoff)
90
+ time.sleep(backoff)
91
+ backoff = min(backoff * 2, 30)
92
+ continue
93
+ r.raise_for_status()
94
+ return r.json()
95
+ r.raise_for_status()
96
+ return {}
97
+
98
+ def gh_list_org_repos(org: str, sess: requests.Session) -> List[Dict]:
99
+ repos: List[Dict] = []
100
+ page = 1
101
+ while True:
102
+ url = f"https://api.github.com/orgs/{org}/repos?per_page=100&page={page}"
103
+ js = gh_get_json(url, sess)
104
+ if not js:
105
+ break
106
+ repos.extend(js)
107
+ if len(js) < 100:
108
+ break
109
+ page += 1
110
+ return repos
111
+
112
+ def gh_list_tree(owner: str, repo: str, branch: str, sess: requests.Session) -> List[Dict]:
113
+ url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=1"
114
+ js = gh_get_json(url, sess)
115
+ return js.get("tree", []) if isinstance(js, dict) else []
116
+
117
+ def gh_fetch_raw(owner: str, repo: str, branch: str, path: str, sess: requests.Session) -> Optional[str]:
118
+ raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}"
119
+ r = sess.get(raw_url, timeout=25)
120
+ if r.status_code == 404 and branch == "main": # try master fallback
121
+ raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/master/{path}"
122
+ r = sess.get(raw_url, timeout=25)
123
+ if r.status_code == 200:
124
+ return r.text
125
+ return None
126
+
127
+ # -------------------------
128
+ # Builders
129
+ # -------------------------
130
+
131
+ def ingest_github_repo(owner: str, name: str, branch: str, docs_paths: List[str],
132
+ include_readme: bool, exts: Tuple[str,...] = (".md",".mdx",".txt")) -> List[Tuple[str,str]]:
133
+ sess = gh_session()
134
+ out: List[Tuple[str,str]] = []
135
+
136
+ # README
137
+ if include_readme:
138
+ for candidate in ("README.md", "readme.md", "README.MD"):
139
+ t = gh_fetch_raw(owner, name, branch, candidate, sess)
140
+ if t:
141
+ out.append((f"github:{owner}/{name}/{candidate}", md_to_text(t)))
142
+ break
143
+
144
+ # Tree -> docs paths
145
+ tree = gh_list_tree(owner, name, branch, sess)
146
+ if not tree:
147
+ return out
148
+
149
+ wanted_dirs = [p.strip("/").lower() for p in docs_paths]
150
+ for entry in tree:
151
+ if entry.get("type") != "blob":
152
+ continue
153
+ path = entry.get("path", "")
154
+ lower = path.lower()
155
+ if not lower.endswith(exts):
156
+ continue
157
+ if any(lower.startswith(d + "/") for d in wanted_dirs):
158
+ t = gh_fetch_raw(owner, name, branch, path, sess)
159
+ if not t:
160
+ continue
161
+ txt = md_to_text(t) if lower.endswith((".md",".mdx")) else normalize_text(t)
162
+ if txt:
163
+ out.append((f"github:{owner}/{name}/{path}", txt))
164
+ return out
165
+
166
+ def ingest_github_sources(cfg: Dict) -> List[Tuple[str,str]]:
167
+ out: List[Tuple[str,str]] = []
168
+ gh = cfg.get("github") or {}
169
+ sess = gh_session()
170
+
171
+ # explicit repos
172
+ for repo in (gh.get("repos") or []):
173
+ owner = repo["owner"]
174
+ name = repo["name"]
175
+ branch = repo.get("branch", "main")
176
+ docs_paths = repo.get("docs_paths", ["docs"])
177
+ include_readme = bool(repo.get("include_readme", True))
178
+ out.extend(ingest_github_repo(owner, name, branch, docs_paths, include_readme))
179
+
180
+ # whole org scan (README + docs/)
181
+ for org in (gh.get("orgs") or []):
182
+ try:
183
+ repos = gh_list_org_repos(org, sess)
184
+ except Exception as e:
185
+ log.warning("Failed to list org %s: %s", org, e)
186
+ continue
187
+ for r in repos:
188
+ owner = r["owner"]["login"]
189
+ name = r["name"]
190
+ default_branch = r.get("default_branch", "main")
191
+ # README + docs/
192
+ out.extend(ingest_github_repo(owner, name, default_branch, ["docs"], include_readme=True))
193
+ return out
194
+
195
+ def ingest_local_sources(cfg: Dict) -> List[Tuple[str,str]]:
196
+ out: List[Tuple[str,str]] = []
197
+ local = cfg.get("local") or {}
198
+ paths = local.get("paths") or []
199
+ glob_pat = local.get("glob", "**/*.md")
200
+ for p in paths:
201
+ fp = Path(p)
202
+ if fp.is_file():
203
+ try:
204
+ raw = fp.read_text(encoding="utf-8", errors="ignore")
205
+ txt = md_to_text(raw) if fp.suffix.lower() in {".md",".mdx"} else normalize_text(raw)
206
+ if txt:
207
+ out.append((str(fp), txt))
208
+ except Exception as e:
209
+ log.warning("Failed reading %s: %s", fp, e)
210
+ elif fp.is_dir():
211
+ for f in fp.rglob(glob_pat):
212
+ try:
213
+ raw = f.read_text(encoding="utf-8", errors="ignore")
214
+ txt = md_to_text(raw) if f.suffix.lower() in {".md",".mdx"} else normalize_text(raw)
215
+ if txt:
216
+ out.append((str(f), txt))
217
+ except Exception as e:
218
+ log.warning("Failed reading %s: %s", f, e)
219
+ return out
220
+
221
+ def build_kb_from_config(config_path: str = "configs/rag_sources.yaml",
222
+ out_jsonl: str = "data/kb.jsonl",
223
+ max_chars: int = 800,
224
+ overlap: int = 120,
225
+ minlen: int = 200,
226
+ dedupe: bool = True) -> int:
227
+ cfg: Dict = {}
228
+ p = Path(config_path)
229
+ if p.exists():
230
+ cfg = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
231
+ else:
232
+ log.warning("rag_sources.yaml not found at %s (using defaults)", p)
233
+
234
+ records: List[Dict] = []
235
+
236
+ # GitHub
237
+ try:
238
+ gh_docs = ingest_github_sources(cfg)
239
+ for src, text in gh_docs:
240
+ for chunk in chunk_text(text, max_chars, overlap):
241
+ if len(chunk) >= minlen:
242
+ records.append({"text": chunk, "source": src})
243
+ except Exception as e:
244
+ log.warning("GitHub ingest failed: %s", e)
245
+
246
+ # Local
247
+ try:
248
+ loc_docs = ingest_local_sources(cfg)
249
+ for src, text in loc_docs:
250
+ for chunk in chunk_text(text, max_chars, overlap):
251
+ if len(chunk) >= minlen:
252
+ records.append({"text": chunk, "source": src})
253
+ except Exception as e:
254
+ log.warning("Local ingest failed: %s", e)
255
+
256
+ # URLs (optional)
257
+ for url in (cfg.get("urls") or []):
258
+ try:
259
+ r = requests.get(url, timeout=25)
260
+ r.raise_for_status()
261
+ txt = normalize_text(r.text)
262
+ for chunk in chunk_text(txt, max_chars, overlap):
263
+ if len(chunk) >= minlen:
264
+ records.append({"text": chunk, "source": url})
265
+ except Exception as e:
266
+ log.warning("URL ingest failed for %s: %s", url, e)
267
+
268
+ if dedupe:
269
+ seen = set()
270
+ deduped: List[Dict] = []
271
+ for rec in records:
272
+ h = hash(rec["text"])
273
+ if h in seen:
274
+ continue
275
+ seen.add(h)
276
+ deduped.append(rec)
277
+ records = deduped
278
+
279
+ if not records:
280
+ log.warning("No KB records produced.")
281
+ return 0
282
+
283
+ out_path = Path(out_jsonl)
284
+ write_jsonl(records, out_path)
285
+ log.info("Wrote %d chunks to %s", len(records), out_path)
286
+ return len(records)
287
+
288
+ def ensure_kb(out_jsonl: str = "data/kb.jsonl",
289
+ config_path: str = "configs/rag_sources.yaml",
290
+ skip_if_exists: bool = True) -> bool:
291
+ """
292
+ If kb.jsonl exists -> return True.
293
+ Else -> build from config and return True on success.
294
+ """
295
+ out = Path(out_jsonl)
296
+ if skip_if_exists and out.exists() and out.stat().st_size > 0:
297
+ log.info("KB already present at %s (skipping build)", out)
298
+ return True
299
+ n = build_kb_from_config(config_path=config_path, out_jsonl=out_jsonl)
300
+ return n > 0
app/core/rag/retriever.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/core/rag/retriever.py
2
+ from __future__ import annotations
3
+ import json, logging, os
4
+ from pathlib import Path
5
+ from typing import List, Dict, Optional
6
+ import numpy as np
7
+ import faiss
8
+ from sentence_transformers import SentenceTransformer
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+ class Retriever:
13
+ def __init__(self, kb_path: str = "data/kb.jsonl",
14
+ model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
15
+ top_k: int = 4):
16
+ self.kb_path = Path(kb_path)
17
+ self.top_k = top_k
18
+ if not self.kb_path.exists():
19
+ raise FileNotFoundError(f"KB file not found: {self.kb_path} (jsonl with {{text,source}})")
20
+
21
+ # Use a project-local cache to avoid '/.cache' permission issues
22
+ cache_dir = Path(os.getenv("HF_HOME", "./.cache"))
23
+ cache_dir.mkdir(parents=True, exist_ok=True)
24
+
25
+ self.model = SentenceTransformer(model_name, cache_folder=str(cache_dir))
26
+
27
+ self.docs: List[Dict[str, str]] = []
28
+ with self.kb_path.open("r", encoding="utf-8") as f:
29
+ for line in f:
30
+ line = line.strip()
31
+ if not line:
32
+ continue
33
+ self.docs.append(json.loads(line))
34
+ texts = [d["text"] for d in self.docs]
35
+ emb = self.model.encode(texts, convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False)
36
+ self.dim = int(emb.shape[1])
37
+ self.index = faiss.IndexFlatIP(self.dim)
38
+ self.index.add(emb.astype("float32"))
39
+
40
+ def retrieve(self, query: str, k: Optional[int] = None) -> List[Dict]:
41
+ k = k or self.top_k
42
+ vec = self.model.encode([query], convert_to_numpy=True, normalize_embeddings=True)
43
+ D, I = self.index.search(vec.astype("float32"), k)
44
+ out: List[Dict] = []
45
+ for idx, score in zip(I[0], D[0]):
46
+ if int(idx) < 0:
47
+ continue
48
+ d = self.docs[int(idx)]
49
+ out.append({"text": d["text"], "source": d.get("source", f"kb:{idx}"), "score": float(score)})
50
+ return out
app/core/rate_limit.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from collections import defaultdict
3
+
4
+ class RateLimiter:
5
+ def __init__(self):
6
+ self.windows: dict[str, tuple[int, int]] = defaultdict(lambda: (0, 0))
7
+
8
+ def allow(self, ip: str, route: str, per_minute: int) -> bool:
9
+ """Checks if a request is allowed under a fixed-window rate limit."""
10
+ now = int(time.time())
11
+ current_window = now // 60
12
+ key = f"{ip}:{route}" # Simplified key for per-route limit
13
+
14
+ window_start, count = self.windows.get(key, (0, 0))
15
+
16
+ if window_start != current_window:
17
+ # New window, reset count
18
+ self.windows[key] = (current_window, 1)
19
+ return True
20
+
21
+ if count >= per_minute:
22
+ # Exceeded limit
23
+ return False
24
+
25
+ # Increment count in current window
26
+ self.windows[key] = (current_window, count + 1)
27
+ return True
app/core/redact.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ SECRET_PATTERN = re.compile(r"(bearer\s+[a-z0-9\-.~+/]+=*|sk-[a-z0-9]{20,})", re.IGNORECASE)
4
+ EMAIL_PATTERN = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", re.IGNORECASE)
5
+
6
+ def redact(text: str) -> str:
7
+ """Redacts sensitive information like API keys and emails from a string."""
8
+ text = SECRET_PATTERN.sub("[REDACTED_TOKEN]", text)
9
+ text = EMAIL_PATTERN.sub("[REDACTED_EMAIL]", text)
10
+ return text
app/core/schema.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, List, Literal
4
+ from pydantic import BaseModel, Field, ConfigDict
5
+
6
+ # ---------------------------
7
+ # Planning schema
8
+ # ---------------------------
9
+
10
+ class Health(BaseModel):
11
+ score: Optional[float] = None
12
+ status: Optional[str] = None
13
+ last_checked: Optional[str] = None # or use datetime if preferred
14
+
15
+
16
+ class RecentCheck(BaseModel):
17
+ check: str
18
+ result: str
19
+ latency_ms: Optional[float] = None
20
+ ts: Optional[str] = None # or use datetime if preferred
21
+
22
+
23
+ class PlanContext(BaseModel):
24
+ """
25
+ Context is permissive: accept any extra keys from Guardian (or future sources).
26
+ Known fields are typed below; unknown fields pass through.
27
+ """
28
+ model_config = ConfigDict(extra="allow")
29
+
30
+ # Common identifiers
31
+ app_id: Optional[str] = None
32
+ entity_uid: Optional[str] = None
33
+
34
+ # Known structured bits
35
+ symptoms: Optional[List[str]] = None
36
+ lkg: Optional[str] = None
37
+ lkg_version: Optional[str] = None
38
+ health: Optional[Health] = None
39
+ recent_checks: Optional[List[RecentCheck]] = None
40
+
41
+
42
+ class PlanConstraints(BaseModel):
43
+ max_steps: int = Field(default=3, ge=1, le=10)
44
+ risk: Literal["low", "medium", "high"] = "low"
45
+
46
+
47
+ class PlanRequest(BaseModel):
48
+ # default to "plan" and only allow that value for now
49
+ mode: Literal["plan"] = "plan"
50
+ context: PlanContext
51
+ constraints: PlanConstraints = Field(default_factory=PlanConstraints)
52
+
53
+
54
+ class PlanResponse(BaseModel):
55
+ plan_id: str
56
+ steps: List[str]
57
+ risk: str
58
+ explanation: str
59
+
60
+
61
+ # ---------------------------
62
+ # Chat (kept for compatibility; router uses its own flexible model)
63
+ # ---------------------------
64
+
65
+ class ChatRequest(BaseModel):
66
+ question: str = Field(..., min_length=3, max_length=512)
67
+
68
+
69
+ class ChatResponse(BaseModel):
70
+ answer: str
71
+ sources: List[str] = Field(default_factory=list)
app/deps.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from functools import lru_cache
2
+ from .core.config import Settings
3
+
4
+ @lru_cache(maxsize=1)
5
+ def get_settings() -> Settings:
6
+ """FastAPI dependency to get application settings."""
7
+ return Settings.load()
app/main.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/main.py
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ import time
7
+ from contextlib import asynccontextmanager
8
+ from typing import Any
9
+
10
+ from fastapi import FastAPI, APIRouter, Request
11
+ from fastapi.responses import RedirectResponse, HTMLResponse
12
+ from fastapi.staticfiles import StaticFiles
13
+ from fastapi.templating import Jinja2Templates
14
+
15
+ # ---- Early env load (HF_TOKEN, ADMIN_TOKEN, GITHUB_TOKEN, etc.) ----
16
+ def _load_env_file(paths: list[str]) -> None:
17
+ logger = logging.getLogger("uvicorn.error")
18
+ try:
19
+ from dotenv import load_dotenv # type: ignore
20
+ for p in paths:
21
+ if os.path.exists(p):
22
+ load_dotenv(dotenv_path=p, override=False)
23
+ logger.info("Loaded environment from %s", p)
24
+ return
25
+ logger.info("No .env file found in %s (skipping)", paths)
26
+ except Exception:
27
+ # Fallback, very small .env parser
28
+ for p in paths:
29
+ if not os.path.exists(p):
30
+ continue
31
+ try:
32
+ with open(p, "r", encoding="utf-8") as f:
33
+ for raw in f:
34
+ line = raw.strip()
35
+ if not line or line.startswith("#"):
36
+ continue
37
+ if line.startswith("export "):
38
+ line = line[len("export "):].strip()
39
+ if "=" not in line:
40
+ continue
41
+ key, val = line.split("=", 1)
42
+ key, val = key.strip(), val.strip()
43
+ if (val.startswith('"') and val.endswith('"')) or (
44
+ val.startswith("'") and val.endswith("'")
45
+ ):
46
+ val = val[1:-1]
47
+ os.environ.setdefault(key, val)
48
+ logger.info("Loaded environment from %s (fallback parser)", p)
49
+ return
50
+ except Exception as e:
51
+ logger.warning("Failed loading env from %s: %s", p, e)
52
+ logger.info("No .env loaded (none found / parsers failed)")
53
+
54
+ _load_env_file([".env", "configs/.env", ".env.local", "configs/.env.local"])
55
+
56
+ # ---- RAG DISABLED (commented out while debugging) ----
57
+ # from .deps import get_settings
58
+ # from .services.chat_service import get_retriever
59
+ # from .core.rag.build import ensure_kb
60
+
61
+ # ---- Middlewares ----
62
+ try:
63
+ from .middleware import attach_middlewares # type: ignore
64
+ except Exception:
65
+ try:
66
+ from .middlewares import attach_middlewares # type: ignore
67
+ except Exception:
68
+ def attach_middlewares(app: FastAPI) -> None:
69
+ logging.getLogger("uvicorn.error").warning(
70
+ "attach_middlewares not found; continuing without custom middlewares."
71
+ )
72
+
73
+ # ---- Routers enabled ----
74
+ from .routers import health
75
+ from .ui import router as ui_router # <-- mount UI so /home works
76
+
77
+ # ---- Validator service integration ----
78
+ VALIDATOR_TAG = {"name": "Validator", "description": "A2A Validator UI and endpoints (/validator)."}
79
+
80
+ HAS_VALIDATOR = False
81
+ HAS_SOCKETIO = False
82
+ socketio_app = None # type: ignore[assignment]
83
+
84
+ try:
85
+ # Primary validator router + optional Socket.IO app
86
+ from .services.validator_service import router as validator_router # type: ignore
87
+ HAS_VALIDATOR = True
88
+ try:
89
+ from .services.validator_service import socketio_app as _socketio_app # type: ignore
90
+ socketio_app = _socketio_app
91
+ HAS_SOCKETIO = socketio_app is not None
92
+ except Exception:
93
+ socketio_app = None
94
+ HAS_SOCKETIO = False
95
+ except Exception as e:
96
+ logging.getLogger("uvicorn.error").warning("validator_service import failed: %s", e)
97
+ # Fallback validator router if import fails
98
+ _templates = Jinja2Templates(directory="app/templates")
99
+ validator_router = APIRouter(prefix="/validator", tags=["Validator"])
100
+
101
+ @validator_router.get("", response_class=HTMLResponse)
102
+ @validator_router.get("/", response_class=HTMLResponse)
103
+ async def _validator_fallback_ui(request: Request) -> HTMLResponse:
104
+ # Try validator.hml first (project used this name), then validator.html
105
+ try:
106
+ return _templates.TemplateResponse("validator.hml", {"request": request})
107
+ except Exception:
108
+ return _templates.TemplateResponse(
109
+ "validator.html",
110
+ {"request": request, "warning": "validator service running in fallback mode"},
111
+ )
112
+
113
+ TAGS_METADATA = [
114
+ {"name": "Health", "description": "Liveness / readiness probes and basic service metadata."},
115
+ VALIDATOR_TAG,
116
+ # UI tag is implicit; only /home (Info) and /validator are exposed
117
+ ]
118
+
119
+ @asynccontextmanager
120
+ async def lifespan(app: FastAPI):
121
+ app.state.started_at = time.time()
122
+ app.state.version = os.getenv("APP_VERSION", "1.0.0")
123
+ logger = logging.getLogger("uvicorn.error")
124
+
125
+ # ---- RAG INIT DISABLED ----
126
+ # try:
127
+ # if ensure_kb(out_jsonl="data/kb.jsonl", config_path="configs/rag_sources.yaml", skip_if_exists=True):
128
+ # logger.info("KB ready at data/kb.jsonl")
129
+ # else:
130
+ # logger.warning("KB build produced no records; running LLM-only.")
131
+ # except Exception as e:
132
+ # logger.warning("KB build failed (%s); running LLM-only.", e)
133
+ # logger.info("Warming up RAG retriever...")
134
+ # get_retriever(get_settings())
135
+ # logger.info("RAG retriever is ready.")
136
+
137
+ hf_token_present = bool(os.getenv("HF_TOKEN"))
138
+ logger.info(
139
+ "matrix-ai starting (version=%s, port=%s, hf_token_present=%s)",
140
+ app.state.version,
141
+ os.getenv("PORT", "7860"),
142
+ "yes" if hf_token_present else "no",
143
+ )
144
+ try:
145
+ yield
146
+ finally:
147
+ uptime = time.time() - getattr(app.state, "started_at", time.time())
148
+ logger.info("matrix-ai shutting down (uptime=%.2fs)", uptime)
149
+
150
+ def create_app() -> FastAPI:
151
+ app = FastAPI(
152
+ title="matrix-ai",
153
+ version=os.getenv("APP_VERSION", "1.0.0"),
154
+ description="Minimal service with A2A Validator and health endpoints",
155
+ openapi_tags=TAGS_METADATA,
156
+ docs_url="/docs",
157
+ redoc_url=None,
158
+ lifespan=lifespan,
159
+ )
160
+
161
+ # Static files (for validator UI assets, etc.)
162
+ try:
163
+ app.mount("/static", StaticFiles(directory="app/static"), name="static")
164
+ except Exception:
165
+ pass
166
+
167
+ # Middlewares (gzip, CORS, rate-limit, req-logs, etc.)
168
+ attach_middlewares(app)
169
+
170
+ # Core info/router pages
171
+ app.include_router(health.router, tags=["Health"])
172
+
173
+ # Validator router
174
+ app.include_router(validator_router, tags=["Validator"])
175
+
176
+ # UI router (enables /home "Info" page and "/" redirect defined in ui.py)
177
+ app.include_router(ui_router)
178
+
179
+ # Alias so the frontend can POST /agent-card (script.js default target)
180
+ try:
181
+ from .services.validator_service import get_agent_card as _get_agent_card # type: ignore
182
+ app.add_api_route(
183
+ "/agent-card",
184
+ _get_agent_card,
185
+ methods=["POST"],
186
+ tags=["Validator"],
187
+ name="agent_card_alias",
188
+ )
189
+ logging.getLogger("uvicorn.error").info(
190
+ "Added alias: POST /agent-card → /validator/agent-card"
191
+ )
192
+ except Exception as e:
193
+ logging.getLogger("uvicorn.error").warning(
194
+ f"Failed to add /agent-card alias: {e}"
195
+ )
196
+
197
+ # Mount Socket.IO if available
198
+ if HAS_SOCKETIO and socketio_app is not None:
199
+ app.mount("/socket.io", socketio_app)
200
+ logging.getLogger("uvicorn.error").info("Mounted Socket.IO at /socket.io")
201
+
202
+ # IMPORTANT:
203
+ # Do NOT define extra "/" or "/home" handlers here.
204
+ # ui.py already defines:
205
+ # - GET "/" -> Redirect to /validator
206
+ # - GET "/home" -> Render home.html (Info tab)
207
+ # Keeping only one definition avoids duplicate-route conflicts.
208
+
209
+ return app
210
+
211
+ app = create_app()
app/middleware.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/middleware.py
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ import logging
6
+ import json
7
+ import asyncio
8
+ from typing import Callable, Optional
9
+
10
+ from anyio import EndOfStream
11
+ from fastapi import FastAPI, Request
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from starlette.responses import Response, JSONResponse
14
+ from starlette.middleware.gzip import GZipMiddleware
15
+ from starlette.exceptions import ClientDisconnect
16
+
17
+ # Optional: python-json-logger for structured logs; fallback to a minimal JSON formatter.
18
+ try:
19
+ from pythonjsonlogger import jsonlogger # type: ignore
20
+ _HAS_PY_JSON_LOGGER = True
21
+ except Exception:
22
+ _HAS_PY_JSON_LOGGER = False
23
+
24
+ from .deps import get_settings
25
+ from .core.rate_limit import RateLimiter
26
+ from .core.logging import add_trace_id
27
+
28
+ class _SimpleJsonFormatter(logging.Formatter):
29
+ def format(self, record: logging.LogRecord) -> str:
30
+ payload = {
31
+ "asctime": self.formatTime(record, "%Y-%m-%d %H:%M:%S"),
32
+ "name": record.name,
33
+ "levelname": record.levelname,
34
+ "message": record.getMessage(),
35
+ "trace_id": getattr(record, "trace_id", None),
36
+ }
37
+ try:
38
+ return json.dumps(payload, ensure_ascii=False)
39
+ except Exception:
40
+ return (
41
+ f'{payload["asctime"]} {payload["name"]} {payload["levelname"]} '
42
+ f'{payload["message"]} trace_id={payload["trace_id"]}'
43
+ )
44
+
45
+ _logger = logging.getLogger("matrix-ai")
46
+ if not _logger.handlers:
47
+ _logger.setLevel(logging.INFO)
48
+ _handler = logging.StreamHandler()
49
+ if _HAS_PY_JSON_LOGGER:
50
+ _formatter = jsonlogger.JsonFormatter(
51
+ "%(asctime)s %(name)s %(levelname)s %(message)s %(trace_id)s"
52
+ )
53
+ else:
54
+ _formatter = _SimpleJsonFormatter()
55
+ logging.getLogger("uvicorn.error").warning(
56
+ "python-json-logger not found; using a minimal JSON formatter."
57
+ )
58
+ _handler.setFormatter(_formatter)
59
+ _logger.addHandler(_handler)
60
+
61
+ _rate_limiter = RateLimiter()
62
+ _SSE_PATH_SUFFIXES = ("/chat/stream", "/v1/chat/stream")
63
+ _HEALTH_PATHS = ("/health", "/livez", "/readyz")
64
+
65
+ def _client_ip(request: Request) -> str:
66
+ xff = request.headers.get("x-forwarded-for")
67
+ if xff:
68
+ return xff.split(",")[0].strip()
69
+ return request.client.host if request.client else "unknown"
70
+
71
+ def _is_sse(request: Request, response: Optional[Response] = None) -> bool:
72
+ path = request.url.path
73
+ if path.endswith(_SSE_PATH_SUFFIXES):
74
+ return True
75
+ if response is not None:
76
+ ctype = response.headers.get("content-type", "")
77
+ if ctype.startswith("text/event-stream"):
78
+ return True
79
+ accept = request.headers.get("accept", "")
80
+ return "text/event-stream" in accept
81
+
82
+ def attach_middlewares(app: FastAPI) -> None:
83
+ app.add_middleware(GZipMiddleware, minimum_size=512)
84
+ app.add_middleware(
85
+ CORSMiddleware,
86
+ allow_origins=["*"],
87
+ allow_credentials=True,
88
+ allow_methods=["*"],
89
+ allow_headers=["*"],
90
+ expose_headers=["X-Trace-Id", "X-Process-Time-Ms", "Server-Timing"],
91
+ )
92
+
93
+ @app.middleware("http")
94
+ async def rate_limit_and_log_middleware(request: Request, call_next: Callable):
95
+ add_trace_id(request)
96
+ trace_id = getattr(request.state, "trace_id", "N/A")
97
+
98
+ path = request.url.path
99
+ method = request.method
100
+ ua = request.headers.get("user-agent", "-")
101
+ ip = _client_ip(request)
102
+
103
+ if path in _HEALTH_PATHS:
104
+ try:
105
+ response = await call_next(request)
106
+ except Exception:
107
+ return JSONResponse({"status": "unhealthy"}, status_code=500)
108
+ response.headers.setdefault("X-Trace-Id", str(trace_id))
109
+ return response
110
+
111
+ settings = get_settings()
112
+ if not _rate_limiter.allow(ip, path, settings.limits.rate_per_min):
113
+ _logger.warning(
114
+ "429 Too Many Requests from %s on %s",
115
+ ip, path, extra={"trace_id": trace_id},
116
+ )
117
+ return JSONResponse({"detail": "Too Many Requests"}, status_code=429,
118
+ headers={"X-Trace-Id": str(trace_id)})
119
+
120
+ t0 = time.time()
121
+ try:
122
+ response = await call_next(request)
123
+
124
+ # --- NEW: treat disconnects as benign (return 204) ---
125
+ except (EndOfStream, ClientDisconnect, asyncio.CancelledError):
126
+ _logger.info(
127
+ "Client disconnected from stream. Path: %s, IP: %s",
128
+ path, ip, extra={"trace_id": trace_id},
129
+ )
130
+ resp = Response(status_code=204)
131
+ resp.headers.setdefault("X-Trace-Id", str(trace_id))
132
+ return resp
133
+
134
+ except RuntimeError as e:
135
+ # Starlette sometimes wraps EndOfStream as this RuntimeError
136
+ if str(e) == "No response returned.":
137
+ _logger.info(
138
+ "Downstream produced no response (likely streaming disconnect). "
139
+ "Path: %s, IP: %s",
140
+ path, ip, extra={"trace_id": trace_id},
141
+ )
142
+ resp = Response(status_code=204)
143
+ resp.headers.setdefault("X-Trace-Id", str(trace_id))
144
+ return resp
145
+ # not a disconnect case → re-raise to be handled below
146
+ raise
147
+
148
+ except Exception as e:
149
+ _logger.exception(
150
+ "Unhandled error while processing %s %s: %s",
151
+ method, path, e, extra={"trace_id": trace_id},
152
+ )
153
+ dur_ms = (time.time() - t0) * 1000.0
154
+ return JSONResponse(
155
+ {"detail": "Internal Server Error"},
156
+ status_code=500,
157
+ headers={
158
+ "X-Trace-Id": str(trace_id),
159
+ "X-Process-Time-Ms": f"{dur_ms:.2f}",
160
+ "Server-Timing": f"app;dur={dur_ms:.2f}",
161
+ },
162
+ )
163
+
164
+ if not isinstance(response, Response):
165
+ _logger.error("Downstream returned no Response object for %s",
166
+ path, extra={"trace_id": trace_id})
167
+ return JSONResponse({"detail": "Internal Server Error"},
168
+ status_code=500,
169
+ headers={"X-Trace-Id": str(trace_id)})
170
+
171
+ sse = _is_sse(request, response)
172
+ dur_ms = (time.time() - t0) * 1000.0
173
+ response.headers.setdefault("X-Trace-Id", str(trace_id))
174
+ response.headers.setdefault("X-Process-Time-Ms", f"{dur_ms:.2f}")
175
+ response.headers.setdefault("Server-Timing", f"app;dur={dur_ms:.2f}")
176
+
177
+ if sse:
178
+ response.headers.setdefault("Cache-Control", "no-cache")
179
+ _logger.info(
180
+ '"%s %s" %s (SSE) ip=%s ua="%s" %.2fms',
181
+ method, path, response.status_code, ip, ua, dur_ms,
182
+ extra={"trace_id": trace_id},
183
+ )
184
+ return response
185
+
186
+ _logger.info(
187
+ '"%s %s" %s ip=%s ua="%s" %.2fms',
188
+ method, path, response.status_code, ip, ua, dur_ms,
189
+ extra={"trace_id": trace_id},
190
+ )
191
+ return response
app/routers/__init__.py ADDED
File without changes
app/routers/health.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ router = APIRouter()
4
+
5
+ @router.get("/healthz", summary="Liveness Probe")
6
+ async def healthz():
7
+ """Checks if the service is running."""
8
+ return {"status": "ok"}
9
+
10
+ @router.get("/readyz", summary="Readiness Probe")
11
+ async def readyz():
12
+ """Checks if the service is ready to accept traffic."""
13
+ # In a real app, this would check dependencies like model loading status.
14
+ return {"ready": True}
app/services/__init__.py ADDED
File without changes
app/services/validator_service.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/services/validator_service.py
2
+ """
3
+ A2A Validator service.
4
+ - Provides /validator (UI) + /validator/agent-card (HTTP) routes.
5
+ - Defines all Socket.IO event handlers.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any
11
+ from urllib.parse import urlparse, urlunparse
12
+ from uuid import uuid4
13
+
14
+ import bleach
15
+ import httpx
16
+
17
+ # Socket.IO is optional; create shims when missing
18
+ try:
19
+ import socketio # type: ignore
20
+ HAS_SOCKETIO = True
21
+ except Exception: # pragma: no cover
22
+ socketio = None # type: ignore
23
+ HAS_SOCKETIO = False
24
+
25
+ from fastapi import APIRouter, Request
26
+ from fastapi.responses import HTMLResponse, JSONResponse
27
+ from fastapi.templating import Jinja2Templates
28
+ from jinja2 import TemplateNotFound
29
+
30
+ # Conditional import for A2A SDK (optional)
31
+ try:
32
+ from a2a.client import A2ACardResolver, A2AClient
33
+ from a2a.types import (
34
+ AgentCard,
35
+ JSONRPCErrorResponse,
36
+ Message,
37
+ MessageSendConfiguration,
38
+ MessageSendParams,
39
+ Role,
40
+ SendMessageRequest,
41
+ SendMessageResponse,
42
+ SendStreamingMessageRequest,
43
+ SendStreamingMessageResponse,
44
+ TextPart,
45
+ )
46
+ HAS_A2A = True
47
+ except Exception:
48
+ HAS_A2A = False
49
+ # Dummy stand-ins so type hints won’t explode
50
+ AgentCard = JSONRPCErrorResponse = Message = MessageSendConfiguration = object # type: ignore
51
+ MessageSendParams = Role = SendMessageRequest = SendMessageResponse = object # type: ignore
52
+ SendStreamingMessageRequest = SendStreamingMessageResponse = TextPart = object # type: ignore
53
+ A2ACardResolver = A2AClient = object # type: ignore
54
+
55
+ from app import validators # local validators.py
56
+
57
+ # ==============================================================================
58
+ # Setup
59
+ # ==============================================================================
60
+ logger = logging.getLogger("uvicorn.error")
61
+
62
+ if HAS_SOCKETIO:
63
+ sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
64
+ socketio_app = socketio.ASGIApp(sio)
65
+ else:
66
+ class _SioShim:
67
+ async def emit(self, *a, **k): # no-op
68
+ pass
69
+
70
+ def on(self, *a, **k):
71
+ def _wrap(f):
72
+ return f
73
+ return _wrap
74
+
75
+ event = on
76
+
77
+ sio = _SioShim()
78
+ socketio_app = None
79
+
80
+ router = APIRouter(prefix="/validator", tags=["Validator"])
81
+ templates = Jinja2Templates(directory="app/templates")
82
+
83
+ STANDARD_HEADERS = {
84
+ "host",
85
+ "user-agent",
86
+ "accept",
87
+ "content-type",
88
+ "content-length",
89
+ "connection",
90
+ "accept-encoding",
91
+ }
92
+
93
+ # ==============================================================================
94
+ # State Management
95
+ # ==============================================================================
96
+ clients: dict[str, tuple[httpx.AsyncClient, Any, Any]] = {}
97
+
98
+ # ==============================================================================
99
+ # Helpers
100
+ # ==============================================================================
101
+ async def _emit_debug_log(sid: str, event_id: str, log_type: str, data: Any) -> None:
102
+ await sio.emit("debug_log", {"type": log_type, "data": data, "id": event_id}, to=sid)
103
+
104
+
105
+ async def _process_a2a_response(result: Any, sid: str, request_id: str) -> None:
106
+ if not HAS_A2A:
107
+ return
108
+
109
+ if isinstance(result.root, JSONRPCErrorResponse):
110
+ error_data = result.root.error.model_dump(exclude_none=True)
111
+ await _emit_debug_log(sid, request_id, "error", error_data)
112
+ await sio.emit(
113
+ "agent_response",
114
+ {"error": error_data.get("message", "Unknown error"), "id": request_id},
115
+ to=sid,
116
+ )
117
+ return
118
+
119
+ event = result.root.result
120
+ response_id = getattr(event, "id", request_id)
121
+ response_data = event.model_dump(exclude_none=True)
122
+ response_data["id"] = response_id
123
+ response_data["validation_errors"] = validators.validate_message(response_data)
124
+
125
+ await _emit_debug_log(sid, response_id, "response", response_data)
126
+ await sio.emit("agent_response", response_data, to=sid)
127
+
128
+
129
+ def get_card_resolver(client: httpx.AsyncClient, agent_card_url: str) -> Any:
130
+ if not HAS_A2A:
131
+ return None
132
+ parsed_url = urlparse(agent_card_url)
133
+ base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
134
+ path_with_query = urlunparse(("", "", parsed_url.path, "", parsed_url.query, ""))
135
+ card_path = path_with_query.lstrip("/")
136
+ if card_path:
137
+ return A2ACardResolver(client, base_url, agent_card_path=card_path)
138
+ return A2ACardResolver(client, base_url)
139
+
140
+ # ==============================================================================
141
+ # FastAPI Routes
142
+ # ==============================================================================
143
+ @router.get("/", response_class=HTMLResponse)
144
+ async def validator_ui(request: Request) -> HTMLResponse:
145
+ # Prefer validator.hml (your current file), fallback to validator.html
146
+ for name in ("validator.hml", "validator.html"):
147
+ try:
148
+ return templates.TemplateResponse(name, {"request": request})
149
+ except TemplateNotFound:
150
+ continue
151
+ # If neither exists, return a minimal message
152
+ return HTMLResponse("<h3>Validator UI template not found.</h3>", status_code=500)
153
+
154
+
155
+ @router.post("/agent-card")
156
+ async def get_agent_card(request: Request) -> JSONResponse:
157
+ """
158
+ Fetch and validate an Agent Card from a URL.
159
+
160
+ If A2A SDK is installed, use its resolver.
161
+ Otherwise, be lenient: follow redirects and probe common well-known paths.
162
+ """
163
+ # Parse request body
164
+ try:
165
+ request_data = await request.json()
166
+ agent_url = (request_data.get("url") or "").strip()
167
+ sid = request_data.get("sid")
168
+ if not agent_url or not sid:
169
+ return JSONResponse({"error": "Agent URL and SID are required."}, status_code=400)
170
+ except Exception:
171
+ return JSONResponse({"error": "Invalid request body."}, status_code=400)
172
+
173
+ # Collect custom headers (forwarded to the target)
174
+ custom_headers = {
175
+ name: value
176
+ for name, value in request.headers.items()
177
+ if name.lower() not in STANDARD_HEADERS
178
+ }
179
+
180
+ await _emit_debug_log(
181
+ sid,
182
+ "http-agent-card",
183
+ "request",
184
+ {"endpoint": "/agent-card", "payload": request_data, "custom_headers": custom_headers},
185
+ )
186
+
187
+ # Fetch the agent card
188
+ try:
189
+ async with httpx.AsyncClient(
190
+ timeout=30.0,
191
+ headers=custom_headers,
192
+ follow_redirects=True, # <<< important for 3xx like 307 to /docs
193
+ ) as client:
194
+ if HAS_A2A:
195
+ # Preferred path: let the resolver figure out the right card location
196
+ card_resolver = get_card_resolver(client, agent_url)
197
+ card = await card_resolver.get_agent_card()
198
+ card_data = card.model_dump(exclude_none=True)
199
+ else:
200
+ # Fallback: try what the user typed first; if non-JSON, probe common paths
201
+ tried: list[str] = []
202
+
203
+ async def _try(url: str) -> dict[str, Any]:
204
+ r = await client.get(url)
205
+ r.raise_for_status()
206
+ ctype = (r.headers.get("content-type") or "").lower()
207
+ if "application/json" in ctype or ctype.endswith("+json"):
208
+ return r.json()
209
+ # If we got HTML or something else, raise to trigger probing
210
+ raise ValueError(f"Non-JSON response (content-type={ctype or 'unknown'}) at {url}")
211
+
212
+ try:
213
+ card_data = await _try(agent_url)
214
+ except Exception:
215
+ # If the user pasted a base/root URL, probe common Agent Card paths on same host
216
+ parsed = urlparse(agent_url)
217
+ base = f"{parsed.scheme}://{parsed.netloc}"
218
+ candidates = [
219
+ agent_url, # original again (in case it became JSON after redirect)
220
+ f"{base}/.well-known/agent.json",
221
+ f"{base}/.well-known/ai-agent.json",
222
+ f"{base}/agent-card",
223
+ f"{base}/agent.json",
224
+ ]
225
+ err: Exception | None = None
226
+ card_data = None
227
+ for u in candidates:
228
+ if u in tried:
229
+ continue
230
+ tried.append(u)
231
+ try:
232
+ card_data = await _try(u)
233
+ agent_url = u # record the working URL
234
+ break
235
+ except Exception as e:
236
+ err = e
237
+ if card_data is None:
238
+ raise RuntimeError(
239
+ f"Could not find a JSON Agent Card at {agent_url} (last error: {err})"
240
+ )
241
+
242
+ # Validate locally
243
+ validation_errors = validators.validate_agent_card(card_data) # type: ignore[arg-type]
244
+ response = {
245
+ "card": card_data,
246
+ "validation_errors": validation_errors,
247
+ "resolved_url": agent_url,
248
+ }
249
+ status = 200
250
+
251
+ except httpx.RequestError as e:
252
+ response = {"error": f"Failed to connect to agent: {e}"}
253
+ status = 502
254
+ except Exception as e:
255
+ response = {"error": f"An internal server error occurred: {e}"}
256
+ status = 500
257
+
258
+ await _emit_debug_log(sid, "http-agent-card", "response", {"status": status, "payload": response})
259
+ return JSONResponse(content=response, status_code=status)
260
+
261
+ # ==============================================================================
262
+ # Socket.IO Event Handlers
263
+ # ==============================================================================
264
+ @sio.on("connect")
265
+ async def handle_connect(sid: str, environ: dict[str, Any]) -> None: # type: ignore[misc]
266
+ logger.info(f"Client connected: {sid}")
267
+
268
+
269
+ @sio.on("disconnect")
270
+ async def handle_disconnect(sid: str) -> None: # type: ignore[misc]
271
+ logger.info(f"Client disconnected: {sid}")
272
+ if sid in clients:
273
+ httpx_client, _, _ = clients.pop(sid)
274
+ await httpx_client.aclose()
275
+ logger.info(f"Cleaned up client for {sid}")
276
+
277
+
278
+ @sio.on("initialize_client")
279
+ async def handle_initialize_client(sid: str, data: dict[str, Any]) -> None: # type: ignore[misc]
280
+ """
281
+ Prepare an A2A client for chat/streaming. If a2a is not installed, reply with a warning
282
+ so the UI still proceeds (card viewing still works via HTTP).
283
+ """
284
+ if not HAS_A2A:
285
+ await sio.emit(
286
+ "client_initialized",
287
+ {"status": "warning", "message": "A2A SDK not installed; chat/streaming disabled."},
288
+ to=sid,
289
+ )
290
+ return
291
+
292
+ agent_card_url = data.get("url")
293
+ custom_headers = data.get("customHeaders", {})
294
+ if not agent_card_url:
295
+ await sio.emit("client_initialized", {"status": "error", "message": "Agent URL is required."}, to=sid)
296
+ return
297
+
298
+ try:
299
+ httpx_client = httpx.AsyncClient(timeout=600.0, headers=custom_headers)
300
+ card_resolver = get_card_resolver(httpx_client, agent_card_url)
301
+ card = await card_resolver.get_agent_card()
302
+ a2a_client = A2AClient(httpx_client, agent_card=card)
303
+ clients[sid] = (httpx_client, a2a_client, card)
304
+ await sio.emit("client_initialized", {"status": "success"}, to=sid)
305
+ except Exception as e:
306
+ await sio.emit("client_initialized", {"status": "error", "message": str(e)}, to=sid)
307
+
308
+
309
+ @sio.on("send_message")
310
+ async def handle_send_message(sid: str, json_data: dict[str, Any]) -> None: # type: ignore[misc]
311
+ if not HAS_A2A:
312
+ await sio.emit("agent_response", {"error": "A2A SDK not installed", "id": json_data.get("id")}, to=sid)
313
+ return
314
+
315
+ message_text = bleach.clean(json_data.get("message", ""))
316
+ message_id = json_data.get("id", str(uuid4()))
317
+ context_id = json_data.get("contextId")
318
+ metadata = json_data.get("metadata", {})
319
+
320
+ if sid not in clients:
321
+ await sio.emit("agent_response", {"error": "Client not initialized.", "id": message_id}, to=sid)
322
+ return
323
+
324
+ _, a2a_client, card = clients[sid]
325
+
326
+ message = Message(
327
+ role=Role.user,
328
+ parts=[TextPart(text=str(message_text))], # type: ignore[list-item]
329
+ message_id=message_id,
330
+ context_id=context_id,
331
+ metadata=metadata,
332
+ )
333
+ payload = MessageSendParams(
334
+ message=message,
335
+ configuration=MessageSendConfiguration(accepted_output_modes=["text/plain", "video/mp4"]),
336
+ )
337
+ supports_streaming = hasattr(card.capabilities, "streaming") and card.capabilities.streaming is True
338
+
339
+ try:
340
+ if supports_streaming:
341
+ stream_request = SendStreamingMessageRequest(
342
+ id=message_id, method="message/stream", jsonrpc="2.0", params=payload
343
+ )
344
+ await _emit_debug_log(sid, message_id, "request", stream_request.model_dump(exclude_none=True))
345
+ response_stream = a2a_client.send_message_streaming(stream_request)
346
+ async for stream_result in response_stream:
347
+ await _process_a2a_response(stream_result, sid, message_id)
348
+ else:
349
+ send_message_request = SendMessageRequest(
350
+ id=message_id, method="message/send", jsonrpc="2.0", params=payload
351
+ )
352
+ await _emit_debug_log(sid, message_id, "request", send_message_request.model_dump(exclude_none=True))
353
+ send_result = await a2a_client.send_message(send_message_request)
354
+ await _process_a2a_response(send_result, sid, message_id)
355
+ except Exception as e:
356
+ await sio.emit("agent_response", {"error": f"Failed to send message: {e}", "id": message_id}, to=sid)
357
+
358
+ __all__ = ["router", "socketio_app", "HAS_SOCKETIO"]
app/static/script.js ADDED
The diff for this file is too large to render. See raw diff
 
app/static/styles.css ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ================================
2
+ MatrixHub / A2A Validator Styles
3
+ (app/static/styles.css)
4
+ ================================ */
5
+
6
+ /* Theme tokens (inherits nicely with base.html) */
7
+ :root {
8
+ --bg: #020402;
9
+ --bg2: #071007;
10
+ --surface: #061006cc;
11
+ --text: #c8facc;
12
+ --muted: #9aa29a;
13
+ --matrix: #00ff9c;
14
+ --border: rgba(0,255,156,0.18);
15
+ --border-strong: #0d1e0f;
16
+ --code-bg: #020a04; /* solid code background to stop canvas bleed */
17
+ }
18
+
19
+ /* Container (kept for compatibility with older markup) */
20
+ .container {
21
+ max-width: 980px;
22
+ margin: 2rem auto;
23
+ font-family: "Share Tech Mono", monospace;
24
+ color: var(--text);
25
+ }
26
+
27
+ /* Simple spacing helpers used by script.js flows */
28
+ .url-input-container,
29
+ .connect-btn-container,
30
+ .chat-input-container {
31
+ margin: 1rem 0;
32
+ }
33
+
34
+ /* Key-Value rows (HTTP headers / metadata rows) */
35
+ .kv-row {
36
+ display: grid;
37
+ grid-template-columns: 1fr 1fr auto;
38
+ gap: .5rem;
39
+ margin-bottom: .5rem;
40
+ align-items: center;
41
+ }
42
+
43
+ .kv-row input[type="text"],
44
+ .kv-row input[type="search"],
45
+ .kv-row input[type="url"] {
46
+ background: rgba(0,0,0,0.35);
47
+ border: 1px solid var(--border);
48
+ border-radius: 10px;
49
+ color: var(--text);
50
+ padding: 10px 12px;
51
+ outline: none;
52
+ }
53
+
54
+ .kv-row input:focus {
55
+ border-color: var(--matrix);
56
+ box-shadow: 0 0 0 3px rgba(0,255,156,0.08);
57
+ }
58
+
59
+ .kv-del {
60
+ padding: .45rem .7rem;
61
+ border-radius: 10px;
62
+ border: 1px solid var(--border);
63
+ color: var(--muted);
64
+ background: rgba(0,0,0,0.25);
65
+ cursor: pointer;
66
+ transition: filter .15s ease, box-shadow .15s ease, background .15s ease;
67
+ }
68
+ .kv-del:hover {
69
+ color: #032215;
70
+ background: linear-gradient(180deg, #00ff9c, #00c97e);
71
+ box-shadow: 0 8px 24px rgba(0,255,156,0.25);
72
+ }
73
+
74
+ /* Collapsible helpers */
75
+ .collapsible-content.hidden,
76
+ .hidden { display: none; }
77
+
78
+ /* Chat bubbles (dark theme) */
79
+ .chat-bubble {
80
+ border: 1px solid var(--border);
81
+ border-radius: 10px;
82
+ padding: .6rem .7rem;
83
+ margin: .5rem 0;
84
+ background: rgba(0,0,0,0.25);
85
+ cursor: pointer;
86
+ transition: box-shadow .15s ease, transform .05s ease, background .15s ease;
87
+ }
88
+ .chat-bubble:hover {
89
+ background: rgba(0,0,0,0.35);
90
+ }
91
+ .chat-bubble:active {
92
+ transform: translateY(1px);
93
+ }
94
+ .chat-bubble.ok {
95
+ box-shadow: 0 0 0 1px rgba(0,255,156,0.18);
96
+ }
97
+ .chat-bubble.warn {
98
+ box-shadow: 0 0 0 1px rgba(255,196,0,0.22);
99
+ }
100
+
101
+ /* Chat bubble header line */
102
+ .chat-head {
103
+ font-weight: 700;
104
+ color: var(--matrix);
105
+ margin-bottom: .25rem;
106
+ letter-spacing: .02em;
107
+ }
108
+
109
+ /* Status colors used in various places */
110
+ .ok { color: #35d08c; }
111
+ .warn { color: #e2a339; }
112
+
113
+ /* Placeholder/aux text */
114
+ .placeholder-text { color: var(--muted); }
115
+
116
+ /* ===============================
117
+ Debug Console (dark, sticky)
118
+ =============================== */
119
+ #debug-console {
120
+ position: fixed;
121
+ right: 1rem;
122
+ bottom: 1rem;
123
+ width: 460px;
124
+ max-height: 60vh;
125
+ background: #0a140a;
126
+ border: 1px solid var(--border);
127
+ border-radius: 12px;
128
+ overflow: hidden;
129
+ box-shadow: 0 12px 36px rgba(0,0,0,.35), 0 0 0 1px rgba(0,255,156,.05);
130
+ z-index: 1000;
131
+ }
132
+ #debug-console.hidden { display: none; }
133
+
134
+ #debug-handle {
135
+ background: #000;
136
+ color: var(--text);
137
+ padding: .6rem .8rem;
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: space-between;
141
+ letter-spacing: .08em;
142
+ border-bottom: 1px solid var(--border);
143
+ }
144
+
145
+ #debug-content {
146
+ padding: .6rem .8rem;
147
+ max-height: 48vh;
148
+ overflow: auto;
149
+ font-family: "Share Tech Mono", monospace;
150
+ }
151
+
152
+ /* Make logged JSON blocks readable and SOLID (no canvas bleed-through) */
153
+ #debug-content pre {
154
+ background: var(--code-bg) !important;
155
+ color: var(--text);
156
+ border: 1px solid var(--border);
157
+ border-radius: 10px;
158
+ padding: 10px;
159
+ white-space: pre-wrap;
160
+ word-break: break-word;
161
+ }
162
+
163
+ /* ===============================
164
+ Code / Pre blocks — OPAQUE FIX
165
+ =============================== */
166
+
167
+ /* Global default for code/pre used across validator */
168
+ pre, code, pre code {
169
+ background: var(--code-bg) !important; /* solid background */
170
+ color: var(--text);
171
+ border: 1px solid var(--border);
172
+ border-radius: 12px;
173
+ }
174
+
175
+ /* Specific areas we know render JSON (agent card, modal, chat bubbles) */
176
+ #agent-card-content,
177
+ .agent-card-display pre,
178
+ .agent-card-display pre code,
179
+ #modal-json-content,
180
+ .chat-bubble pre {
181
+ background: var(--code-bg) !important;
182
+ backdrop-filter: none !important;
183
+ }
184
+
185
+ /* Highlight.js fallback (some themes force their own bg) */
186
+ .hljs,
187
+ code.hljs {
188
+ background: var(--code-bg) !important;
189
+ color: var(--text);
190
+ border-radius: 10px;
191
+ padding: 10px;
192
+ }
193
+
194
+ /* ===============================
195
+ Buttons (generic)
196
+ =============================== */
197
+ button,
198
+ .button {
199
+ background: linear-gradient(180deg, #00ff9c, #00c97e);
200
+ color: #032215;
201
+ border: 0;
202
+ padding: .6rem .9rem;
203
+ border-radius: 12px;
204
+ font-weight: 800;
205
+ font-family: "Share Tech Mono", monospace;
206
+ letter-spacing: .03em;
207
+ cursor: pointer;
208
+ box-shadow: 0 8px 24px rgba(0,255,156,0.25);
209
+ transition: transform .05s ease, box-shadow .15s ease, filter .15s ease;
210
+ }
211
+ button:hover,
212
+ .button:hover { filter: brightness(1.05); box-shadow: 0 12px 36px rgba(0,255,156,0.35); }
213
+ button:active,
214
+ .button:active { transform: translateY(1px); }
215
+ button:disabled {
216
+ background: #0b1a12;
217
+ color: var(--muted);
218
+ border: 1px solid var(--border);
219
+ box-shadow: none;
220
+ cursor: not-allowed;
221
+ }
222
+
223
+ /* Inputs (generic) */
224
+ input[type="text"],
225
+ input[type="search"],
226
+ input[type="url"] {
227
+ width: 100%;
228
+ color: var(--text);
229
+ background: var(--code-bg);
230
+ border: 1px solid var(--border);
231
+ border-radius: 12px;
232
+ padding: 10px 12px;
233
+ font-size: 14px;
234
+ font-family: "Share Tech Mono", monospace;
235
+ outline: none;
236
+ transition: border-color .15s ease, box-shadow .15s ease;
237
+ }
238
+ input[type="text"]:focus,
239
+ input[type="search"]:focus,
240
+ input[type="url"]:focus {
241
+ border-color: var(--matrix);
242
+ box-shadow: 0 0 0 3px rgba(0,255,156,0.08), 0 0 12px rgba(0,255,156,0.25) inset;
243
+ }
app/templates/base.html ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>matrix-ai</title>
7
+
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Inter:wght@400;600&display=swap" rel="stylesheet">
11
+
12
+ <style>
13
+ :root {
14
+ --bg: #020402;
15
+ --bg2: #071007;
16
+ --text: #c8facc;
17
+ --muted: #7ef7a7;
18
+ --matrix: #00ff9c;
19
+ --card: #061006cc; /* translucent */
20
+ --border: #0d1e0f;
21
+ }
22
+ html, body { height: 100%; }
23
+ body {
24
+ margin: 0;
25
+ color: var(--text);
26
+ background:
27
+ radial-gradient(1200px 800px at 100% -10%, rgba(0,255,156,0.06), transparent 40%),
28
+ radial-gradient(1000px 600px at -10% 100%, rgba(0,255,156,0.05), transparent 40%),
29
+ linear-gradient(180deg, var(--bg), var(--bg2) 60%, var(--bg));
30
+ font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
31
+ overflow-y: auto;
32
+ }
33
+ body::after {
34
+ content: "";
35
+ position: fixed;
36
+ inset: 0;
37
+ pointer-events: none;
38
+ background: repeating-linear-gradient(to bottom, rgba(0,0,0,0.06) 0px, rgba(0,0,0,0.06) 1px, transparent 2px, transparent 3px);
39
+ mix-blend-mode: overlay;
40
+ opacity: 0.6;
41
+ z-index: 0;
42
+ }
43
+ #code-rain {
44
+ position: fixed;
45
+ inset: 0;
46
+ width: 100vw;
47
+ height: 100vh;
48
+ z-index: 0;
49
+ pointer-events: none;
50
+ opacity: 0.65;
51
+ }
52
+ header {
53
+ position: sticky; top: 0; z-index: 3;
54
+ display: flex; align-items: center; gap: 18px;
55
+ padding: 18px 22px;
56
+ background: linear-gradient(180deg, rgba(0,0,0,0.35), rgba(0,0,0,0));
57
+ border-bottom: 1px solid var(--border);
58
+ backdrop-filter: blur(4px);
59
+ }
60
+ .brand {
61
+ font-family: "Share Tech Mono", monospace;
62
+ color: var(--matrix);
63
+ text-shadow: 0 0 8px rgba(0,255,156,0.4);
64
+ letter-spacing: 0.04em;
65
+ font-size: 18px;
66
+ }
67
+ nav a {
68
+ color: var(--muted);
69
+ text-decoration: none;
70
+ margin-right: 16px;
71
+ transition: color .15s ease, text-shadow .15s ease;
72
+ }
73
+ nav a:hover {
74
+ color: var(--matrix);
75
+ text-shadow: 0 0 8px rgba(0,255,156,0.4);
76
+ }
77
+ .wrap { position: relative; z-index: 2; max-width: 980px; margin: 0 auto; padding: 26px 22px 60px; }
78
+ .card {
79
+ background: var(--card);
80
+ border: 1px solid var(--border);
81
+ border-radius: 16px;
82
+ box-shadow: 0 0 0 1px rgba(0,255,156,0.06), 0 8px 30px rgba(0,0,0,0.35);
83
+ padding: 20px;
84
+ }
85
+ h2, h3, h4 { font-family: "Share Tech Mono", monospace; color: var(--matrix); letter-spacing: .02em; }
86
+ p { color: var(--text); opacity: 0.95; }
87
+ input, textarea {
88
+ width: 100%;
89
+ color: var(--text);
90
+ background: #020a04;
91
+ border: 1px solid var(--border);
92
+ border-radius: 12px;
93
+ padding: 12px 12px;
94
+ font-size: 14px;
95
+ font-family: "Share Tech Mono", monospace;
96
+ outline: none;
97
+ transition: border-color .15s ease, box-shadow .15s ease;
98
+ }
99
+ input:focus, textarea:focus {
100
+ border-color: var(--matrix);
101
+ box-shadow: 0 0 0 3px rgba(0,255,156,0.08), 0 0 12px rgba(0,255,156,0.25) inset;
102
+ }
103
+ button {
104
+ background: linear-gradient(180deg, #00ff9c, #00c97e);
105
+ color: #002f1b;
106
+ border: 0;
107
+ padding: 10px 16px;
108
+ border-radius: 12px;
109
+ font-weight: 700;
110
+ font-family: "Share Tech Mono", monospace;
111
+ letter-spacing: 0.03em;
112
+ cursor: pointer;
113
+ box-shadow: 0 6px 20px rgba(0,255,156,0.25);
114
+ transition: transform .05s ease, box-shadow .15s ease, filter .15s ease;
115
+ }
116
+ button:hover { filter: brightness(1.05); box-shadow: 0 10px 30px rgba(0,255,156,0.35); }
117
+ button:active { transform: translateY(1px); }
118
+ pre, code {
119
+ font-family: "Share Tech Mono", monospace;
120
+ background: #020a04;
121
+ border: 1px solid var(--border);
122
+ border-radius: 12px;
123
+ }
124
+ pre { padding: 12px; overflow: auto; }
125
+ @keyframes glow {
126
+ 0%, 100% { text-shadow: 0 0 10px rgba(0,255,156,0.12); }
127
+ 50% { text-shadow: 0 0 14px rgba(0,255,156,0.28); }
128
+ }
129
+ h3 { animation: glow 3.5s ease-in-out infinite; }
130
+ </style>
131
+ </head>
132
+ <body>
133
+ <canvas id="code-rain"></canvas>
134
+
135
+ <header>
136
+ <div class="brand">MATRIX-AI</div>
137
+ <nav>
138
+ <a href="/validator">Validator</a>
139
+ <a href="/home">Info</a>
140
+ <a href="https://github.com/agent-matrix/matrix-ai" target="_blank" rel="noreferrer" title="Give me a star on GitHub!">GitHub</a>
141
+ </nav>
142
+ </header>
143
+
144
+ <div class="wrap">
145
+ {% block body %}{% endblock %}
146
+ </div>
147
+
148
+ <script>
149
+ (function () {
150
+ const c = document.getElementById('code-rain');
151
+ if (!c) return;
152
+ const ctx = c.getContext('2d', { alpha: true });
153
+ let w, h, cols, drops;
154
+ const fontSize = 20;
155
+ const charSet = 'アァカサタナハマヤラワ0123456789アイウエオアイウエオ01';
156
+
157
+ function resize() {
158
+ w = c.width = window.innerWidth;
159
+ h = c.height = window.innerHeight;
160
+ cols = Math.floor(w / fontSize);
161
+ drops = Array(cols).fill(0).map(() => Math.floor(Math.random() * -50));
162
+ ctx.font = fontSize + "px 'Share Tech Mono', monospace";
163
+ }
164
+
165
+ function draw() {
166
+ ctx.fillStyle = 'rgba(2, 10, 4, 0.10)';
167
+ ctx.fillRect(0, 0, w, h);
168
+ for (let i = 0; i < cols; i++) {
169
+ const x = i * fontSize;
170
+ const y = drops[i] * fontSize;
171
+ const ch = charSet[Math.floor(Math.random() * charSet.length)];
172
+ ctx.shadowColor = 'rgba(0,255,156,0.35)';
173
+ ctx.shadowBlur = 8;
174
+ ctx.fillStyle = '#00ff9c';
175
+ ctx.fillText(ch, x, y);
176
+ if (y > h && Math.random() > 0.975) drops[i] = 0;
177
+ else drops[i]++;
178
+ }
179
+ setTimeout(draw, 70);
180
+ }
181
+
182
+ window.addEventListener('resize', resize);
183
+ resize();
184
+ draw();
185
+ })();
186
+ </script>
187
+ </body>
188
+ </html>
app/templates/home.html ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block body %}
3
+ <div class="card" style="padding:28px">
4
+ <div class="hero" style="display:grid; gap:14px;">
5
+ <div class="badge">MatrixHub • A2A Verification</div>
6
+ <h1 style="margin:0">MatrixHub</h1>
7
+ <p class="lede">
8
+ MatrixHub uses <strong>A2A agents</strong> and <strong>MCP servers</strong> to deliver
9
+ plug-and-play building blocks for <em>multi-agent applications</em>. This instance ships
10
+ a compact <strong>A2A Validator</strong> UI so you can quickly connect to agents,
11
+ inspect their <em>Agent Card</em>, and validate protocol compliance.
12
+ </p>
13
+
14
+ <div class="cta">
15
+ <a class="btn" href="/validator">Open Validator</a>
16
+ </div>
17
+
18
+ <div class="meta">
19
+ <span class="chip">Product: MatrixHub Validator</span>
20
+ <span class="chip">Version: {{ request.app.version }}</span>
21
+ <span class="chip">Endpoints: /validator · /agent-card · /healthz · /readyz</span>
22
+ </div>
23
+ </div>
24
+ </div>
25
+
26
+ <div class="grid">
27
+ <div class="card feature">
28
+ <div class="icon">🤝</div>
29
+ <h3>What is A2A?</h3>
30
+ <p>
31
+ <strong>A2A (Agent-to-Agent)</strong> is a simple, open protocol that lets independently
32
+ built AI agents talk to each other over the web. It standardizes three things:
33
+ </p>
34
+ <div class="list">
35
+ <div>• <strong>Discovery</strong> — every agent publishes an <em>Agent Card</em> (JSON) with
36
+ name/description/version, capabilities (e.g. streaming), skills, and default IO modes.
37
+ Think of it like “openapi.json” for agents.</div>
38
+ <div>• <strong>Messaging</strong> — structured events over HTTP/JSON-RPC (and optionally websockets):
39
+ <code>message</code>, <code>task</code>, <code>status-update</code>, <code>artifact-update</code>;
40
+ supports streaming; carries metadata for auth/tenancy.</div>
41
+ <div>• <strong>Interop rules</strong> — content types, IDs, correlation, and validation
42
+ so different frameworks can compose cleanly.</div>
43
+ </div>
44
+ <p style="margin-top:8px">
45
+ <strong>Why it matters:</strong> interoperability, discoverability, portability, safety/compliance,
46
+ and composability (fan-out, tools-as-agents) with consistent IDs and streaming.
47
+ </p>
48
+ </div>
49
+
50
+ <div class="card feature">
51
+ <div class="icon">🧭</div>
52
+ <h3>Where MatrixHub fits</h3>
53
+ <p>MatrixHub is the “registry + utilities” layer for A2A:</p>
54
+ <div class="list">
55
+ <div>• <strong>Directory/Catalog:</strong> publish & discover A2A-compatible agents by skill, version, trust.</div>
56
+ <div>• <strong>Validation & QA:</strong> lint Agent Cards and live endpoints (what this Validator UI does).</div>
57
+ <div>• <strong>Routing/Relay:</strong> optional proxy for CORS, auth, rate-limits, observability.</div>
58
+ <div>• <strong>Dev Experience:</strong> templates, SDKs, and “try it” UIs for quick local testing.</div>
59
+ <div>• <strong>Governance:</strong> versions, deprecation, and optional signing (provenance & trust).</div>
60
+ </div>
61
+ <p style="margin-top:8px">
62
+ Think of MatrixHub as the connective tissue that makes A2A practical at team/org scale.
63
+ </p>
64
+ </div>
65
+
66
+ <div class="card feature">
67
+ <div class="icon">🧩</div>
68
+ <h3>A2A on MCP servers</h3>
69
+ <p>
70
+ MCP is great for wiring <em>tools</em> to <em>clients</em> (e.g., editor ↔ tools). A2A complements MCP for
71
+ agent↔agent federation across domains:
72
+ </p>
73
+ <div class="list">
74
+ <div>• <strong>Expose MCP tools as A2A skills:</strong> publish an Agent Card; other agents can discover/call them.</div>
75
+ <div>• <strong>Cross-ecosystem calls:</strong> LLM agents, workflows, or other MCP servers invoke via A2A without speaking MCP.</div>
76
+ <div>• <strong>Uniform messaging:</strong> map MCP tool calls to A2A <code>message</code>/<code>task</code> and use A2A streaming.</div>
77
+ <div>• <strong>Catalog integration:</strong> MatrixHub indexes your MCP server by its Agent Card for discovery & testing.</div>
78
+ </div>
79
+ <p style="margin-top:8px">
80
+ Net effect: MCP stays your local tool bridge; A2A turns it into an internet-addressable agent other agents can compose with.
81
+ </p>
82
+ </div>
83
+ </div>
84
+
85
+ <div class="card arch">
86
+ <h3 style="margin-top:0">What this program does now</h3>
87
+ <div class="diagram">
88
+ <div class="lane">
89
+ <div class="box user">You</div>
90
+ <div class="arrow">/validator</div>
91
+ <div class="box ai">MatrixHub Validator</div>
92
+ <div class="arrow">/agent-card</div>
93
+ <div class="box svc">Target Agent (A2A)</div>
94
+ </div>
95
+ </div>
96
+
97
+ <div class="quick">
98
+ <p><strong>Summary:</strong> This instance exposes a lightweight A2A Validator:</p>
99
+ <div class="list" style="margin-top:6px">
100
+ <div>• <strong>Validator UI</strong> at <code>/validator</code> — connect to an agent URL, fetch & validate its Agent Card.</div>
101
+ <div>• <strong>Alias</strong> <code>POST /agent-card</code> → <code>/validator/agent-card</code> (frontend default).</div>
102
+ <div>• <strong>Health</strong> endpoints: <code>/healthz</code>, <code>/readyz</code>.</div>
103
+ <div>• <strong>Socket.IO</strong> mounted at <code>/socket.io</code> (used for debug logs & future streaming).</div>
104
+ <div>• RAG/Chat/Plan routes are disabled here to keep the focus on A2A verification.</div>
105
+ </div>
106
+
107
+ <details style="margin-top:10px">
108
+ <summary>Quick start (local)</summary>
109
+ <pre><code># Run the service
110
+ uvicorn app.main:app --host 0.0.0.0 --port 7860
111
+
112
+ # Open the Validator UI
113
+ # → http://localhost:7860/validator
114
+
115
+ # (Optional) Resolve an Agent Card directly (server alias)
116
+ curl -s -X POST http://localhost:7860/agent-card \
117
+ -H 'content-type: application/json' \
118
+ -d '{"url":"https://example.com/.well-known/agent.json","sid":"debug"}' | jq</code></pre>
119
+ </details>
120
+ </div>
121
+ </div>
122
+
123
+ <style>
124
+ .hero .badge{
125
+ display:inline-block; font-size:12px; letter-spacing:.06em;
126
+ color:#002f1b; background:linear-gradient(180deg,#00ff9c,#00c97e);
127
+ border-radius:999px; padding:6px 10px; font-weight:700;
128
+ box-shadow:0 6px 24px rgba(0,255,156,.25);
129
+ }
130
+ h1 { font-family:"Share Tech Mono", monospace; color:var(--matrix); letter-spacing:.03em; }
131
+ .lede { font-size:16px; opacity:.95; max-width:64ch; }
132
+ .cta { display:flex; gap:10px; margin-top:4px; flex-wrap:wrap; }
133
+ .btn {
134
+ display:inline-block; text-decoration:none; padding:10px 14px; border-radius:12px;
135
+ font-weight:700; font-family:"Share Tech Mono", monospace; letter-spacing:.03em;
136
+ background:linear-gradient(180deg,#00ff9c,#00c97e); color:#032215;
137
+ box-shadow:0 6px 20px rgba(0,255,156,.25);
138
+ }
139
+ .btn.ghost { background:#0b1a12; color:var(--muted); border:1px solid var(--border); box-shadow:none; }
140
+ .btn:hover { filter:brightness(1.05); }
141
+ .meta { display:flex; gap:10px; flex-wrap:wrap; margin-top:6px; }
142
+ .chip {
143
+ font-size:12px; border:1px solid var(--border); border-radius:999px;
144
+ padding:4px 10px; background:#061006a6;
145
+ }
146
+ .grid {
147
+ margin:22px auto; display:grid; gap:16px;
148
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
149
+ }
150
+ .feature .icon { font-size:22px; margin-bottom:4px; }
151
+ .feature .list { margin-top:6px; opacity:.9; font-size:14px; display:grid; gap:4px; }
152
+ .arch { margin-top:18px; }
153
+ .diagram { margin-top:8px; display:grid; gap:8px; }
154
+ .lane { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
155
+ .box {
156
+ padding:8px 10px; border-radius:10px; border:1px solid var(--border);
157
+ background:#020a04; font-family:"Share Tech Mono", monospace; font-size:13px;
158
+ }
159
+ .svc { box-shadow:0 0 0 1px rgba(0,255,156,.08); }
160
+ .ai { box-shadow:0 0 0 1px rgba(0,255,156,.12); }
161
+ .user{ box-shadow:0 0 0 1px rgba(0,255,156,.08); }
162
+ .arrow { opacity:.7; font-family:"Share Tech Mono", monospace; }
163
+ .quick { margin-top:8px; display:grid; gap:8px; }
164
+ details { border:1px solid var(--border); border-radius:12px; padding:8px 10px; background:#06100680; }
165
+ details summary { cursor:pointer; user-select:none; color:var(--muted); }
166
+ pre { margin:8px 0 0; }
167
+ </style>
168
+ {% endblock %}
app/templates/validator.html ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block body %}
3
+
4
+ <div class="card">
5
+ <h3>A2A Validator /// MATRIX NODE</h3>
6
+
7
+ <div class="fieldset">
8
+ <div class="fieldset-head" id="connection-header">ESTABLISH CONNECTION</div>
9
+ <div class="fieldset-body" style="display:grid; gap:12px; margin-top:12px;">
10
+ <input type="text" id="agent-card-url" placeholder="Enter Agent Card URL" />
11
+ <div class="fieldset inner">
12
+ <div class="fieldset-head http-headers-header" id="http-headers-toggle">
13
+ <span class="chev">►</span> HTTP HEADERS
14
+ </div>
15
+ <div class="fieldset-body http-headers-content" id="http-headers-content" style="display:none;">
16
+ <div id="headers-list"></div>
17
+ <button id="add-header-btn" type="button">+ Add Header</button>
18
+ </div>
19
+ </div>
20
+ <div class="actions-row">
21
+ <button id="connect-btn" type="button">Connect</button>
22
+ <a class="link" href="https://a2a-protocol.org/latest/" target="_blank" rel="noreferrer">Protocol Docs ↗</a>
23
+ </div>
24
+ </div>
25
+ </div>
26
+
27
+ <div id="agent-card-section" class="fieldset" style="margin-top:16px;">
28
+ <h2 class="collapsible-header">
29
+ <span class="toggle-icon">▼</span> AGENT CARD
30
+ </h2>
31
+ <div class="collapsible-content">
32
+ <div id="validation-errors">
33
+ <p class="muted">Awaiting connection to view agent card...</p>
34
+ </div>
35
+ <div class="agent-card-display">
36
+ <pre><code id="agent-card-content" class="language-json"></code></pre>
37
+ </div>
38
+ </div>
39
+ </div>
40
+
41
+ <div id="chat-container" class="fieldset" style="margin-top:16px;">
42
+ <div class="fieldset-head chat-header">CHAT TERMINAL</div>
43
+ <div class="fieldset-body">
44
+ <p class="muted chat-info">Agent messages marked with ✅ (compliant) or ⚠️ (non-compliant). Click to view raw JSON.</p>
45
+ <div id="chat-messages" class="chat-list">
46
+ <p class="muted placeholder-text">Messages will appear here.</p>
47
+ </div>
48
+ <div class="fieldset inner message-metadata-container">
49
+ <div class="fieldset-head message-metadata-header" id="message-metadata-toggle">
50
+ <span class="chev">►</span> MESSAGE METADATA
51
+ </div>
52
+ <div class="fieldset-body message-metadata-content" id="message-metadata-content" style="display:none;">
53
+ <div id="metadata-list"></div>
54
+ <button id="add-metadata-btn" type="button">+ Add Metadata</button>
55
+ </div>
56
+ </div>
57
+ <div class="row chat-input-container" style="margin-top:10px;">
58
+ <input type="text" id="chat-input" placeholder="> Type a message…" disabled />
59
+ <button id="send-btn" type="button" disabled>Send</button>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ </div>
64
+
65
+ <div id="loader" class="loader-overlay" aria-hidden="true" style="display:none;">
66
+ <div class="loader-wrap">
67
+ <div class="loader-spinner"></div>
68
+ <div class="loader-text">CONNECTING…</div>
69
+ </div>
70
+ </div>
71
+
72
+ <div id="debug-console" class="hidden">
73
+ <div id="debug-handle">
74
+ <span>Debug Console</span>
75
+ <div class="debug-controls">
76
+ <button id="clear-console-btn">Clear</button>
77
+ <button id="toggle-console-btn">Show</button>
78
+ </div>
79
+ </div>
80
+ <div id="debug-content"></div>
81
+ </div>
82
+
83
+ <div id="json-modal" class="modal-overlay hidden">
84
+ <div class="modal-content">
85
+ <span class="modal-close-btn">&times;</span>
86
+ <h3>Raw JSON</h3>
87
+ <pre id="modal-json-content"></pre>
88
+ </div>
89
+ </div>
90
+
91
+ <style>
92
+ :root {
93
+ --matrix: #00ff9c;
94
+ --border: rgba(0, 255, 156, 0.2);
95
+ --background: #020a04;
96
+ --dark-surface: #0a140a;
97
+ --muted-2: #9aa29a;
98
+ }
99
+ body {
100
+ font-family: "Share Tech Mono", monospace;
101
+ background-color: var(--background);
102
+ color: #e0e0e0;
103
+ margin: 0;
104
+ padding: 2rem;
105
+ }
106
+ body::before{
107
+ content:"";
108
+ position: fixed;
109
+ inset:0;
110
+ z-index:-1;
111
+ background:
112
+ radial-gradient(800px 500px at 50% -20%, rgba(0,255,156,0.08), transparent 40%),
113
+ linear-gradient(180deg, rgba(0,0,0,0.72), rgba(0,0,0,0.65));
114
+ }
115
+
116
+ .card { position: relative; z-index: 1; max-width: 800px; margin: 0 auto; }
117
+ h1, h2, h3, .fieldset-head { text-transform: uppercase; }
118
+ h1 { color: var(--matrix); }
119
+ h2 { font-size: 1em; margin: 0; }
120
+ .muted { color: var(--muted-2, #9aa29a); }
121
+ .row { display:flex; gap:8px; align-items: center; }
122
+ .row input[type="text"] { flex:1; }
123
+ .actions-row { display: flex; gap: 12px; align-items: center; }
124
+
125
+ .fieldset { border: 1px solid var(--border); border-radius: 12px; padding: 12px; background: rgba(2,10,4,0.35); backdrop-filter: blur(2px); }
126
+ .fieldset.inner { background: rgba(2,10,4,0.25); border-color: rgba(128, 128, 128, 0.3); }
127
+ .fieldset-head, .collapsible-header { font-weight: 600; letter-spacing: .02em; cursor: pointer; user-select:none; display:flex; align-items:center; gap:6px; color: var(--matrix); text-shadow: 0 0 5px var(--matrix); padding: 5px; }
128
+ .fieldset-body, .collapsible-content { padding-top: 4px; }
129
+ .fieldset-body pre { background: #020a04; border: 1px solid var(--border); border-radius: 8px; padding: 10px; margin: 8px 0 0 0; }
130
+ .chev, .toggle-icon { font-family: ui-monospace, "Share Tech Mono", monospace; }
131
+
132
+ input, button { font-family: inherit; }
133
+ input[type="text"] { background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: 8px; padding: 10px; color: #e0e0e0; }
134
+ button { background: transparent; border: 1px solid var(--matrix); color: var(--matrix); padding: 10px 15px; border-radius: 8px; cursor: pointer; transition: all 0.2s; }
135
+ button:hover:not(:disabled) { background: var(--matrix); color: var(--background); box-shadow: 0 0 10px var(--matrix); }
136
+ button:disabled { border-color: var(--muted-2); color: var(--muted-2); cursor: not-allowed; }
137
+
138
+ .chat-list { height: 300px; overflow-y: auto; background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: 8px; padding: 10px; }
139
+ .chat-info { font-size: 0.9em; }
140
+
141
+ /* Debug & Modal Styling (adapted for Matrix theme) */
142
+ #debug-console { position: fixed; bottom: 0; left: 0; right: 0; height: 200px; background: var(--dark-surface); border-top: 2px solid var(--matrix); display: flex; flex-direction: column; z-index: 100; }
143
+ #debug-console.hidden { display: none; }
144
+ #debug-handle { background: #000; padding: 5px 10px; cursor: ns-resize; display: flex; justify-content: space-between; align-items: center; text-transform: uppercase; letter-spacing: 0.1em; }
145
+ #debug-content { flex-grow: 1; overflow-y: auto; padding: 10px; font-size: 0.9em; }
146
+ .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); backdrop-filter: blur(5px); z-index: 1000; display: flex; align-items: center; justify-content: center; }
147
+ .modal-overlay.hidden { display: none; }
148
+ .modal-content { position: relative; background: var(--dark-surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px; max-width: 800px; width: 90%; max-height: 80vh; display: flex; flex-direction: column; }
149
+ .modal-close-btn { position: absolute; top: 10px; right: 15px; font-size: 24px; cursor: pointer; color: var(--matrix); }
150
+
151
+ /* Loader Styling (same as before) */
152
+ .loader-overlay { position: fixed; inset: 0; z-index: 9999; display: none; align-items: center; justify-content: center; backdrop-filter: blur(3px); background: radial-gradient(800px 500px at 50% -20%, rgba(0,255,156,0.08), transparent 40%), linear-gradient(180deg, rgba(0,0,0,0.72), rgba(0,0,0,0.65)); }
153
+ .loader-wrap { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 22px 26px; border-radius: 16px; border: 1px solid var(--border); background: rgba(6,16,6,0.75); box-shadow: 0 10px 40px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,255,156,0.06); }
154
+ .loader-spinner { width: 64px; height: 64px; border-radius: 50%; border: 3px solid rgba(0,255,156,0.15); border-top-color: var(--matrix); border-right-color: var(--matrix); box-shadow: 0 0 18px rgba(0,255,156,0.35); animation: spin 0.9s linear infinite; }
155
+ .loader-text { font-family: "Share Tech Mono", monospace; letter-spacing: 0.08em; color: var(--matrix); text-shadow: 0 0 8px rgba(0,255,156,0.35); opacity: 0.95; }
156
+ @keyframes spin { to { transform: rotate(360deg); } }
157
+ </style>
158
+
159
+ <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
160
+ <script src="/static/script.js"></script>
161
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
162
+
163
+ <script>
164
+ // Raining Code Effect
165
+ const canvas = document.getElementById('matrix-canvas');
166
+ if (canvas) {
167
+ const ctx = canvas.getContext('2d');
168
+ canvas.width = window.innerWidth;
169
+ canvas.height = window.innerHeight;
170
+ const alphabet = 'アァカサタナハマヤャラワガザダバパイィキシチニヒミリヰギジヂビピウゥクスツヌフムユュルグズブプエェケセテネヘメレヱゲゼデベペオォコソトノホモヨョロヲゴゾドボポヴッンABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
171
+ const fontSize = 16;
172
+ const columns = canvas.width / fontSize;
173
+ const rainDrops = Array.from({ length: columns }).fill(1);
174
+ const draw = () => {
175
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
176
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
177
+ ctx.fillStyle = '#0F0';
178
+ ctx.font = fontSize + 'px monospace';
179
+ for (let i = 0; i < rainDrops.length; i++) {
180
+ const text = alphabet.charAt(Math.floor(Math.random() * alphabet.length));
181
+ ctx.fillText(text, i * fontSize, rainDrops[i] * fontSize);
182
+ if (rainDrops[i] * fontSize > canvas.height && Math.random() > 0.975) {
183
+ rainDrops[i] = 0;
184
+ }
185
+ rainDrops[i]++;
186
+ }
187
+ };
188
+ setInterval(draw, 33);
189
+ }
190
+
191
+ // The main script.js will handle its own toggles now that the HTML is correct.
192
+ // We only need to add listeners for the fieldsets that script.js doesn't know about.
193
+ (function () {
194
+ function setupToggle(toggleEl, contentEl) {
195
+ if (!toggleEl || !contentEl) return;
196
+ toggleEl.addEventListener('click', () => {
197
+ const isHidden = contentEl.style.display === 'none';
198
+ contentEl.style.display = isHidden ? 'block' : 'none';
199
+ const chev = toggleEl.querySelector('.chev');
200
+ if (chev) chev.textContent = isHidden ? '▼' : '►';
201
+ });
202
+ }
203
+ // script.js handles '.collapsible-header', so we only set up our custom fieldsets.
204
+ setupToggle(document.getElementById('http-headers-toggle'), document.getElementById('http-headers-content'));
205
+ setupToggle(document.getElementById('message-metadata-toggle'), document.getElementById('message-metadata-content'));
206
+ })();
207
+ </script>
208
+ {% endblock %}
app/ui.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ui.py
2
+
3
+ from fastapi import APIRouter, Request, Form
4
+ from fastapi.responses import HTMLResponse, RedirectResponse
5
+ from fastapi.templating import Jinja2Templates
6
+ import httpx
7
+ import os
8
+ import json
9
+
10
+ router = APIRouter()
11
+ templates = Jinja2Templates(directory="app/templates")
12
+
13
+ # Tabs to render in the UI. "Info" is now the active tab for the /home route.
14
+ NAV_TABS = [
15
+ {"href": "/validator", "label": "Validator"},
16
+ {"href": "/home", "label": "Info"},
17
+ ]
18
+ templates.env.globals["NAV_TABS"] = NAV_TABS
19
+
20
+ def _self_base_url() -> str:
21
+ port = os.getenv("PORT", "7860")
22
+ return f"http://127.0.0.1:{port}"
23
+
24
+ @router.get("/", include_in_schema=False)
25
+ async def root_redirect():
26
+ # Default to the Validator page
27
+ return RedirectResponse(url="/validator", status_code=302)
28
+
29
+ @router.get("/home", response_class=HTMLResponse, include_in_schema=False)
30
+ async def home_page(request: Request):
31
+ """
32
+ FIX: This route now correctly serves the home.html template
33
+ instead of getting caught in a redirect loop.
34
+ """
35
+ return templates.TemplateResponse(
36
+ "home.html",
37
+ # Pass the active tab name to the template
38
+ {"request": request, "tabs": NAV_TABS, "active": "home"},
39
+ )
40
+
41
+ # The /chat and /dev routes are not needed based on your last request,
42
+ # so they have been removed to simplify the file.
43
+ # If you need them back, you can uncomment them.
44
+
45
+ # @router.get("/chat", response_class=HTMLResponse)
46
+ # async def chat_get(request: Request):
47
+ # return templates.TemplateResponse(
48
+ # "chat.html",
49
+ # {"request": request, "answer": None, "tabs": NAV_TABS, "active": "chat"},
50
+ # )
51
+
52
+ # @router.get("/dev", response_class=HTMLResponse)
53
+ # async def dev_get(request: Request):
54
+ # # ... dev page logic ...
55
+ # pass
app/validators.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/validators.py
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import Any, Iterable, Mapping, Sequence
6
+ from urllib.parse import urlparse
7
+
8
+
9
+ # -----------------------------
10
+ # Helpers
11
+ # -----------------------------
12
+
13
+ _SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.\-]+)?$")
14
+
15
+
16
+ def _is_non_empty_str(val: Any) -> bool:
17
+ return isinstance(val, str) and val.strip() != ""
18
+
19
+
20
+ def _is_list_of_str(val: Any) -> bool:
21
+ return isinstance(val, list) and all(isinstance(x, str) for x in val)
22
+
23
+
24
+ def _as_mapping(val: Any) -> Mapping[str, Any] | None:
25
+ return val if isinstance(val, Mapping) else None
26
+
27
+
28
+ def _as_sequence(val: Any) -> Sequence[Any] | None:
29
+ return val if isinstance(val, Sequence) and not isinstance(val, (str, bytes)) else None
30
+
31
+
32
+ # -----------------------------
33
+ # Agent Card Validation
34
+ # -----------------------------
35
+
36
+ _REQUIRED_AGENT_CARD_FIELDS = frozenset(
37
+ [
38
+ "name",
39
+ "description",
40
+ "url",
41
+ "version",
42
+ "capabilities",
43
+ "defaultInputModes",
44
+ "defaultOutputModes",
45
+ "skills",
46
+ ]
47
+ )
48
+
49
+
50
+ def validate_agent_card(card_data: dict[str, Any]) -> list[str]:
51
+ """
52
+ Validate the structure and fields of an agent card.
53
+
54
+ Contract (non-exhaustive, pragmatic checks):
55
+ - Required top-level fields must exist.
56
+ - url must be absolute (http/https) with a host.
57
+ - version should be semver-like (e.g., 1.2.3 or 1.2.3-alpha).
58
+ - capabilities must be an object/dict.
59
+ - defaultInputModes/defaultOutputModes must be non-empty arrays of strings.
60
+ - skills must be a non-empty array (objects or strings permitted); if objects, "name" should be string.
61
+
62
+ Returns:
63
+ A list of human-readable error strings. Empty list means "looks valid".
64
+ """
65
+ errors: list[str] = []
66
+ data = card_data or {}
67
+
68
+ # Presence of required fields
69
+ for field in _REQUIRED_AGENT_CARD_FIELDS:
70
+ if field not in data:
71
+ errors.append(f"Required field is missing: '{field}'.")
72
+
73
+ # Type/format checks (guard with `in` to avoid KeyErrors)
74
+ # name
75
+ if "name" in data and not _is_non_empty_str(data["name"]):
76
+ errors.append("Field 'name' must be a non-empty string.")
77
+
78
+ # description
79
+ if "description" in data and not _is_non_empty_str(data["description"]):
80
+ errors.append("Field 'description' must be a non-empty string.")
81
+
82
+ # url
83
+ if "url" in data:
84
+ url_val = data["url"]
85
+ if not _is_non_empty_str(url_val):
86
+ errors.append("Field 'url' must be a non-empty string.")
87
+ else:
88
+ parsed = urlparse(url_val)
89
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
90
+ errors.append(
91
+ "Field 'url' must be an absolute URL with http(s) scheme and host."
92
+ )
93
+
94
+ # version (soft semver check; adjust if your ecosystem allows non-semver)
95
+ if "version" in data:
96
+ ver = data["version"]
97
+ if not _is_non_empty_str(ver):
98
+ errors.append("Field 'version' must be a non-empty string.")
99
+ elif not _SEMVER_RE.match(ver):
100
+ errors.append(
101
+ "Field 'version' should be semver-like (e.g., '1.2.3' or '1.2.3-alpha')."
102
+ )
103
+
104
+ # capabilities
105
+ if "capabilities" in data:
106
+ if not isinstance(data["capabilities"], dict):
107
+ errors.append("Field 'capabilities' must be an object.")
108
+ else:
109
+ # Optional: sanity checks for common capability fields
110
+ caps = data["capabilities"]
111
+ if "streaming" in caps and not isinstance(caps["streaming"], bool):
112
+ errors.append("Field 'capabilities.streaming' must be a boolean if present.")
113
+
114
+ # defaultInputModes / defaultOutputModes
115
+ for field in ("defaultInputModes", "defaultOutputModes"):
116
+ if field in data:
117
+ modes = data[field]
118
+ if not _is_list_of_str(modes):
119
+ errors.append(f"Field '{field}' must be an array of strings.")
120
+ elif len(modes) == 0:
121
+ errors.append(f"Field '{field}' must not be empty.")
122
+
123
+ # skills
124
+ if "skills" in data:
125
+ skills = _as_sequence(data["skills"])
126
+ if skills is None:
127
+ errors.append("Field 'skills' must be an array.")
128
+ elif len(skills) == 0:
129
+ errors.append(
130
+ "Field 'skills' must not be empty. Agent must have at least one skill if it performs actions."
131
+ )
132
+ else:
133
+ # If entries are objects, check they have a name
134
+ for i, s in enumerate(skills):
135
+ if isinstance(s, Mapping):
136
+ if not _is_non_empty_str(s.get("name")):
137
+ errors.append(f"skills[{i}].name is required and must be a non-empty string.")
138
+ elif not isinstance(s, str):
139
+ errors.append(
140
+ f"skills[{i}] must be either an object with 'name' or a string; found: {type(s).__name__}"
141
+ )
142
+
143
+ return errors
144
+
145
+
146
+ # -----------------------------
147
+ # Agent Message/Event Validation
148
+ # -----------------------------
149
+
150
+ def _validate_task(data: dict[str, Any]) -> list[str]:
151
+ errors: list[str] = []
152
+ if "id" not in data:
153
+ errors.append("Task object missing required field: 'id'.")
154
+ status = _as_mapping(data.get("status"))
155
+ if status is None or "state" not in status:
156
+ errors.append("Task object missing required field: 'status.state'.")
157
+ return errors
158
+
159
+
160
+ def _validate_status_update(data: dict[str, Any]) -> list[str]:
161
+ errors: list[str] = []
162
+ status = _as_mapping(data.get("status"))
163
+ if status is None or "state" not in status:
164
+ errors.append("StatusUpdate object missing required field: 'status.state'.")
165
+ return errors
166
+
167
+
168
+ def _validate_artifact_update(data: dict[str, Any]) -> list[str]:
169
+ errors: list[str] = []
170
+ artifact = _as_mapping(data.get("artifact"))
171
+ if artifact is None:
172
+ errors.append("ArtifactUpdate object missing required field: 'artifact'.")
173
+ return errors
174
+
175
+ parts = artifact.get("parts")
176
+ if not isinstance(parts, list) or len(parts) == 0:
177
+ errors.append("Artifact object must have a non-empty 'parts' array.")
178
+ return errors
179
+
180
+
181
+ def _validate_message(data: dict[str, Any]) -> list[str]:
182
+ errors: list[str] = []
183
+ parts = data.get("parts")
184
+ if not isinstance(parts, list) or len(parts) == 0:
185
+ errors.append("Message object must have a non-empty 'parts' array.")
186
+ role = data.get("role")
187
+ if role != "agent":
188
+ errors.append("Message from agent must have 'role' set to 'agent'.")
189
+ # Optional: check text presence in at least one part if parts are objects
190
+ # (Leave relaxed to avoid false negatives if parts are other media-types)
191
+ return errors
192
+
193
+
194
+ _KIND_VALIDATORS: dict[str, callable[[dict[str, Any]], list[str]]] = {
195
+ "task": _validate_task,
196
+ "status-update": _validate_status_update,
197
+ "artifact-update": _validate_artifact_update,
198
+ "message": _validate_message,
199
+ }
200
+
201
+
202
+ def validate_message(data: dict[str, Any]) -> list[str]:
203
+ """
204
+ Validate an incoming event/message coming from the agent according to its 'kind'.
205
+
206
+ Expected kinds: 'task', 'status-update', 'artifact-update', 'message'
207
+ Returns:
208
+ A list of human-readable error strings. Empty list means "looks valid".
209
+ """
210
+ if not isinstance(data, Mapping):
211
+ return ["Response from agent must be an object."]
212
+ if "kind" not in data:
213
+ return ["Response from agent is missing required 'kind' field."]
214
+
215
+ kind = str(data.get("kind"))
216
+ validator = _KIND_VALIDATORS.get(kind)
217
+ if validator:
218
+ return validator(dict(data))
219
+
220
+ return [f"Unknown message kind received: '{kind}'."]
221
+
222
+
223
+ __all__ = [
224
+ "validate_agent_card",
225
+ "validate_message",
226
+ ]
assets/2025-10-05-00-49-00.png ADDED

Git LFS Details

  • SHA256: 4a8a4729ba380b1f0c777bd6fe9bba681732fa84aa71565a495c09723a17ba34
  • Pointer size: 131 Bytes
  • Size of remote file: 578 kB
configs/.env.example ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # === API Keys (DO NOT COMMIT REAL KEYS) ===
2
+ GROQ_API_KEY=your_groq_key_here
3
+ GOOGLE_API_KEY=your_google_gemini_key_here
4
+ HF_TOKEN=your_huggingface_token_here
5
+
6
+ # === Provider order ===
7
+ # Comma-separated cascade, first working provider wins.
8
+ # Options: groq, gemini, router
9
+ PROVIDER_ORDER=groq,gemini,router
10
+
11
+ # === Provider-default models (override if needed) ===
12
+ GROQ_MODEL=llama-3.1-8b-instant
13
+ GEMINI_MODEL=gemini-2.5-flash
14
+
15
+ # === Logging ===
16
+ LOG_LEVEL=INFO
17
+
18
+
19
+
20
+ # For local development only. Use Space Secrets in production.
21
+ ADMIN_TOKEN="a-secure-admin-token-for-index-refresh"
22
+
23
+ # --- Optional Overrides ---
24
+ # MODEL_NAME="mistralai/Mistral-7B-Instruct-v0.2"
25
+ # INDEX_DATASET="your-username/matrix-ai-index"
26
+ # RATE_LIMITS="120" # requests per minute
configs/rag_sources.yaml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Where to pull documentation from when building the RAG knowledge base.
2
+ # You can add/remove repos here; the builder will respect these sources.
3
+
4
+ github:
5
+ # 1) Explicit repos (stable)
6
+ repos:
7
+ - owner: agent-matrix
8
+ name: matrix-cli
9
+ branch: master
10
+ docs_paths: ["docs"] # folders to harvest (recursive)
11
+ include_readme: true
12
+ - owner: agent-matrix
13
+ name: matrix-python-sdk
14
+ branch: master
15
+ docs_paths: ["docs"]
16
+ include_readme: true
17
+ - owner: agent-matrix
18
+ name: matrixlink
19
+ branch: master
20
+ docs_paths: ["docs"]
21
+ include_readme: true
22
+ - owner: agent-matrix
23
+ name: matrix-hub
24
+ branch: master
25
+ docs_paths: ["docs"]
26
+ include_readme: true
27
+
28
+ # 2) Optionally scan an entire org for repos (README + docs/ if present)
29
+ # Comment out if you want only the explicit list above.
30
+ orgs:
31
+ - agent-matrix
32
+
33
+ # Local content in THIS repo (optional but recommended)
34
+ local:
35
+ paths:
36
+ - docs # everything under /docs
37
+ - README.md # root readme
38
+ glob: "**/*.md" # or "**/*.{md,mdx,txt}"
39
+
40
+ # Extra public URLs to pull (optional)
41
+ urls: []
configs/settings.yaml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model:
2
+ # HF router defaults (used at the last step)
3
+ name: "HuggingFaceH4/zephyr-7b-beta"
4
+ fallback: "mistralai/Mistral-7B-Instruct-v0.2"
5
+ provider: "featherless-ai"
6
+ max_new_tokens: 256
7
+ temperature: 0.2
8
+
9
+ # Provider-specific defaults (free-tier friendly)
10
+ groq_model: "llama-3.1-8b-instant"
11
+ gemini_model: "gemini-2.5-flash"
12
+
13
+ # Try providers in this order
14
+ provider_order:
15
+ - groq
16
+ - gemini
17
+ - router
18
+
19
+ # Switch to the multi-provider path
20
+ chat_backend: "multi"
21
+ chat_stream: true
22
+
23
+ limits:
24
+ rate_per_min: 60
25
+ cache_size: 256
26
+
27
+ rag:
28
+ index_dataset: ""
29
+ top_k: 4
30
+
31
+ matrixhub:
32
+ base_url: "https://api.matrixhub.io"
33
+
34
+ security:
35
+ admin_token: ""
pyproject.toml ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "a2a-validator"
7
+ version = "0.1.0"
8
+ description = "Agent validator Service for Matrix EcoSystem"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "Apache-2.0" }
12
+
13
+ dependencies = [
14
+ "fastapi==0.111.0",
15
+ "groq>=0.32.0",
16
+ "uvicorn[standard]==0.29.0",
17
+ "httpx==0.28.1",
18
+ "pydantic>=2.7.1",
19
+ "python-json-logger==2.0.7",
20
+ "cachetools==5.3.3",
21
+ "huggingface-hub==0.23.0",
22
+ "sentence-transformers==2.7.0",
23
+ "faiss-cpu==1.8.0",
24
+ "numpy==1.26.4",
25
+ "orjson==3.10.3",
26
+ "pyyaml==6.0.1",
27
+ "tenacity==8.2.3",
28
+ "python-dotenv==1.0.1",
29
+ "google-genai>=1.39.1",
30
+
31
+ # --- Added for a2a-validator ---
32
+ "a2a-sdk[http-server]>=0.3.0",
33
+ "httpx-sse>=0.4.0",
34
+ "jwcrypto>=1.5.6",
35
+ "pyjwt>=2.10.1",
36
+ "sse-starlette>=2.2.1",
37
+ "typing-extensions>=4.12.2",
38
+ # FIX: Ensure all standard websocket dependencies are included
39
+ "python-socketio[asyncio_standard]>=5.11.0",
40
+ "jinja2>=3.1.2",
41
+ "bleach>=6.2.0"
42
+ ]
43
+
44
+ [tool.ruff]
45
+ line-length = 100
46
+ target-version = "py311"
47
+
48
+ [tool.ruff.lint]
49
+ select = ["E", "F", "W", "I", "UP", "B", "SIM"]
50
+ ignore = ["E501"]
requirements.txt ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn[standard]==0.29.0
3
+ httpx>=0.28.1
4
+ pydantic>=2.7.1
5
+ python-json-logger==2.0.7
6
+ cachetools==5.3.3
7
+ huggingface-hub==0.23.0
8
+ #sentence-transformers==2.7.0
9
+ #faiss-cpu==1.8.0
10
+ numpy==1.26.4
11
+ orjson==3.10.3
12
+ pyyaml==6.0.1
13
+ tenacity==8.2.3
14
+ jinja2==3.1.4
15
+ a2a-sdk[http-server]>=0.3.0
16
+ python-socketio[asyncio_standard]>=5.11.0
17
+ bleach>=6.2.0
18
+ jinja2>=3.1.2
19
+
20
+ # Dev (optional)
21
+ pytest
22
+ ruff
23
+ mypy
24
+ pytest-asyncio
25
+
26
+ # Additional libraries for extended functionality
27
+ #groq>=0.32.0
28
+ python-dotenv==1.0.1
29
+ #google-genai>=1.39.1
30
+
31
+ requests>=2.32.0
32
+ #beautifulsoup4>=4.12.3 # only used if you later add generic HTML URLs
33
+ PyYAML>=6.0.1