Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions aiac/.gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# AIAC working artefacts (regenerated; not source of truth)
docs/issues/
docs/handoffs/
docs/gh-issues/

# Matt Pocock engineering-skill config — local per-clone (set up via
# /setup-matt-pocock-skills); the skills read these, they are not repo source.
docs/agents/

# PDP policy writer integration-test scratch output (test/pdp/policy/generate_rego.py)
test/pdp/policy/rego_out/
Expand All @@ -10,3 +12,6 @@ test/pdp/policy/rego_out/
# regenerated per run, left on disk for eyeballing — not source of truth. probe.rego is a
# committed fixture and is not under rego_out/, so it stays tracked.
test/integration/rego_out/

# uc1-onboarding demo's captured .rego snapshots — regenerated per run, not source of truth.
demo/use-cases/uc1-onboarding/generated/
41 changes: 37 additions & 4 deletions aiac/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,30 @@ When a document under `docs/specs/` contains a markdown link to another file, us

## Issue tracking

Issues are tracked as local markdown files under `docs/issues/`, not on GitHub.
Never use `gh` commands to create, update, or list issues — always read/write the local files directly.
Issues are tracked as **GitHub issues** on the `s-and-p-team/cortex` repo,
organized in the org-level **AIAC** Project (Projects v2):
<https://github.com/orgs/s-and-p-team/projects/1>. Use `gh` to read and manage
them.

Note: `s-and-p-team/cortex` is the AIAC team's fork of the canonical upstream
`rossoctl/cortex`. Tracking issues on the fork (not upstream) is deliberate —
PRs still target upstream, but issue tracking stays on the team fork, so the
`-R s-and-p-team/cortex` scoping below is intentional.

Hierarchy: the Project groups **Feature**-typed container issues — one per
component area, nested via GitHub **native sub-issues** to form the tree — over
**Task**-typed leaf issues. Every issue carries the `aiac` label plus cumulative
`area:<path>` labels; open issues also carry a `status:<value>` label, and the
Project's built-in **Status** field mirrors that value.

`docs/issues/implementation-plan.md` — overall implementation plan.
For current issue list, `ls` the subdirectories under `docs/issues/`.
```bash
# list / view (filter to the AIAC set)
gh issue list -R s-and-p-team/cortex --label aiac --state all
gh issue view <number> -R s-and-p-team/cortex
```

Filtered web list:
<https://github.com/s-and-p-team/cortex/issues?q=is%3Aissue+label%3Aaiac>

## Issue tracking — codebase inspection policy

Expand Down Expand Up @@ -178,3 +197,17 @@ demo workloads, which don't) and CPU/memory requests + limits.
## External references

- [Kagenti Developer Guide](https://github.com/kagenti/kagenti/blob/main/docs/dev-guide.md) — upstream Kagenti dev guide: per-persona workflows (agent, tool, extensions developers, MCP gateway operators), Git/PR process, pre-commit hooks, feature flags, local Kagenti UI v2 development (React frontend + FastAPI backend, building/deploying images to Kubernetes), and HyperShift-based testing on ephemeral OpenShift clusters (cluster lifecycle, cost management, troubleshooting).

## Agent skills

### Issue tracker

GitHub issues on `s-and-p-team/cortex`, filtered by the `aiac` label, tracked in the org-level AIAC Project. See `docs/agents/issue-tracker.md`.

### Triage labels

Issue status is tracked with `status:<value>` labels; the board's built-in **Status** field carries the same values. See `docs/agents/triage-labels.md`.

### Domain docs

Single-context, scoped to `aiac/` (`CONTEXT.md` + `docs/adr/` at the `aiac/` root). See `docs/agents/domain.md`.
99 changes: 99 additions & 0 deletions aiac/demo/assets/INSTALL.md
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.**
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- **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
Expand Up @@ -20,8 +20,8 @@ Deploy it before starting the agent:
authbridge/demos/github-issue/k8s/github-tool-deployment.yaml
```

> **Not the same as `demo/tools/github_tool/`.**
> `demo/tools/github_tool/` is a simplified 4-tool stub (`source-read`, `source-write`, `issues-read`,
> **Not the same as `aiac/demo/assets/tools/github_tool/`.**
> `aiac/demo/assets/tools/github_tool/` is a simplified 4-tool stub (`source-read`, `source-write`, `issues-read`,
> `issues-write`) deployed as Service `github-tool` for **UC-1 onboarding discovery** only.
> The agent never connects to it — it connects to the production `github-tool-mcp` server which
> exposes the 44-tool GitHub API federation.
Expand Down Expand Up @@ -58,7 +58,7 @@ All settings are read from environment variables (or a `.env` file). Copy one of
## Running locally

```bash
cd aiac/demo/agents/github_agent
cd aiac/demo/assets/agents/github_agent
cp .env.ollama .env # or another preset
uv sync
uv run server
Expand All @@ -70,38 +70,8 @@ Optionally, run `expect -f test_startup.exp` instead to check startup automatica

## Deploying to Kagenti (Kind cluster)

Prerequisites: a running Kagenti cluster (Keycloak realm `kagenti`, namespace `team1`) with `github-tool-mcp` already deployed.

1. **Build the image:**
```bash
cd aiac/demo/agents/github_agent
podman build -t github-agent:latest .
# or: docker build -t github-agent:latest .
```

2. **Load into the Kind cluster:**
```bash
kind load docker-image github-agent:latest --name kagenti
```

3. **Apply manifests:**
```bash
kubectl apply -f k8s/configmaps.yaml
kubectl apply -f k8s/github-agent-deployment.yaml
```

4. **Confirm AuthBridge injection:**
```bash
kubectl get pod -n team1 -l app.kubernetes.io/name=github-agent -o jsonpath='{.items[0].spec.containers[*].name}'
```
You should see the `authbridge-proxy` (or `envoy-proxy`) sidecar alongside `agent`.

5. **Port-forward and send a message:**
```bash
kubectl port-forward svc/github-agent 8080:8080 -n team1 &
# Send an A2A message/send request:
curl -s http://localhost:8080/.well-known/agent-card.json | python3 -m json.tool
```
See [`../../INSTALL.md`](../../INSTALL.md) — the single install guide for this agent and the
`github_tool` stub together (build, `kind load`, manifests, invariants, verification).

## Architecture

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import asyncio
import concurrent.futures
import logging
import os
import sys
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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())
PY

