Skip to content

Add sam-cop tool - #314

Open
kaisoz wants to merge 13 commits into
google:mainfrom
kaisoz:kaisoz/agent-cop-on-sam-mesh
Open

Add sam-cop tool#314
kaisoz wants to merge 13 commits into
google:mainfrom
kaisoz:kaisoz/agent-cop-on-sam-mesh

Conversation

@kaisoz

@kaisoz kaisoz commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Adds sam-cop, a mesh watchdog under development/tools/sam-cop that polls its local node over MCP.

  • Node side: typed node-event ring buffer, plus poll_node_events and get_control_plane_info MCP tools.
  • Detects peer loss, mesh-size drops, router churn, signing-key changes, and control-plane unreachability.
  • Alerts fan out through a self-registering channel registry (Telegram/stdout), with sanitized content and reconnect on session loss.
  • sam_mcp Python client now uses the MCP SDK's HTTP client so SSE streams survive beyond httpx's 5s read timeout.

Fixes #239

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces sam-cop, a Python-based monitoring tool for SAM nodes that tracks peer connectivity, service churn, node events, and control plane health, delivering alerts via Slack, Telegram, or stdout. It also implements Go-side event buffering, adds new MCP tools (poll_node_events and get_control_plane_info), and updates the Python client to use SSE-friendly timeouts. The review feedback highlights several critical improvement opportunities: delivering alerts concurrently using asyncio.gather to prevent sequential blockages, adding defensive checks for null results in JSON parsing, avoiding memory and file descriptor leaks by closing abandoned clients asynchronously rather than storing them globally, catching standard exceptions instead of BaseException to allow clean shutdowns, and zeroing out discarded elements in the Go event buffer before re-slicing to prevent memory leaks.

Comment thread development/tools/sam-cop/sam_cop.py Outdated
Comment thread development/tools/sam-cop/sam_cop.py
Comment on lines +344 to +346
async def reconnect(client, state: SamCopState) -> Tuple[SamClient, str, SamCopState]:
"""Recovers from a lost MCP session; the node's seq counter restarts too, so reset cursor."""
_abandoned.append(client)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Parking dead clients in a global _abandoned list prevents them from ever being garbage collected, leading to a memory and file descriptor leak over time. Instead of keeping them alive indefinitely, you can close them asynchronously in a background task and safely swallow any exceptions.

Suggested change
async def reconnect(client, state: SamCopState) -> Tuple[SamClient, str, SamCopState]:
"""Recovers from a lost MCP session; the node's seq counter restarts too, so reset cursor."""
_abandoned.append(client)
async def reconnect(client, state: SamCopState) -> Tuple[SamClient, str, SamCopState]:
"""Recovers from a lost MCP session; the node's seq counter restarts too, so reset cursor."""
async def close_client_safely(c):
try:
await c.close()
except Exception:
pass
asyncio.create_task(close_client_safely(client))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The library only lets the task that opened a connection close it, so any close() here — even in a background task — throws and closes nothing; the suggestion just hides that error.
Dropping the reference is worse: the GC's cleanup then cancels the main polling loop (a bug I faced and this fixed). Parking the client is deliberate; it happens once per reconnect (after 3 failed cycles), so growth is negligible.

However, now I exit the process when len(_abandoned) > threshold so that a supervisor (such as k8s) can restart it with a clean state

Comment thread development/tools/sam-cop/sam_cop.py Outdated
Comment thread internal/node/events.go
@kaisoz

kaisoz commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

I added the sam-cop to development/tools instead of the usual development/examples since this feels more like a mesh tool rather than an example for the users. However I'm open to discuss it

kaisoz added 13 commits August 25, 2026 21:23
…, add channel registry

Move the agent to development/examples/sam-cop and rename Cop symbols to
SamCop. Split the delivery backends into channels.py, where Channel
subclasses self-register and declare required_env so build_channels picks
them up without a registry edit.

Node event categories and types are now exported constants instead of
string literals at the call sites.
…meouts

