How It Works¶
Architecture¶
Argus follows a clean separation of concerns called Same brain. Different hands. Different home.
┌─────────────────────────────────────────────────────────────────┐
│ core/ — Pure Python, zero cloud imports │
│ │
│ AgentLoop.run() │
│ ├── Think: ai_provider.chat(messages, tools, system_prompt) │
│ ├── Act: adapter.list_resources() / get_metrics() / ... │
│ ├── Observe: tool result appended to conversation │
│ └── Repeat until submit_findings() │
└──────────────────────────────┬──────────────────────────────────┘
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
CloudAdapter AIProvider Entrypoint
(AWS/GCP/Azure) (Bedrock/Anthropic (Lambda/CloudRun/
/Vertex/AzureOAI) AzureFunction)
Brain = core/ — the agent loop and prompts. No cloud imports. Runs in any environment.
Hands = adapters/ — cloud-specific data collection. Implements a four-method contract.
Home = entrypoints/ — thin runtime wrappers. No business logic.
The ReAct loop¶
Argus uses ReAct (Reason + Act) — a pattern where the AI alternates between reasoning and tool use:
User: "Begin your cloud cost analysis now."
Claude: <thinks> I'll start by listing all resources.
<calls> list_resources()
Tool: [{"id":"i-0abc","type":"AWS::EC2::Instance","region":"us-east-1"},
{"id":"nat-0def","type":"AWS::EC2::NatGateway","region":"us-east-1"},
...]
Claude: <thinks> I see 47 resources. Let me get cost data for all of them at once.
<calls> get_cost(resource_ids=[...all 47...])
Tool: {"i-0abc": 28.40, "nat-0def": 10.80, ...}
Claude: <thinks> i-0abc costs $28.40/month. Let me check if it's actually being used.
<calls> get_metrics(resource_id="i-0abc", resource_type="AWS::EC2::Instance")
Tool: {"has_data": true, "CPUUtilization_avg": 0.0014, "NetworkIn_avg": 0.0}
Claude: <thinks> 0.001% CPU — effectively zero. Let me check last activity.
<calls> get_last_activity(resource_id="i-0abc", resource_type="AWS::EC2::Instance")
Tool: null
Claude: <thinks> No CloudTrail events in 90 days + near-zero metrics = idle.
... investigates more resources ...
<calls> submit_findings([...])
The loop runs for up to 50 iterations. In practice, a full scan of 50 resources takes 8–15 iterations.
Tool dispatch¶
AgentLoop._execute() maps tool names to adapter calls:
| Tool | Adapter method | Returns |
|---|---|---|
list_resources | adapter.list_resources(ignore_regions) | List of Resource objects (compact JSON) |
get_metrics | adapter.get_metrics(resource_id, resource_type, days) | MetricSummary (avg values + has_data flag) |
get_cost | adapter.get_cost(resource_ids, days) | dict[resource_id, float] (USD) |
get_last_activity | adapter.get_last_activity(resource_id, resource_type) | ISO8601 timestamp or null |
Token optimization¶
Three mechanisms keep AI token usage low:
- Non-billable resource filter — IAM roles, subnets, route tables, CloudFormation stacks, etc. are stripped before the AI sees
list_resources. Cuts 60–70% of resources in a typical account. - Compact JSON — resource list uses short keys (
id,type,region) and omits null fields. No whitespace in serialized JSON. - Prompt caching — the system prompt is pinned with
cache_control: ephemeral. Iterations 2–N pay 10% of normal input token cost for the cached portion.
Finding lifecycle¶
Resource Explorer / Asset Inventory / Resource Graph
↓ filter non-billable
↓ compact JSON
AI sees list_resources result
↓ AI selects candidates by cost
get_cost (batched — one API call)
↓ AI prioritizes expensive candidates
get_metrics + get_last_activity (per candidate)
↓ AI reasons: idle? underutilized? orphaned?
submit_findings
↓
ResourceFinding dataclass list
↓
build_report() → JSON report
↓
build_html_report() → self-contained HTML file
↓ (if REPORT_*_BUCKET is set)
upload to S3 / GCS / Azure Blob → pre-signed / SAS URL
↓
build notification payloads (Slack / Teams / generic webhook)
↓
notify_all() → delivers to all configured channels