Python Agent SDK

Package: mirastack-agents-sdk-python Import: from mirastack_sdk ... License: AGPL v3

The Python Agent SDK is the official library for building MIRASTACK agents in Python. It handles all gRPC communication with the engine so you can focus entirely on your agent’s logic.


Installation

pip install mirastack-agents-sdk-python

Python 3.12 or later is required.


Core Concepts

The MirastackPlugin Base Class

Every agent inherits from MirastackPlugin and overrides five methods:

from mirastack_sdk.plugin import MirastackPlugin

class MyPlugin(MirastackPlugin):
    def info(self) -> PluginInfo: ...
    def schema(self) -> PluginSchema: ...
    async def execute(self, req: ExecuteRequest) -> ExecuteResponse: ...
    async def health_check(self) -> None: ...
    async def config_updated(self, config: dict) -> None: ...

serve()

The entry point for every agent. Call it from main():

from mirastack_sdk import serve

serve(plugin_instance)

serve starts the agent’s gRPC server, reads MIRASTACK_ENGINE_ADDR from the environment, connects to the engine, self-registers via the RegisterPlugin RPC, and handles all incoming calls. It blocks until shutdown. On SIGINT/SIGTERM, it deregisters from the engine before stopping.


Types Reference

PluginInfo

@dataclass
class PluginInfo:
    name: str                           # Unique identifier, snake_case, e.g. "query_vlogs"
    version: str                        # Semantic version, e.g. "1.0.0"
    description: str                    # Plain-English description
    permission: Permission              # Permission.READ | MODIFY | ADMIN
    devops_stages: list[str]            # DevOps Infinity Loop stage tags
    intent_patterns: list[IntentPattern] = field(default_factory=list)
    prompt_templates: list[PromptTemplate] = field(default_factory=list)

DevOps stages you can declare:

Stage When to use it
observe Reading metrics, logs, traces, alerts
operate RCA, correlation, incident response
deploy Kubernetes operations, deployments, rollbacks
release Change events, version management
plan Capacity, SLO/SLA analysis, coverage
build Build system integrations
test Test coverage, flaky test detection
code Code analysis, dependency scanning

Permission

from mirastack_sdk.plugin import Permission

Permission.READ    # Query only — no side effects
Permission.MODIFY  # Changes state — always requires human approval
Permission.ADMIN   # Critical ops (delete, destroy) — always requires human approval

PluginSchema and Action

@dataclass
class PluginSchema:
    actions: list[Action]

@dataclass
class Action:
    name: str               # snake_case action name
    description: str        # LLM-readable description — write for the LLM, not for humans
    input_params: dict[str, ParamSchema]
    output_params: dict[str, ParamSchema] = field(default_factory=dict)
    permission: Permission | None = None  # Overrides plugin-level permission if set
    intents: list[str] = field(default_factory=list)

@dataclass
class ParamSchema:
    type: str               # "string" | "integer" | "boolean" | "number" | "array" | "object"
    required: bool = True
    description: str = ""
    default: str = ""
    enum: list[str] = field(default_factory=list)

ExecuteRequest

@dataclass
class ExecuteRequest:
    params: dict[str, str]          # Action parameters
    time_range: TimeRange | None    # Parsed time range (None for non-time queries)
    engine_client: EngineClient     # Call back into the engine

TimeRange

@dataclass
class TimeRange:
    start_epoch_ms: int     # UTC epoch in milliseconds
    end_epoch_ms: int       # UTC epoch in milliseconds
    timezone: str           # IANA timezone (display only)
    original_expression: str  # What the user typed, e.g. "last 30 minutes"

ExecuteResponse

@dataclass
class ExecuteResponse:
    output: dict[str, str] = field(default_factory=dict)
    # Put your main result in output["result"] as a JSON string
    logs: list[str] = field(default_factory=list)
    # Non-fatal messages, warnings, errors

EngineClient — Calling Back into the Engine

Cache

# Read from cache
value: str | None = await req.engine_client.cache_get(ctx, "my_agent:key")

# Write to cache with TTL in seconds (0 = no expiry)
await req.engine_client.cache_set(ctx, "my_agent:key", json_string, ttl=300)

Key naming convention: always prefix with your agent name — "my_agent:service_list".

Log Event

await req.engine_client.log_event(ctx, {
    "event": "my_agent.query_executed",
    "message": "Query completed successfully",
    "rows": "1250",
})

Publish Result

await req.engine_client.publish_result(ctx, partial_json_string)

datetimeutils Module

from mirastack_sdk.datetimeutils import (
    format_epoch_seconds,
    format_epoch_millis,
    format_epoch_micros,
    format_rfc3339,
    format_lookback_millis,
    format_in_timezone,
    now_utc_ms,
)

Never parse time strings in your agent. Use these functions to convert the TimeRange values into what your backend expects.