Handle SIGINT/SIGTERM via a shutdown flag so session-loss CancelledError
is distinguishable from a real shutdown. Park dead clients instead of
closing them cross-task. Always keep the stdout channel on. Use
create_mcp_http_client so SSE streams don't hit httpx's 5s read timeout.
@kaisoz
kaisoz force-pushed the kaisoz/agent-cop-on-sam-mesh branch from 84f9590 to 5efce99 Compare August 25, 2026 21:45
@kaisoz

kaisoz commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces sam-cop, a Python-based watchdog and alerting tool that monitors SAM node events, detects network partitions, service churn, and control plane changes, and delivers alerts via Slack, Telegram, or stdout. It also adds Go backend support for recording and polling node events, exposes new MCP tools (poll_node_events and get_control_plane_info), and updates the Python client to use SSE-friendly timeouts. The review feedback highlights several performance and resource management improvements: asynchronously closing dead clients in sam_cop.py to prevent file descriptor leaks, using copy in events.go to preserve slice capacity and avoid repeated allocations, executing control plane fetches concurrently in mcp_handlers.go to reduce latency, and reusing a single httpx.AsyncClient in channels.py to avoid connection overhead.

Comment thread development/tools/sam-cop/sam_cop.py
Comment thread internal/node/events.go
Comment on lines +67 to +74
if len(b.entries) > nodeEventBufferSize {
discarded := len(b.entries) - nodeEventBufferSize
// Zero the trimmed prefix so its strings don't linger until the next reallocation.
for i := range discarded {
b.entries[i] = NodeEvent{}
}
b.entries = b.entries[discarded:]
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Slicing the slice via b.entries = b.entries[discarded:] causes the slice's capacity to decrease over time. Once the capacity is reduced to nodeEventBufferSize, every subsequent record call will trigger a new underlying array allocation and memory copy. Shifting the elements to the beginning of the slice using copy preserves the slice's capacity at index 0, completely eliminating future allocations once the buffer is full.

Suggested change
if len(b.entries) > nodeEventBufferSize {
discarded := len(b.entries) - nodeEventBufferSize
// Zero the trimmed prefix so its strings don't linger until the next reallocation.
for i := range discarded {
b.entries[i] = NodeEvent{}
}
b.entries = b.entries[discarded:]
}
if len(b.entries) > nodeEventBufferSize {
discarded := len(b.entries) - nodeEventBufferSize
copy(b.entries, b.entries[discarded:])
for i := len(b.entries) - discarded; i < len(b.entries); i++ {
b.entries[i] = NodeEvent{}
}
b.entries = b.entries[:nodeEventBufferSize]
}

Comment on lines +932 to +939
info, err := FetchControlPlaneInfo(ctx, controlPlaneURL)
if err != nil {
return nil, nil, err
}
keys, err := FetchControlPlaneKeys(ctx, controlPlaneURL)
if err != nil {
return nil, nil, err
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The calls to FetchControlPlaneInfo and FetchControlPlaneKeys are independent and can be executed concurrently to reduce the latency of the get_control_plane_info tool call. Since "sync" is already imported in this file, we can use a sync.WaitGroup to fetch both concurrently.

	var info *api.ControlPlaneInfoResponse
	var keys *api.KeysResponse
	var errInfo, errKeys error
	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
		defer wg.Done()
		info, errInfo = FetchControlPlaneInfo(ctx, controlPlaneURL)
	}()
	go func() {
		defer wg.Done()
		keys, errKeys = FetchControlPlaneKeys(ctx, controlPlaneURL)
	}()
	wg.Wait()
	if errInfo != nil {
		return nil, nil, errInfo
	}
	if errKeys != nil {
		return nil, nil, errKeys
	}

Comment on lines +31 to +37
def __init__(self, webhook_url: str):
self.webhook_url = webhook_url

async def send(self, message: str) -> None:
async with httpx.AsyncClient() as http_client:
response = await http_client.post(self.webhook_url, json={"text": message}, timeout=10.0)
response.raise_for_status()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Creating a new httpx.AsyncClient on every single alert delivery is inefficient as it incurs connection establishment and TLS handshake overhead for each message. We should instantiate a single httpx.AsyncClient in the channel's __init__ method and reuse it across all send calls.

Suggested change
def __init__(self, webhook_url: str):
self.webhook_url = webhook_url
async def send(self, message: str) -> None:
async with httpx.AsyncClient() as http_client:
response = await http_client.post(self.webhook_url, json={"text": message}, timeout=10.0)
response.raise_for_status()
def __init__(self, webhook_url: str):
self.webhook_url = webhook_url
self.http_client = httpx.AsyncClient()
async def send(self, message: str) -> None:
response = await self.http_client.post(self.webhook_url, json={"text": message}, timeout=10.0)
response.raise_for_status()

Comment on lines +44 to +52
def __init__(self, bot_token: str, chat_id: str):
self.bot_token = bot_token
self.chat_id = chat_id

async def send(self, message: str) -> None:
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
async with httpx.AsyncClient() as http_client:
response = await http_client.post(url, json={"chat_id": self.chat_id, "text": message}, timeout=10.0)
response.raise_for_status()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Creating a new httpx.AsyncClient on every single alert delivery is inefficient as it incurs connection establishment and TLS handshake overhead for each message. We should instantiate a single httpx.AsyncClient in the channel's __init__ method and reuse it across all send calls.

Suggested change
def __init__(self, bot_token: str, chat_id: str):
self.bot_token = bot_token
self.chat_id = chat_id
async def send(self, message: str) -> None:
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
async with httpx.AsyncClient() as http_client:
response = await http_client.post(url, json={"chat_id": self.chat_id, "text": message}, timeout=10.0)
response.raise_for_status()
def __init__(self, bot_token: str, chat_id: str):
self.bot_token = bot_token
self.chat_id = chat_id
self.http_client = httpx.AsyncClient()
async def send(self, message: str) -> None:
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
response = await self.http_client.post(url, json={"chat_id": self.chat_id, "text": message}, timeout=10.0)
response.raise_for_status()

@aojea

aojea commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Thanks @kaisoz — there are two changes here I'd like to land immediately. But I want to push back on the node-side event machinery: our goal is to give local agents visibility into mesh health without growing the MCP surface, and the node already has the vehicles for this.

Please split out if you want to merge fast:

  • The sam_mcp/client.py SSE timeout fix — real bug, nice catch.
  • peer_id in get_mesh_info — trivial and useful.

Request changes on the rest:

internal/node/events.go + poll_node_events duplicate the existing log buffer. Every RecordNodeEvent() call site sits next to an existing logger.Warnf("[Mesh Event] ...") line, and those already land in the node's ring buffer sink and are exposed via the existing get_recent_logs tool. This adds a second parallel buffer, new global mutable state (tests have to reassign globalEventBuffer), cursor semantics with silent gap loss for slow consumers, and a new tool.

get_control_plane_info turns every node into an on-demand proxy to the control plane — each call live-fetches /info and /keys, so any local agent can amplify traffic against the hub. The node already syncs and stores this data (SyncMeshConfig); anything we expose should come from the local store, never a live fetch on the agent's trigger.

Suggested replacement (~5 lines, existing promauto pattern, no new tools):

sam_node_mesh_events_total{type="banned|key_rotation|policy_update"}
sam_node_mesh_events_rejected_total{reason="rate_limit|invalid_signature|stale"}
Optionally sam_node_control_plane_reachable gauge updated by the existing sync loop.
These go on the sidecar's existing public /metrics endpoint — same pattern as sam_node_agents_seen.

Then re-base sam-cop on existing surfaces:

scrape /metrics for security/mesh counters and CP health (this is also what makes it fleet-scalable — point real Prometheus at every node),
diff get_mesh_info between polls for peer loss / mesh-size drops / router churn,
get_recent_logs (grep [Mesh Event]) when it needs per-peer detail.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agent cop on sam mesh

2 participants