-
Notifications
You must be signed in to change notification settings - Fork 39
Feat: AIAC Phase 1 runnable UC-1 onboarding demo walkthrough #725
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e080bfe
bf0f48e
813848b
f0128cd
de1d983
fd53e93
3148e50
a5f91b8
0bdcce2
c1a1d53
1b17ad5
ef7304b
88603a1
6e9b4db
f9d5c69
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| # Installing the demo assets (`github-tool` + `github-agent`) | ||
|
|
||
| Single install guide for both reusable demo workloads under `demo/assets/`, deployed to a | ||
| Kagenti/Kind cluster in namespace `team1`. Consolidates what used to be split across | ||
| [`docs/specs/demo/github-tool.md`](../../docs/specs/demo/github-tool.md) §7–8 and the agent | ||
| README's former "Deploying to Kagenti" section, so the two installation paths can't drift apart | ||
| again. Prefer [`install.sh`](install.sh) over doing this by hand; the steps below are what it | ||
| automates. | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| - A running Kagenti/Kind cluster with the kagenti-operator installed. | ||
| - Namespace **`team1`** already exists — created by the Rossoctl/Kagenti installer, not by | ||
| anything in this repo. Nothing here creates cluster-owned resources. | ||
| - `kubectl`, `kind`, and a container runtime (`docker` or `podman`) on `PATH`. | ||
|
|
||
| ## What gets deployed | ||
|
|
||
| | Workload | Image | Manifests, in order | Objects created (ns `team1`) | | ||
| |---|---|---|---| | ||
| | tool | `localhost/github-tool:latest` | `tools/github_tool/k8s/github-tool-deployment.yaml` | ServiceAccount + Deployment + Service `github-tool` (`:9090`) + `AgentRuntime` `github-tool` (`type: tool`) | | ||
| | agent | `localhost/github-agent:latest` | `agents/github_agent/k8s/configmaps.yaml`, **then** `agents/github_agent/k8s/github-agent-deployment.yaml` | ServiceAccount + Deployment + Service `github-agent` (`:8001`, `:8080`) + `AgentRuntime` `github-agent` (`type: agent`); the ConfigMaps are `authbridge-config` + `authproxy-routes` | | ||
|
|
||
| Per workload, in order: build the image → `kind load docker-image <image> --name <cluster>` → | ||
| apply the manifests → `kubectl rollout status`. | ||
|
|
||
| ## Non-obvious invariants | ||
|
|
||
| These each fail far from their cause — read before editing the manifests or install path. | ||
|
|
||
| - **The tool `Service` must carry `protocol.kagenti.io/mcp: "true"`.** This is a *deploy-time* | ||
| label that the kagenti operator does **not** add. Without it, UC-1's `analyze_tool` returns | ||
| **502** — during onboarding, long after deployment looked fine. It is already present in the | ||
| committed manifest; don't drop it when editing. | ||
| - **`kagenti.io/type` is applied by the operator** from the `AgentRuntime` CR. Don't hand-set it | ||
| on the pod. | ||
| - **The tool's declared `PORT` must not be `9090`.** The AuthBridge sidecar reuses the declared | ||
| `PORT` as its own reverse-proxy listener and shifts the app's real listen port to `PORT+1`. | ||
| AuthBridge also has a *fixed* health-check listener hardcoded to `9091`. `PORT: 9090` shifts the | ||
| app to `9091`, colliding with that fixed listener — the container crash-loops fighting the | ||
| sidecar for the port. The manifest uses `PORT: 9095` (shifted: `9096`), which clears every | ||
| AuthBridge-fixed port (`8080`, `8081`, `9091`, `9093`, `9094`); the Service's external port stays | ||
| `9090`. | ||
| - **Keycloak client registration is asynchronous.** The operator registers each workload as a | ||
| client named `team1/<workload>` *after* the pods come up, so "rollout complete" is **not** | ||
| "ready to onboard". Anything that needs the client must poll for it (the UC-1 onboarding demo | ||
| does). | ||
| - **`github-tool-mcp` is not required for this install.** The agent README names the production | ||
| 44-tool `github-tool-mcp` server (deployed from `authbridge/demos/github-issue/`) as its | ||
| `MCP_URL` target, and that is a different thing from `tools/github_tool/` (a 4-tool stub used | ||
| only for UC-1 onboarding discovery). The MCP connection happens **per request inside | ||
| `GithubExecutor.execute()`** (`a2a_agent.py:188-211`), not at startup — `run()` only builds the | ||
| AgentCard and serves it. So the agent pod becomes ready and serves | ||
| `/.well-known/agent-card.json` with no MCP server present, which is all UC-1 discovery needs. | ||
| **Do not add `github-tool-mcp` to this install path.** | ||
| - **Namespace `team1` is a precondition, not an output.** No manifest here creates it; the | ||
| Rossoctl/Kagenti installer owns it (and any labels it carries). `install.sh` fails fast with a | ||
| pointer to the installer if the namespace is missing. | ||
|
|
||
| ## Manual steps (what `install.sh` automates) | ||
|
|
||
| **Tool:** | ||
| ```bash | ||
| cd tools/github_tool | ||
| podman build -t localhost/github-tool:latest . # or docker; the localhost/ prefix must match the manifest's image ref | ||
| kind load docker-image localhost/github-tool:latest --name kagenti | ||
| kubectl apply -f k8s/github-tool-deployment.yaml | ||
| kubectl rollout status deployment/github-tool -n team1 | ||
| ``` | ||
|
|
||
| **Agent:** | ||
| ```bash | ||
| cd agents/github_agent | ||
| podman build -t localhost/github-agent:latest . # or docker; the localhost/ prefix must match the manifest's image ref | ||
| kind load docker-image localhost/github-agent:latest --name kagenti | ||
| kubectl apply -f k8s/configmaps.yaml | ||
| kubectl apply -f k8s/github-agent-deployment.yaml | ||
| kubectl rollout status deployment/github-agent -n team1 | ||
| ``` | ||
|
|
||
| ## Verifying | ||
|
|
||
| ```bash | ||
| # Tool got its MCP label and the operator stamped kagenti.io/type | ||
| kubectl get svc github-tool -n team1 -o jsonpath='{.metadata.labels.protocol\.kagenti\.io/mcp}' | ||
| kubectl get pod -l app=github-tool -n team1 -o jsonpath='{.items[0].metadata.labels.kagenti\.io/type}' | ||
|
|
||
| # Agent serves its card with no MCP server present | ||
| kubectl port-forward svc/github-agent 8080:8080 -n team1 & | ||
| curl -s http://localhost:8080/.well-known/agent-card.json | python3 -m json.tool | ||
| ``` | ||
|
|
||
| ## Out of scope | ||
|
|
||
| - Deploying `github-tool-mcp`, Keycloak, SPIRE, the kagenti operator, or the cluster itself. | ||
| - Deploying the AIAC stack (`k8s/`) — see `k8s/aiac-deployment-guide.md`. | ||
| - Waiting for Keycloak client registration — that needs Keycloak credentials this install path | ||
| has no business holding; it belongs to whatever use-case demo consumes the client (e.g. UC-1's | ||
| `00-prereqs.py`). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| """ | ||
|
|
||
| import asyncio | ||
| import concurrent.futures | ||
| import logging | ||
| import os | ||
| import sys | ||
|
|
@@ -196,19 +197,24 @@ async def execute(self, context: RequestContext, event_queue: EventQueue): | |
| "headers": headers, | ||
| } | ||
| adapter = MCPServerAdapter(server_params, connect_timeout=settings.MCP_TIMEOUT) | ||
| # MCPServerAdapter.__enter__/__exit__ perform blocking MCP I/O; | ||
| # run them off the event loop so we don't stall other async tasks. | ||
| mcp_tools = await asyncio.to_thread(adapter.__enter__) | ||
| try: | ||
| curated_tools = select_enabled_tools(mcp_tools, settings) | ||
| if not curated_tools: | ||
| raise RuntimeError( | ||
| "No enabled tools found from the GitHub MCP server. " | ||
| "Check the ENABLED_TOOLS setting and ensure the server is reachable." | ||
| ) | ||
| await self._run_agent(messages, settings, event_emitter, curated_tools) | ||
| finally: | ||
| await asyncio.to_thread(adapter.__exit__, None, None, None) | ||
| # MCPServerAdapter.__enter__/__exit__ perform blocking MCP I/O; run them off the | ||
| # event loop so we don't stall other async tasks. They MUST run on the SAME thread | ||
| # (the adapter binds MCP session state to the entering thread), so use a dedicated | ||
| # single-worker executor rather than asyncio.to_thread's shared pool, which could | ||
| # otherwise dispatch __enter__ and __exit__ to different workers. | ||
| loop = asyncio.get_running_loop() | ||
| with concurrent.futures.ThreadPoolExecutor(max_workers=1) as mcp_exec: | ||
| mcp_tools = await loop.run_in_executor(mcp_exec, adapter.__enter__) | ||
|
Comment on lines
+206
to
+207
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
cd aiac
.venv/bin/python - <<'PY'
import asyncio
import time
from concurrent.futures import ThreadPoolExecutor
async def blocked_scope():
loop = asyncio.get_running_loop()
with ThreadPoolExecutor(max_workers=1) as executor:
await loop.run_in_executor(executor, time.sleep, 2)
async def main():
task = asyncio.create_task(blocked_scope())
await asyncio.sleep(0.1)
started = time.monotonic()
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
elapsed = time.monotonic() - started
print(f"Cancellation completion time: {elapsed:.2f}s")
assert elapsed >= 1.5, "Executor shutdown did not wait for the running worker"
asyncio.run(main())
PYRepository: rossoctl/cortex Length of output: 215 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
echo "Repository files around target:"
git ls-files | rg '(^|/)a2a_agent\.py$|(^|/)README\.md$|(^|/)pyproject\.toml$|(^|/)requirements|(^|/)poetry.lock$' || true
if [ -f aiac/demo/assets/agents/github_agent/a2a_agent.py ]; then
echo
echo "Target outline:"
ast-grep outline aiac/demo/assets/agents/github_agent/a2a_agent.py --view condensed || true
echo
echo "Target lines 180-230:"
sed -n '180,230p' aiac/demo/assets/agents/github_agent/a2a_agent.py | cat -n
fi
echo
echo "Python availability:"
for p in python python3 .venv/bin/python /usr/bin/python3; do
if command -v "$p" >/dev/null 2>&1; then
"$p" -V
fi
doneRepository: rossoctl/cortex Length of output: 4777 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
python3 - <<'PY'
import asyncio
import concurrent.futures
import inspect
import sys
print("Python", sys.version)
print("ThreadPoolExecutor shutdown signature:")
print(inspect.signature(concurrent.futures.ThreadPoolExecutor.shutdown))
print("ThreadPoolExecutor exit signature:")
try:
method = concurrent.futures.ThreadPoolExecutor.__exit__
except Exception as e:
print("Error:", type(e).__name__, e)
method = None
if method:
print(inspect.signature(method))
# Programmatic call-site check based on source strings:
# avoid importing/converting potentially repository code.
src = open("aiac/demo/assets/agents/github_agent/a2a_agent.py", encoding="utf-8").read()
checks = {
"uses same executor for __enter__ and __exit__": "mcp_exec" in src[src.index("adapter = MCPServerAdapter"):] and
"adapter.__enter__" in src[src.index("adapter = MCPServerAdapter"):] and
"adapter.__exit__" in src[src.index("adapter = MCPServerAdapter"):] and
"ThreadPoolExecutor(max_workers=1)" in src[src.index("adapter = MCPServerAdapter"):],
"executor is context-managed inside coroutine": "with concurrent.futures.ThreadPoolExecutor(max_workers=1) as mcp_exec:" in src,
}
for name, ok in checks.items():
print(f"{name}: {ok}")
PYRepository: rossoctl/cortex Length of output: 493 🌐 Web query:
💡 Result: In Python, calling.cancel on the asyncio.Future returned by loop.run_in_executor does not stop or terminate the underlying task executing in the ThreadPoolExecutor [1][2][3]. The cancellation is superficial: it only notifies the asyncio event loop that you are no longer interested in the result [3]. The task in the worker thread will continue to execute to completion [1][2][4]. Python threads cannot be safely interrupted or killed once they have started running [4][3]. To effectively stop a long-running blocking operation in a ThreadPoolExecutor, you must design your code to cooperate with a signaling mechanism [1][4][3]. Common patterns include: 1. Using a threading.Event: Pass a threading.Event object to your blocking function and periodically check it (e.g., if event.is_set: return). When you want to cancel, set the event [1][3]. 2. Cooperative cancellation: For longer tasks, structure them as a series of smaller, interruptible steps that check for a stop signal frequently [4]. Regarding ThreadPoolExecutor shutdown: - When you use a context manager (with ThreadPoolExecutor as executor:), the executor implicitly calls.shutdown(wait=True) upon exiting the block [5][6]. This causes the main thread to wait until all pending and currently running tasks in that executor are completed before continuing [4][5]. - If you cancel an asyncio task that is waiting on a result from an executor, the asyncio part of the task handles the cancellation, but the executor's worker thread is unaware and will keep running [2][4]. - To shut down an executor while tasks are still running, you may need to call executor.shutdown(wait=True) (or wait=False if you do not want to block) manually [5][6]. Note that shutdown with wait=True will block the calling thread until the executor finishes its tasks [5][6]. Shutdown with wait=False will allow the program to continue, but ensure resources are cleaned up later [5]. - Loop.shutdown_default_executor can be used to manage the cleanup of the default executor explicitly [7][8]. In summary, because standard Python threads cannot be forcefully terminated, cancellation must be implemented cooperatively within the function being executed [1][4]. Citations:
Do not rely on cancellation to stop MCP setup work.
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| try: | ||
| curated_tools = select_enabled_tools(mcp_tools, settings) | ||
| if not curated_tools: | ||
| raise RuntimeError( | ||
| "No enabled tools found from the GitHub MCP server. " | ||
| "Check the ENABLED_TOOLS setting and ensure the server is reachable." | ||
| ) | ||
| await self._run_agent(messages, settings, event_emitter, curated_tools) | ||
| finally: | ||
| await loop.run_in_executor(mcp_exec, adapter.__exit__, None, None, None) | ||
| else: | ||
| await self._run_agent(messages, settings, event_emitter, None) | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.