Function Output Use with
format_epoch_seconds(ms) "1774973400" VictoriaMetrics, Prometheus
format_epoch_millis(ms) "1774973400000" Jaeger
format_epoch_micros(ms) "1774973400000000" VictoriaTraces
format_rfc3339(ms) "2026-04-08T00:00:00Z" VictoriaLogs, REST APIs
format_lookback_millis(start, end) Duration in ms Window calculations
format_in_timezone(ms, tz) Human-readable Display purposes
now_utc_ms() Current time as int64 ms Default range fallbacks

Pattern: time range with fallback

from mirastack_sdk.datetimeutils import format_epoch_seconds
from mirastack_sdk.plugin import TimeRange

async def _action_range_query(self, params: dict, tr: TimeRange | None) -> ExecuteResponse:
    if tr and tr.start_epoch_ms > 0:
        start = format_epoch_seconds(tr.start_epoch_ms)
        end   = format_epoch_seconds(tr.end_epoch_ms)
    else:
        # Fallback for direct API calls without a TimeRange
        start = params.get("start")
        end   = params.get("end")
    # use start, end to call your backend

IntentPattern and PromptTemplate

IntentPattern

Map natural-language phrases to specific actions so the engine can route user queries to your agent:

from mirastack_sdk.plugin import IntentPattern

IntentPattern(pattern="query metrics for *", action="range_query"),
IntentPattern(pattern="what are the error rates for *", action="range_query"),

PromptTemplate

Contribute prompt templates for LLM steps that involve your agent’s output:

from mirastack_sdk.plugin import PromptTemplate

PromptTemplate(
    name="metrics_analysis",
    content=(
        "You are analyzing metrics data for . "
        "The data shows: . "
        "Identify anomalies, trends, and potential root causes."
    ),
)

Error Handling Rules

  • Put non-fatal errors in ExecuteResponse(logs=[...]) — do not raise exceptions
  • Raise exceptions only for fatal infrastructure failures (e.g., the backend is completely unreachable)
  • Never expose backend URLs, credentials, stack traces, or internal details in error messages
# Correct: non-fatal backend error
except BackendError as e:
    return ExecuteResponse(logs=[f"backend query failed: {e}"])

# Correct: fatal infrastructure error
if not self.client:
    raise RuntimeError("backend client not initialised — check MY_BACKEND_URL")

health_check() — Keep It Real

health_check is called regularly by the engine. Make it actually test your backend connection — do not just return None without checking:

async def health_check(self) -> None:
    # Raises an exception if the backend is unhealthy.
    # Exception message is shown in `miractl agent health`.
    async with aiohttp.ClientSession() as session:
        async with session.get(f"{self.backend_url}/health", timeout=aiohttp.ClientTimeout(total=5)) as resp:
            if resp.status != 200:
                raise RuntimeError(f"backend returned HTTP {resp.status}")

Complete Minimal Example

import asyncio
import json
import os
from mirastack_sdk import serve
from mirastack_sdk.plugin import (
    MirastackPlugin, PluginInfo, PluginSchema, Action, ParamSchema,
    ExecuteRequest, ExecuteResponse, Permission
)
from mirastack_sdk.datetimeutils import format_rfc3339


class MyPlugin(MirastackPlugin):

    def __init__(self):
        self.backend_url = os.environ.get("MY_BACKEND_URL", "")

    def info(self) -> PluginInfo:
        return PluginInfo(
            name="my_agent",
            version="1.0.0",
            description="A minimal example agent",
            permission=Permission.READ,
            devops_stages=["observe"],
        )

    def schema(self) -> PluginSchema:
        return PluginSchema(actions=[
            Action(
                name="get_status",
                description=(
                    "Get the current operational status of a named service. "
                    "Use this when you need to know if a service is healthy."
                ),
                input_params={
                    "service": ParamSchema(type="string", required=True,
                                          description="Service name to check"),
                },
            )
        ])

    async def execute(self, req: ExecuteRequest) -> ExecuteResponse:
        action = req.params.get("action", "")
        if action != "get_status":
            return ExecuteResponse(logs=[f"unknown action: {action}"])

        service = req.params.get("service", "")
        result = {"service": service, "status": "ok"}
        if req.time_range:
            result["queried_at"] = format_rfc3339(req.time_range.end_epoch_ms)

        return ExecuteResponse(output={"result": json.dumps(result)})

    async def health_check(self) -> None:
        pass  # Add real backend ping here

    async def config_updated(self, config: dict) -> None:
        if "backend_url" in config:
            self.backend_url = config["backend_url"]


def main():
    # MIRASTACK_ENGINE_ADDR is read by the SDK automatically.
    serve(MyPlugin())


if __name__ == "__main__":
    main()

Testing Your Agent

The SDK ships with test utilities you can use to unit-test your agent without a running engine:

from mirastack_sdk.testing import MockEngineClient, make_execute_request

async def test_get_status():
    plugin = MyPlugin()
    req = make_execute_request(
        params={"action": "get_status", "service": "checkout"},
        engine_client=MockEngineClient(),
    )
    resp = await plugin.execute(req)
    assert "result" in resp.output
    assert "checkout" in resp.output["result"]
    assert not resp.logs  # no errors

Run with pytest (async tests require pytest-asyncio):

pip install pytest pytest-asyncio
pytest