AstroML ships an LLM agent framework for multi-step reasoning and autonomous task execution on top of the existing graph pipeline. It is provider agnostic, dependency light (standard library for the core loop), and traces every step so autonomous runs stay auditable.
┌──────────────────────────────┐
goal ───────────▶ │ AgentExecutor (reason loop) │
└───────────────┬──────────────┘
│ messages
┌───────────▼───────────┐
│ LLMProvider │ EchoLLM
│ complete(messages) │ ScriptedLLM
└───────────┬───────────┘ CallableLLM
│ tool calls OpenAICompatibleLLM
┌───────────▼───────────┐
│ ToolRegistry │ graph_overview
│ run(call) -> result │ window_stats
└───────────┬───────────┘ account_features
│ observations top_accounts
┌───────────▼───────────┐ score_accounts
│ Memory + AgentTrace │
└───────────────────────┘
- Quick start
- Providers
- Tools
- Memory
- Planning
- The execution loop
- AstroML domain tools
- Command line interface
- Testing
The agent core has no third-party requirements, so it can be exercised
offline with ScriptedLLM (deterministic replay) or EchoLLM.
from astroml.agent import (
AgentConfig,
AgentExecutor,
ToolRegistry,
ScriptedLLM,
tool,
)
@tool()
def count_edges(start_ts: int = 0, end_ts: int = 10) -> dict:
"""Count the transactions in an inclusive timestamp window."""
return {"edges": 3}
llm = ScriptedLLM(
[
'Thought: count them\nAction: count_edges\nAction Input: {"start_ts": 1}',
"Thought: done\nFinal Answer: 3 transactions",
]
)
agent = AgentExecutor(
llm=llm,
tools=ToolRegistry([count_edges]),
config=AgentConfig(mode="react", max_steps=4),
)
result = agent.run("How many transactions are there?")
print(result.answer) # 3 transactions
print(result.trace.summary()) # steps, tool calls, errors, durationEvery run returns an AgentRunResult:
| Field | Meaning |
|---|---|
result.answer |
Final answer, or None when the run failed |
result.success |
True only when trace.status == "succeeded" |
result.trace.steps |
Per-cycle thought, tool calls and observations |
result.trace.metadata |
Provider, model, mode, visible tools, plan |
result.to_dict() |
JSON-serialisable trace for logging/evaluation |
The loop never raises for model or tool failures — inspect
trace.status and trace.error instead.
An agent only needs an object implementing LLMProvider:
class LLMProvider(ABC):
async def complete(self, messages, *, tools=None, temperature=0.0,
max_tokens=None) -> LLMResponse: ...Four providers ship with the framework:
| Provider | Use case |
|---|---|
EchoLLM |
Offline default; echoes the latest user message |
ScriptedLLM |
Deterministic replay of a fixed response script |
CallableLLM |
Adapt any messages -> response function (sync or async) |
OpenAICompatibleLLM |
Any /v1/chat/completions endpoint via aiohttp |
OpenAICompatibleLLM works with OpenAI, Ollama, vLLM, LM Studio and
OpenRouter. aiohttp is imported lazily, so the rest of the framework keeps
working when it is absent.
Select a provider from the environment with provider_from_env():
# Local Ollama
export ASTROML_LLM_PROVIDER=ollama
export ASTROML_LLM_MODEL=llama3.1
export ASTROML_LLM_BASE_URL=http://localhost:11434/v1
# OpenAI (or any compatible gateway)
export ASTROML_LLM_PROVIDER=openai
export ASTROML_LLM_MODEL=gpt-4o-mini
export ASTROML_LLM_API_KEY=sk-...| Variable | Purpose |
|---|---|
ASTROML_LLM_PROVIDER |
echo (default), scripted, openai, openai-compatible, ollama, vllm, lmstudio |
ASTROML_LLM_MODEL |
Model identifier |
ASTROML_LLM_BASE_URL |
API root, e.g. http://localhost:11434/v1 |
ASTROML_LLM_API_KEY |
Bearer token when the endpoint needs one |
ASTROML_LLM_TIMEOUT |
Request timeout in seconds (default 60) |
ASTROML_LLM_SCRIPT |
scripted provider script, ` |
ASTROML_LLM_ON_EXHAUSTED |
raise | repeat_last | empty |
A Tool wraps a plain Python callable plus the metadata an LLM needs.
Parameters schemas are derived from type hints; docstring first lines become
descriptions.
from typing import Any, Dict, List, Optional
from astroml.agent import Tool, ToolError, ToolRegistry, tool
@tool(requires_confirmation=True)
def window_stats(
edges: List[Dict[str, Any]],
start_ts: int,
end_ts: int,
asset: Optional[str] = None,
) -> Dict[str, Any]:
"""Count nodes and edges in an inclusive timestamp window."""
if start_ts > end_ts:
raise ToolError("start_ts must be <= end_ts")
...The generated JSON Schema for window_stats is:
{
"type": "object",
"properties": {
"edges": {"type": "array", "items": {"type": "object"}},
"start_ts": {"type": "integer"},
"end_ts": {"type": "integer"},
"asset": {"type": "string"}
},
"required": ["edges", "start_ts", "end_ts"]
}Key behaviours:
- Sync and async callables are both supported;
Tool.runawaits coroutine functions transparently. - Retryable failures. Raise
ToolError("...", retryable=True)to opt in to the retry budget (AgentConfig.tool_retries). Errors from tools are never fatal — they are rendered asERROR: ...observations and fed back to the model so it can self-correct. - Unknown tools and bad arguments become failed
ToolResults, not exceptions, because models hallucinate tool names. - Registry ordering is preserved so prompts (and therefore runs) are
deterministic.
registry.filtered(["window_stats"])restricts the tool set exposed to the model.
ConversationMemory is a bounded, system-message aware buffer. System
messages are pinned and always returned first; the remaining history is FIFO
capped at max_messages. on_evict is the hook for rolling
summarisation.
from astroml.agent import ConversationMemory
memory = ConversationMemory(max_messages=40, on_evict=summarise_or_drop)
agent = AgentExecutor(llm, tools, memory=memory)Passing a shared memory carries context across multiple runs; omitting it
gives each run a fresh buffer.
TaskPlanner asks an LLM to decompose a goal into ordered steps using a
tolerant JSON contract (bare JSON, fenced blocks and JSON embedded in prose
all parse; unparseable output degrades to an empty plan rather than raising).
from astroml.agent import AgentExecutor, TaskPlanner
planner = TaskPlanner(llm, max_steps=6)
agent = AgentExecutor(llm, tools, planner=planner)
result = agent.run("Find the busiest accounts and flag anything unusual")When a plan is produced it is recorded on result.trace.metadata["plan"] and
injected into the prompt as a suggested plan — the executor still reasons
freely and may deviate.
AgentExecutor.arun (async) and AgentExecutor.run (blocking wrapper)
implement reason → act → observe:
-
Send the goal, tool catalogue and (optionally) the plan to the model.
-
Interpret the turn. Three strategies are available via
AgentConfig.mode:Mode Behaviour nativeUse provider function calling only reactParse Thought/Action/Action Input/Final Answertextauto(default)Prefer native tool calls, fall back to ReAct text -
Execute the requested tools, append observations, repeat.
-
Stop when the model answers directly,
max_stepsis reached, or the tool error budget is exhausted.
from astroml.agent import AgentConfig
config = AgentConfig(
max_steps=8, # hard cap on reason/act cycles
max_tool_errors=3, # abort once more than N tool calls fail
tool_retries=1, # extra attempts for retryable ToolErrors
stop_on_tool_error=False,
temperature=0.0,
max_tokens=None,
mode="auto",
include_tool_specs_in_prompt=True,
)Statuses: succeeded, failed, max_steps (plus pending, running,
skipped for individual steps). Use on_step=callback for progress
reporting; callback exceptions are logged and never break the run.
astroml.agent.domain_tools wraps existing framework capabilities so an
agent can inspect graphs immediately:
| Tool | Purpose |
|---|---|
graph_overview |
Node/edge counts, assets, density, busiest accounts |
window_stats |
Inclusive [start_ts, end_ts] slice via window_snapshot |
account_features |
Per-account features from compute_node_features |
top_accounts |
Rank accounts by volume, degree, in-degree or out-degree |
anomaly_score_tool |
Factory wrapping a trained InductiveAnomalyScorer |
from astroml.agent import AgentExecutor, build_default_registry
tools = build_default_registry() # all four graph tools
tools = build_default_registry(include=["graph_overview"]) # subsetGraph tools accept edges as JSON mappings that follow the repository's edge
contract (src, dst, timestamp, optionally amount / asset).
Malformed input raises ToolError, which is surfaced to the model as an
observation.
Model-backed scoring stays opt-in so torch / torch-geometric are only
imported when a trained scorer exists:
from astroml.agent.domain_tools import anomaly_score_tool
registry.register(anomaly_score_tool(my_trained_scorer))python -m astroml.agent "Summarise this transaction graph"
python -m astroml.agent --provider ollama --model llama3.1 --plan "Rank accounts"
python -m astroml.agent --edges data/edges.json --json "How many accounts?"
python -m astroml.cli agent --edges data/edges.json "..." # same CLI| Flag | Purpose |
|---|---|
goal / --goal |
Goal for the agent to accomplish |
--edges PATH |
JSON edge list; binds the dataset so the model supplies only scalars |
--provider --model --base-url --api-key |
Provider overrides |
--mode {auto,native,react} |
Reasoning strategy |
--max-steps --max-tool-errors |
Loop bounds |
--plan |
Decompose the goal with TaskPlanner before executing |
--tools a,b |
Restrict the visible tool set |
--json |
Print the full trace as JSON |
--quiet |
Suppress per-step progress and the trace summary |
Exit codes: 0 success, 1 run failed, 2 configuration or IO error.
Progress is written to stderr; the answer goes to stdout.
Agent tests are hermetic — they use ScriptedLLM and stub scorers and never
touch the network.
python -m pytest tests/test_agent_types.py tests/test_agent_tools.py \
tests/test_agent_memory.py tests/test_agent_llm.py \
tests/test_agent_planner.py tests/test_agent_executor.py \
tests/test_agent_domain_tools.py tests/test_agent_cli.py -vFeature-tool tests use pytest.importorskip("pandas"), so the suite passes
in environments without the ML stack installed.
- The core loop has no third-party dependencies.
aiohttpis required only byOpenAICompatibleLLM;pandas/torchonly by specific domain tools. - Importing
astroml.agent.domain_toolspulls inastroml.features, which eagerly imports the SQLAlchemy-backed pipelines. The domain tools are therefore re-exported lazily fromastroml.agentvia__getattr__. requires_confirmationmarks destructive tools, but the framework does not enforce approval — host applications decide how to gate those calls.- Tool output is truncated to 4000 characters before it reaches the model
(
render_result); summarise large payloads inside the tool instead. - Tool execution is sequential by design: it keeps traces deterministic and avoids hammering endpoints from a single agent turn.