Skip to content
Merged
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,21 @@ Map<String, Object> events = client.listIntentEvents(intentId, null, RequestOpti

---

## Agent Mesh - Monitor and Govern

Agent Mesh gives every agent real-time health monitoring, policy enforcement, and a kill switch - all from a single dashboard.

```java
client.mesh().startHeartbeat();
client.mesh().reportMetric(Metric.builder().success(true).latencyMs(230).costUsd(0.02).build());
```

Set action policies (allowlist/denylist intent types) and cost policies (intents/day, $/day limits) per agent via dashboard or API. Mesh module coming soon to this SDK - [Python SDK](https://github.com/AxmeAI/axme-sdk-python) available now. [Full overview](https://github.com/AxmeAI/axme#agent-mesh---see-and-control-your-agents).

Open the live dashboard at [mesh.axme.ai](https://mesh.axme.ai) or run `axme mesh dashboard` from the CLI.

---

## Examples

See [`examples/BasicSubmit.java`](examples/BasicSubmit.java). More: [axme-examples](https://github.com/AxmeAI/axme-examples)
Expand Down
22 changes: 21 additions & 1 deletion src/main/java/dev/axme/sdk/AxmeClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public final class AxmeClient {
private final String actorToken;
private final HttpClient httpClient;
private final ObjectMapper objectMapper = new ObjectMapper();
private volatile MeshClient mesh;

public AxmeClient(AxmeClientConfig config) {
this(config, HttpClient.newHttpClient());
Expand All @@ -34,6 +35,25 @@ public AxmeClient(AxmeClientConfig config, HttpClient httpClient) {
this.httpClient = httpClient;
}

/**
* Returns the Agent Mesh sub-client (lazy-initialized, thread-safe).
*
* @return the shared {@link MeshClient} instance
*/
public MeshClient getMesh() {
MeshClient result = mesh;
if (result == null) {
synchronized (this) {
result = mesh;
if (result == null) {
result = new MeshClient(this);
mesh = result;
}
}
}
return result;
}

public Map<String, Object> registerNick(Map<String, Object> payload, RequestOptions options)
throws IOException, InterruptedException {
return requestJson("POST", "/v1/users/register-nick", Map.of(), payload, normalizeOptions(options));
Expand Down Expand Up @@ -830,7 +850,7 @@ private static boolean isTerminalIntentEvent(Map<String, Object> event) {
return eventType instanceof String && TERMINAL_EVENT_TYPES.contains(eventType);
}

private Map<String, Object> requestJson(
Map<String, Object> requestJson(
String method,
String path,
Map<String, String> query,
Expand Down
282 changes: 282 additions & 0 deletions src/main/java/dev/axme/sdk/MeshClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
package dev.axme.sdk;

import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

/**
* Agent Mesh operations - heartbeat, health monitoring, metrics reporting,
* agent management, and event listing.
*
* <p>Obtain an instance via {@link AxmeClient#getMesh()}.
*/
public final class MeshClient {
private final AxmeClient client;
private volatile Thread heartbeatThread;
private volatile boolean heartbeatStopRequested;
private final ConcurrentHashMap<String, AtomicReference<Double>> metricsBuffer = new ConcurrentHashMap<>();
private final AtomicInteger intentsTotal = new AtomicInteger(0);
private final AtomicInteger intentsSucceeded = new AtomicInteger(0);
private final AtomicInteger intentsFailed = new AtomicInteger(0);

MeshClient(AxmeClient client) {
this.client = client;
}

// -- Heartbeat ---------------------------------------------------------------

/**
* Send a single heartbeat to the mesh. Optionally include metrics.
*
* @param metrics optional metrics map to include in the heartbeat
* @param traceId optional trace-id header value
* @return the gateway response body
*/
public Map<String, Object> heartbeat(Map<String, Object> metrics, String traceId)
throws IOException, InterruptedException {
Map<String, Object> body = new LinkedHashMap<>();
if (metrics != null && !metrics.isEmpty()) {
body.put("metrics", metrics);
}
RequestOptions opts = traceId != null ? new RequestOptions(null, traceId) : RequestOptions.none();
return client.requestJson(
"POST",
"/v1/mesh/heartbeat",
Map.of(),
body.isEmpty() ? null : body,
opts);
}

/**
* Start a background daemon thread that sends heartbeats at regular intervals.
*
* <p>If a heartbeat thread is already running, this method is a no-op.
*
* @param intervalSeconds seconds between heartbeats (default 30)
* @param includeMetrics whether to flush and include buffered metrics with each heartbeat
*/
public synchronized void startHeartbeat(double intervalSeconds, boolean includeMetrics) {
if (heartbeatThread != null && heartbeatThread.isAlive()) {
return; // already running
}
heartbeatStopRequested = false;

heartbeatThread = new Thread(() -> {
while (!heartbeatStopRequested) {
try {
Thread.sleep((long) (intervalSeconds * 1000));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
if (heartbeatStopRequested) {
return;
}
try {
Map<String, Object> metrics = includeMetrics ? flushMetrics() : null;
heartbeat(metrics, null);
} catch (Exception e) {
// Heartbeat failures are non-fatal
}
}
}, "axme-mesh-heartbeat");
heartbeatThread.setDaemon(true);
heartbeatThread.start();
}

/**
* Start heartbeat with default settings (30s interval, include metrics).
*/
public void startHeartbeat() {
startHeartbeat(30.0, true);
}

/**
* Stop the background heartbeat thread. Blocks up to 5 seconds for the
* thread to terminate.
*/
public synchronized void stopHeartbeat() {
heartbeatStopRequested = true;
Thread thread = heartbeatThread;
if (thread != null) {
thread.interrupt();
try {
thread.join(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
heartbeatThread = null;
}
}

// -- Metrics -----------------------------------------------------------------

/**
* Buffer a metric observation. Flushed with next heartbeat.
*
* @param success whether the intent succeeded
* @param latencyMs latency in milliseconds (may be null)
* @param costUsd cost in USD (may be null)
*/
public void reportMetric(boolean success, Double latencyMs, Double costUsd) {
int total = intentsTotal.incrementAndGet();
if (success) {
intentsSucceeded.incrementAndGet();
} else {
intentsFailed.incrementAndGet();
}
if (latencyMs != null) {
metricsBuffer.compute("avg_latency_ms", (key, ref) -> {
if (ref == null) {
ref = new AtomicReference<>(0.0);
}
double prevAvg = ref.get();
ref.set(prevAvg + (latencyMs - prevAvg) / total);
return ref;
});
}
if (costUsd != null) {
metricsBuffer.compute("cost_usd", (key, ref) -> {
if (ref == null) {
ref = new AtomicReference<>(0.0);
}
ref.set(ref.get() + costUsd);
return ref;
});
}
}

/**
* Flush the buffered metrics and return them, or null if nothing was buffered.
*/
Map<String, Object> flushMetrics() {
int total = intentsTotal.getAndSet(0);
int succeeded = intentsSucceeded.getAndSet(0);
int failed = intentsFailed.getAndSet(0);
if (total == 0 && metricsBuffer.isEmpty()) {
return null;
}
Map<String, Object> result = new LinkedHashMap<>();
if (total > 0) {
result.put("intents_total", total);
}
if (succeeded > 0) {
result.put("intents_succeeded", succeeded);
}
if (failed > 0) {
result.put("intents_failed", failed);
}
AtomicReference<Double> avgRef = metricsBuffer.remove("avg_latency_ms");
if (avgRef != null) {
result.put("avg_latency_ms", avgRef.get());
}
AtomicReference<Double> costRef = metricsBuffer.remove("cost_usd");
if (costRef != null) {
result.put("cost_usd", costRef.get());
}
return result.isEmpty() ? null : result;
}

// -- Agent management --------------------------------------------------------

/**
* List all agents in the workspace with health status.
*
* @param limit maximum number of agents to return
* @param health optional health filter (e.g. "healthy", "degraded", "dead")
* @return gateway response body
*/
public Map<String, Object> listAgents(int limit, String health)
throws IOException, InterruptedException {
Map<String, String> params = new LinkedHashMap<>();
params.put("limit", String.valueOf(limit));
if (health != null && !health.trim().isEmpty()) {
params.put("health", health);
}
return client.requestJson("GET", "/v1/mesh/agents", params, null, RequestOptions.none());
}

/**
* List agents with default limit of 100 and no health filter.
*/
public Map<String, Object> listAgents() throws IOException, InterruptedException {
return listAgents(100, null);
}

/**
* Get single agent detail with metrics and events.
*
* @param addressId the agent address ID
* @return gateway response body
*/
public Map<String, Object> getAgent(String addressId)
throws IOException, InterruptedException {
return client.requestJson(
"GET",
"/v1/mesh/agents/" + addressId,
Map.of(),
null,
RequestOptions.none());
}

/**
* Kill an agent - block all intents to and from it.
*
* @param addressId the agent address ID to kill
* @return gateway response body
*/
public Map<String, Object> kill(String addressId)
throws IOException, InterruptedException {
return client.requestJson(
"POST",
"/v1/mesh/agents/" + addressId + "/kill",
Map.of(),
null,
RequestOptions.none());
}

/**
* Resume a killed agent.
*
* @param addressId the agent address ID to resume
* @return gateway response body
*/
public Map<String, Object> resume(String addressId)
throws IOException, InterruptedException {
return client.requestJson(
"POST",
"/v1/mesh/agents/" + addressId + "/resume",
Map.of(),
null,
RequestOptions.none());
}

// -- Events ------------------------------------------------------------------

/**
* List recent mesh events (kills, resumes, health changes).
*
* @param limit maximum number of events to return
* @param eventType optional filter by event type
* @return gateway response body
*/
public Map<String, Object> listEvents(int limit, String eventType)
throws IOException, InterruptedException {
Map<String, String> params = new LinkedHashMap<>();
params.put("limit", String.valueOf(limit));
if (eventType != null && !eventType.trim().isEmpty()) {
params.put("event_type", eventType);
}
return client.requestJson("GET", "/v1/mesh/events", params, null, RequestOptions.none());
}

/**
* List events with default limit of 50 and no type filter.
*/
public Map<String, Object> listEvents() throws IOException, InterruptedException {
return listEvents(50, null);
}
}
Loading
Loading