Repository: 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
done

Repository: 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}")
PY

Repository: rossoctl/cortex

Length of output: 493


🌐 Web query:

Python asyncio loop.run_in_executor cancellation run_in_executor shutdown ThreadPoolExecutor wait True

💡 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.

loop.run_in_executor(..., adapter.__enter__) cannot be stopped by task.cancel() when adapter.__enter__ is already scheduled on the thread pool. The worker may still perform MCP connection I/O after the coroutine is cancelled, blocking the same-thread __exit__ cleanup or leaving the connection in a pending state. Add cooperative cancellation for blocked work, or move the whole lifecycle to an explicit process/thread pool where cleanup is guaranteed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@aiac/demo/assets/agents/github_agent/a2a_agent.py` around lines 206 - 207,
Update the MCP setup lifecycle around adapter.__enter__ and its
ThreadPoolExecutor so coroutine cancellation cannot leave setup I/O running
without cleanup. Add cooperative cancellation for the blocked worker, or move
both __enter__ and __exit__ into an explicitly managed process/thread execution
context that guarantees cleanup before cancellation completes.

Source: 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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,7 @@ async def execute(self, user_input):
"numbers": prereq.numbers or [],
}
)
return self.agents.github_query_task.output.raw
output = self.agents.github_query_task.output
if output is None or output.raw is None:
return "The agent produced no output for this request."
return output.raw
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
- "List branches of owner/repo" → list_branches
- "Open issues in kagenti/agent-examples" → list_issues
- "Find issues mentioning timeout across all repos" → search_issues
- "Sub-issues under #134 in openai/triton" → sub_issue_write / issue_read with issue_number
- "Sub-issues under #134 in openai/triton" → issue_read with issue_number (read-only; never sub_issue_write)
- "PR #42 in owner/repo" → pull_request_read

Carefully inspect the user's request for filters such as labels, date ranges, keywords, state (open/closed), etc. Use available optional parameters where appropriate.
Expand All @@ -112,8 +112,8 @@

Extraction Rules:
- Copy owner/organization names, repository names, ref names, file paths, and issue/PR identifiers exactly as the user typed them. Preserve casing, punctuation, spacing, diacritics, and hyphenation; never rewrite, normalize, or translate these strings.
- Only return values that are explicitly present in the user request. If any item is missing, output None for that field.
- Do not infer or guess missing identifiers. If you are unsure about any value, leave it as None.
- Only return values that are explicitly present in the user request. If any item is missing, output null for that field (JSON null, not the string "None").
- Do not infer or guess missing identifiers. If you are unsure about any value, leave it as null.

Output format: a JSON object with keys "owner", "repo", "ref", "path", "numbers".
Example: {"owner": "kagenti", "repo": "kagenti", "ref": null, "path": null, "numbers": null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ description = "Github Agent module"
dependencies = [
"python-dotenv>=1.2.2",
"a2a-sdk>=1.1.0,<2",
"aiohttp>=3.14.3", # Indirect; prevents GHSA-cq5v-8q36-5273 / GHSA-mfx4-hv73-q22v / GHSA-mq44-7p77-q5h7
# We use 1.6.1 instead of the latest because
# crewai requires openai>=2.30.0
# but litellm requires openai==2.24.0
Expand All @@ -14,7 +15,7 @@ dependencies = [
"crewai-tools[mcp]>=1.6.1",
"urllib3>=2.7.0", # Indirect; prevents CVE-2025-66418
"python-multipart>=0.0.32", # Indirect; prevents CVE-2026-24486
"cryptography>=48.0.0,<49", # Indirect; prevents CVE-2026-26007
"cryptography>=50.0.0", # Indirect; prevents CVE-2026-26007 + GHSA-g6cj-pr64-35w5
"pyasn1>=0.6.3", # Indirect; prevents CVE-2026-30922
"starlette>=1.3.1", # Indirect; prevents CVE-2025-62727
"pillow>=12.3.0", # Indirect; prevents CVE-2026-40192
Expand Down
Loading
Loading