argus-cloud-optimizer 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. adapters/__init__.py +0 -0
  2. adapters/aws/__init__.py +0 -0
  3. adapters/aws/adapter.py +85 -0
  4. adapters/aws/auth.py +57 -0
  5. adapters/aws/cloudtrail.py +83 -0
  6. adapters/aws/cloudwatch.py +732 -0
  7. adapters/aws/config.py +9 -0
  8. adapters/aws/cost_explorer.py +116 -0
  9. adapters/aws/resource_explorer.py +186 -0
  10. adapters/aws/retry.py +55 -0
  11. adapters/azure/__init__.py +0 -0
  12. adapters/azure/activity_log.py +159 -0
  13. adapters/azure/adapter.py +117 -0
  14. adapters/azure/cost_management.py +125 -0
  15. adapters/azure/monitor.py +311 -0
  16. adapters/azure/resource_graph.py +113 -0
  17. adapters/azure/retry.py +57 -0
  18. adapters/base.py +105 -0
  19. adapters/gcp/__init__.py +0 -0
  20. adapters/gcp/adapter.py +86 -0
  21. adapters/gcp/asset_inventory.py +116 -0
  22. adapters/gcp/billing.py +118 -0
  23. adapters/gcp/cloud_logging.py +93 -0
  24. adapters/gcp/cloud_monitoring.py +276 -0
  25. adapters/gcp/retry.py +46 -0
  26. ai/__init__.py +0 -0
  27. ai/anthropic.py +174 -0
  28. ai/azure_openai.py +241 -0
  29. ai/base.py +78 -0
  30. ai/bedrock.py +169 -0
  31. ai/vertexai.py +234 -0
  32. argus_cloud_optimizer-0.2.0.dist-info/METADATA +433 -0
  33. argus_cloud_optimizer-0.2.0.dist-info/RECORD +62 -0
  34. argus_cloud_optimizer-0.2.0.dist-info/WHEEL +5 -0
  35. argus_cloud_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
  36. argus_cloud_optimizer-0.2.0.dist-info/licenses/LICENSE +21 -0
  37. argus_cloud_optimizer-0.2.0.dist-info/top_level.txt +4 -0
  38. core/__init__.py +0 -0
  39. core/__version__.py +1 -0
  40. core/agent/__init__.py +0 -0
  41. core/agent/loop.py +390 -0
  42. core/agent/prompts.py +317 -0
  43. core/config.py +235 -0
  44. core/log.py +69 -0
  45. core/models/__init__.py +0 -0
  46. core/models/finding.py +76 -0
  47. core/py.typed +0 -0
  48. core/reports/__init__.py +0 -0
  49. core/reports/comparison.py +49 -0
  50. core/reports/delivery.py +323 -0
  51. core/reports/export.py +111 -0
  52. core/reports/generator.py +168 -0
  53. core/reports/html.py +286 -0
  54. core/reports/multi_cloud.py +162 -0
  55. core/secrets.py +145 -0
  56. core/token_tracker.py +97 -0
  57. core/validation.py +214 -0
  58. entrypoints/__init__.py +0 -0
  59. entrypoints/aws_lambda.py +299 -0
  60. entrypoints/azure_function.py +257 -0
  61. entrypoints/cli.py +156 -0
  62. entrypoints/gcp_cloudrun.py +209 -0
@@ -0,0 +1,62 @@
1
+ adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ adapters/base.py,sha256=fF5MSnjAtaOBb8yPfetUWoYIu2QoIzlSXQOgwD9eXuI,3211
3
+ adapters/aws/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ adapters/aws/adapter.py,sha256=La_fi8Q81AJbFdQjEHUnivPFnnbO3QJGkIvMVqM6KJY,2457
5
+ adapters/aws/auth.py,sha256=SwMw9LIpFPQXVxX2x0ISI6N15kZfCgqgjBV78xGoXdA,1945
6
+ adapters/aws/cloudtrail.py,sha256=tBYJ_eQGfAftRYihLkBn86kIZuxGmxIoHtbXvthVzFE,2465
7
+ adapters/aws/cloudwatch.py,sha256=TpAxLkXIzmorWbHmI4hWLMtn0tmsEkm3pNxARTF9JFU,34769
8
+ adapters/aws/config.py,sha256=2SYo2FCXEtEULLMnEOh-f7HFfT0_9knmhJLjYAAf_2M,182
9
+ adapters/aws/cost_explorer.py,sha256=NH3_Nk8pfo-DUZWeE8GLwBqq5b3_mH4wpYw3Sm8c-Bg,4033
10
+ adapters/aws/resource_explorer.py,sha256=o40G20sFdFURtqiBAwAAf7NjXkZASyFBEUEOMKIsZrs,6950
11
+ adapters/aws/retry.py,sha256=LgS_QzcxFdzeIduvQfaGCe1S13dAwZ9eSUdPbqgNH2A,1431
12
+ adapters/azure/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ adapters/azure/activity_log.py,sha256=OqN5Xxy1n9gvwkE02Qr-ykmenGknI9FY4neqlT8vKKU,4869
14
+ adapters/azure/adapter.py,sha256=1DBv35iovPuw0y4xokr3bh3JedM6mlhxHIdxENUDuYw,3702
15
+ adapters/azure/cost_management.py,sha256=Jxg7RpcSixCfKvbOirvy2NzUwsCqAze-G-qAUP6J75Y,4147
16
+ adapters/azure/monitor.py,sha256=0nEFCWuMJ0gJ6YKhifUqPEu2MKA26uKWsiJ0lDsKzYg,9657
17
+ adapters/azure/resource_graph.py,sha256=dKyVK6U_qhHV9AQanhEvHJmyGP-rsumlLg1xcNRlFiE,3634
18
+ adapters/azure/retry.py,sha256=MCEoXB-n8i0jwAg4yUQV_Y5g-MJSLV38VL86noDweUw,1611
19
+ adapters/gcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ adapters/gcp/adapter.py,sha256=QbIOwEBkvJhsF6OUNprhBNOd5RAZ6z6WC-8JmPmNLrk,2661
21
+ adapters/gcp/asset_inventory.py,sha256=BHb0ZtcboYDr1dS3xCZDrC3JcXT-KufcIWDwLaFwcuw,4011
22
+ adapters/gcp/billing.py,sha256=Gk3Z06jExvlI7drgMTfr2o36JMjL_vPXD_3ADcodbmM,4144
23
+ adapters/gcp/cloud_logging.py,sha256=ozYlqVMmo4qP8KAq5pWIYWnEWl3VieAFfLMRPBoyNoM,3600
24
+ adapters/gcp/cloud_monitoring.py,sha256=4YxdPVXLk9VxNumO7SIo20dcTLxUMt5s_Rt8hQJS8Gs,10392
25
+ adapters/gcp/retry.py,sha256=sl_vs0EQmB4F4gWv5DaxCKqNZM9KDLWfQP2UTD9ZWIw,1220
26
+ ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
+ ai/anthropic.py,sha256=sDz8YmudxIzJ2jWF_HPy4lWQOnVuPi8TjLcsN3WsvKg,6129
28
+ ai/azure_openai.py,sha256=qTj718fLwtOegW_TiHe0hygWX8jntHrWDxAjr0zs1lc,8368
29
+ ai/base.py,sha256=8hmbU0SFkm6h11swAl-LXZjNZNxc_WIQx4cDK6iCbMQ,1765
30
+ ai/bedrock.py,sha256=R17slWJEWJyvtfs-aTUyrpz0AV0o112Mh8Twpd_ocNc,5736
31
+ ai/vertexai.py,sha256=nnGHjw-u1IwqOFZgjsvLgsTwDF9aP_yRVjpYaXfdnGo,8198
32
+ argus_cloud_optimizer-0.2.0.dist-info/licenses/LICENSE,sha256=4suQFFXzS6jjYY4AdtOSf9gQ3NGIVb_I6wFpMKoaaA4,1079
33
+ core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
+ core/__version__.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
35
+ core/config.py,sha256=ODRqMlhI34pGRml65g14oB01QgOdjbC-eKWDOUFC7gc,8536
36
+ core/log.py,sha256=T1s8yR4m6MPHiwjAF0ThK6UMzVJgrXQZ2qU4D3e_1cQ,2140
37
+ core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
38
+ core/secrets.py,sha256=DAub5i22_DoKcxOjPzJiOnKxDvSqEcYcnQkhKjrMsFo,4864
39
+ core/token_tracker.py,sha256=pVmQaAB4TBJBmWN_lgwBrDAO-mm3z8YOO_6S2augLpI,3266
40
+ core/validation.py,sha256=1eBY-uYknu6kLTXrSrEDltG9O_I75QY01RgVsbKC2mw,7221
41
+ core/agent/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
42
+ core/agent/loop.py,sha256=DsfFywqYtZ6cQdQlSBdGjyX9cl4crvRMC0Ve9At6xSI,14660
43
+ core/agent/prompts.py,sha256=IMcbEObOe7lYchfrV8EEG9djbadVNd-CZNL5eR5PdC0,14240
44
+ core/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
45
+ core/models/finding.py,sha256=fI70eENPr6yALRusDgb0eECQcNotYR6WUnLP2Te6z-8,2803
46
+ core/reports/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
+ core/reports/comparison.py,sha256=NFam-wleVq4fJ3AF_Os-DcIqRhjBFAwD4h4J05gCApU,1730
48
+ core/reports/delivery.py,sha256=3ljRwHQun2gRyMbQiEnyCzXnNMzo0V7J2ttBdkefKEE,10752
49
+ core/reports/export.py,sha256=qxyHiXICNYqvaXp4SH9vb5WmnagOWYKhup4EnLA8vw0,3812
50
+ core/reports/generator.py,sha256=z7fQSoGAMpEjRcfRe4IiYk_LtRPAELr-3nAhmAOYwqM,5365
51
+ core/reports/html.py,sha256=6OJ1-mSySz3fo667FdmURex5UbwONbGakZ9m_fVTr1o,11963
52
+ core/reports/multi_cloud.py,sha256=BlHYvSNBI2e8aaOvIXBRc3s-hiABW04AxvvK0SFpU4M,5448
53
+ entrypoints/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
54
+ entrypoints/aws_lambda.py,sha256=7K1YjJU7MJ2kT5RFWf0rFE7Ml7NpAZvhO8LgyyO9vZI,10630
55
+ entrypoints/azure_function.py,sha256=mK_bfBukPb-JZVb5MrtK0XNm9zvet888GCc4P-b96fI,9738
56
+ entrypoints/cli.py,sha256=nkFtjHiCF2TGHuIEWuP4oI7TzgrNYLwe8ap4pR7vQG4,4856
57
+ entrypoints/gcp_cloudrun.py,sha256=dVqBGc5sd6bJEleIrjMQVaoOTcc5CO0ndJHyGTvMLIk,7513
58
+ argus_cloud_optimizer-0.2.0.dist-info/METADATA,sha256=z9vVUUQRk3wiBWR8WwwYgRWKINe4N4k8PltdoRJsyMo,17293
59
+ argus_cloud_optimizer-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
60
+ argus_cloud_optimizer-0.2.0.dist-info/entry_points.txt,sha256=FiDJzCVQfrUrBj_s90I_IYtOOGui971C8hjBMjvnZII,47
61
+ argus_cloud_optimizer-0.2.0.dist-info/top_level.txt,sha256=geIgqBOobcCsO7BiWtKL3hhrj91M86KlN1oxO9EuCcM,29
62
+ argus_cloud_optimizer-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ argus = entrypoints.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Vamshi Siddarth Gaddam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ adapters
2
+ ai
3
+ core
4
+ entrypoints
core/__init__.py ADDED
File without changes
core/__version__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
core/agent/__init__.py ADDED
File without changes
core/agent/loop.py ADDED
@@ -0,0 +1,390 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ import structlog
9
+
10
+ from adapters.base import CloudAdapter
11
+ from ai.base import AIProvider, Message, Tool, ToolCall, ToolResult
12
+ from core.agent.prompts import build_system_prompt, build_tool_schemas
13
+ from core.config import get_settings
14
+ from core.models.finding import ResourceFinding
15
+ from core.token_tracker import BudgetExceededError, TokenTracker
16
+
17
+ logger = structlog.get_logger(__name__)
18
+
19
+ _PARALLELIZABLE_TOOLS = frozenset({"get_metrics", "get_last_activity"})
20
+
21
+
22
+ class AgentLoop:
23
+ """
24
+ ReAct (Reason + Act) agent loop.
25
+
26
+ The AI decides which tools to call and in what order. This class:
27
+ - Manages the conversation history
28
+ - Dispatches tool calls to the CloudAdapter
29
+ - Terminates when the AI calls submit_findings
30
+ - Never contains cloud-specific logic
31
+ """
32
+
33
+ def __init__(self, ai_provider: AIProvider, cloud_adapter: CloudAdapter) -> None:
34
+ self._ai = ai_provider
35
+ self._adapter = cloud_adapter
36
+ self._tools: list[Tool] = [
37
+ Tool(
38
+ name=t["name"],
39
+ description=t["description"],
40
+ input_schema=t["input_schema"],
41
+ )
42
+ for t in build_tool_schemas()
43
+ ]
44
+
45
+ def run(
46
+ self,
47
+ cloud: str,
48
+ ignore_regions: list[str],
49
+ accounts: list[dict[str, Any]],
50
+ ) -> tuple[list[ResourceFinding], str]:
51
+ """
52
+ Run the full agent analysis for one cloud + account combination.
53
+
54
+ Returns:
55
+ (findings, executive_summary)
56
+ findings: ResourceFinding list sorted by estimated_monthly_cost desc
57
+ executive_summary: AI-written 3-5 sentence summary for managers
58
+ """
59
+ system_prompt = build_system_prompt(
60
+ cloud=cloud, ignore_regions=ignore_regions, accounts=accounts
61
+ )
62
+
63
+ # ------------------------------------------------------------------
64
+ # Phase 0 — pre-filter (outside AI context, no tokens consumed)
65
+ # Fetch all resources + batch cost, then hand only the top-N by cost
66
+ # to the agent. This keeps context small regardless of account size.
67
+ # ------------------------------------------------------------------
68
+ self._prefilter_resources(ignore_regions)
69
+
70
+ settings = get_settings()
71
+ self.tracker = TokenTracker(
72
+ budget_usd=settings.scan.llm_budget_usd,
73
+ provider=settings.ai.provider,
74
+ )
75
+ max_iterations = settings.scan.max_iterations
76
+
77
+ messages: list[Message] = [
78
+ Message(role="user", text="Begin your cloud cost analysis now.")
79
+ ]
80
+
81
+ for iteration in range(1, max_iterations + 1):
82
+ logger.info("agent_iteration", iteration=iteration)
83
+
84
+ response = self._ai.chat(messages, self._tools, system_prompt=system_prompt)
85
+
86
+ try:
87
+ self.tracker.record(response.input_tokens, response.output_tokens)
88
+ except BudgetExceededError:
89
+ logger.warning(
90
+ "budget_exceeded_stopping",
91
+ **self.tracker.summary(),
92
+ )
93
+ return [], (
94
+ f"Scan aborted: LLM budget of "
95
+ f"${self.tracker.budget_usd:.2f} exceeded "
96
+ f"(${self.tracker.estimated_cost_usd:.4f} spent "
97
+ f"after {self.tracker.iteration_count} iterations). "
98
+ f"Increase LLM_BUDGET_USD or reduce MAX_RESOURCES_PER_SCAN."
99
+ )
100
+
101
+ if response.stop_reason == "tool_use":
102
+ # Check first — submit_findings terminates the loop immediately
103
+ for tc in response.tool_calls:
104
+ if tc.name == "submit_findings":
105
+ logger.info(
106
+ "agent_complete",
107
+ findings_count=len(tc.arguments.get("findings", [])),
108
+ **self.tracker.summary(),
109
+ )
110
+ return _parse_findings(tc.arguments, cloud=cloud)
111
+
112
+ # Persist the assistant turn before executing tools
113
+ messages.append(
114
+ Message(
115
+ role="assistant",
116
+ text=response.text,
117
+ tool_calls=response.tool_calls,
118
+ )
119
+ )
120
+
121
+ # Parallel for metrics/activity, sequential for the rest
122
+ tool_results = self._execute_tool_calls(response.tool_calls)
123
+
124
+ messages.append(Message(role="user", tool_results=tool_results))
125
+
126
+ else:
127
+ # end_turn or max_tokens without submit_findings — shouldn't happen
128
+ # if the prompt is working, but handle gracefully
129
+ logger.warning(
130
+ "agent_stopped_without_findings",
131
+ stop_reason=response.stop_reason,
132
+ **self.tracker.summary(),
133
+ )
134
+ return [], response.text or ""
135
+
136
+ raise RuntimeError(
137
+ f"Agent loop exceeded {max_iterations} iterations "
138
+ "without submitting findings. "
139
+ "Check the system prompt or increase MAX_AGENT_ITERATIONS."
140
+ )
141
+
142
+ def _execute_tool_calls(self, tool_calls: list[ToolCall]) -> list[ToolResult]:
143
+ parallel = [tc for tc in tool_calls if tc.name in _PARALLELIZABLE_TOOLS]
144
+ sequential = [tc for tc in tool_calls if tc.name not in _PARALLELIZABLE_TOOLS]
145
+
146
+ results_by_id: dict[str, ToolResult] = {}
147
+
148
+ for tc in sequential:
149
+ content, is_error = self._execute(tc)
150
+ logger.info("tool_executed", tool=tc.name, is_error=is_error)
151
+ results_by_id[tc.id] = ToolResult(
152
+ tool_call_id=tc.id, content=content, is_error=is_error
153
+ )
154
+
155
+ if parallel:
156
+ results_by_id.update(self._execute_parallel(parallel))
157
+
158
+ return [results_by_id[tc.id] for tc in tool_calls]
159
+
160
+ def _execute_parallel(self, tool_calls: list[ToolCall]) -> dict[str, ToolResult]:
161
+ results: dict[str, ToolResult] = {}
162
+ max_workers = min(get_settings().scan.adapter_concurrency, len(tool_calls))
163
+
164
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
165
+ future_to_tc = {executor.submit(self._execute, tc): tc for tc in tool_calls}
166
+ for future in as_completed(future_to_tc):
167
+ tc = future_to_tc[future]
168
+ content, is_error = future.result()
169
+ logger.info(
170
+ "tool_executed", tool=tc.name, is_error=is_error, parallel=True
171
+ )
172
+ results[tc.id] = ToolResult(
173
+ tool_call_id=tc.id, content=content, is_error=is_error
174
+ )
175
+
176
+ return results
177
+
178
+ def _prefilter_resources(self, ignore_regions: list[str]) -> list[dict]:
179
+ """
180
+ Phase 0: enumerate all resources + batch-fetch cost outside the AI loop.
181
+
182
+ Steps:
183
+ 1. list_resources — discovers all billable resources
184
+ 2. get_cost (one batched call) — fetches USD cost for all of them
185
+ 3. Sort by cost descending, keep top get_settings().scan.max_resources
186
+ 4. Build the compact payload that will be handed to the AI on its
187
+ first list_resources call
188
+
189
+ This keeps the AI context bounded regardless of account size:
190
+ - 10K raw resources → ~3K after non-billable filter → top 200 by cost
191
+ - The AI never sees zero-cost noise
192
+ """
193
+ logger.info("prefilter_start")
194
+
195
+ resources = self._adapter.list_resources(ignore_regions=ignore_regions or None)
196
+ resources = _apply_exclusion_filters(resources)
197
+ total_discovered = len(resources)
198
+
199
+ if not resources:
200
+ self._prefiltered_payload = []
201
+ logger.info("prefilter_complete", discovered=0, sent_to_ai=0)
202
+ return []
203
+
204
+ # Batch cost fetch — one API call for all resource IDs
205
+ resource_ids = [r.resource_id for r in resources]
206
+ try:
207
+ costs = self._adapter.get_cost(resource_ids=resource_ids)
208
+ except Exception as exc: # noqa: BLE001
209
+ logger.warning("prefilter_cost_fetch_failed", error=str(exc))
210
+ costs = {}
211
+
212
+ # Attach cost to each resource and sort descending
213
+ resources_with_cost = [(r, costs.get(r.resource_id, 0.0)) for r in resources]
214
+ resources_with_cost.sort(key=lambda x: x[1], reverse=True)
215
+
216
+ # Cap at get_settings().scan.max_resources
217
+ capped = resources_with_cost[:get_settings().scan.max_resources]
218
+ dropped = total_discovered - len(capped)
219
+
220
+ if dropped > 0:
221
+ logger.info(
222
+ "prefilter_capped",
223
+ discovered=total_discovered,
224
+ sent_to_ai=len(capped),
225
+ dropped_zero_cost=dropped,
226
+ cap=get_settings().scan.max_resources,
227
+ )
228
+
229
+ # Build compact payload — include cost so AI doesn't need to call get_cost
230
+ # for the initial triage (it already has it)
231
+ payload = []
232
+ for resource, cost in capped:
233
+ entry = _compress_resource(resource.to_dict())
234
+ if cost > 0.0:
235
+ entry["cost_usd"] = round(cost, 2)
236
+ payload.append(entry)
237
+
238
+ self._prefiltered_payload = payload
239
+
240
+ logger.info(
241
+ "prefilter_complete",
242
+ discovered=total_discovered,
243
+ sent_to_ai=len(payload),
244
+ )
245
+ return payload
246
+
247
+ def _execute(self, tc: ToolCall) -> tuple[str, bool]:
248
+ """Dispatch a tool call to the adapter. Returns (result_str, is_error)."""
249
+ try:
250
+ match tc.name:
251
+ case "list_resources":
252
+ # Return the pre-filtered, cost-sorted list built in Phase 0.
253
+ # The adapter is NOT called again here — avoids a second full
254
+ # Resource Explorer / Asset Inventory scan mid-conversation.
255
+ return (
256
+ json.dumps(
257
+ self._prefiltered_payload,
258
+ default=str,
259
+ separators=(",", ":"),
260
+ ),
261
+ False,
262
+ )
263
+
264
+ case "get_metrics":
265
+ summary = self._adapter.get_metrics(**tc.arguments)
266
+ return (
267
+ json.dumps(
268
+ summary.to_dict(), default=str, separators=(",", ":")
269
+ ),
270
+ False,
271
+ )
272
+
273
+ case "get_cost":
274
+ costs = self._adapter.get_cost(**tc.arguments)
275
+ return json.dumps(costs, default=str, separators=(",", ":")), False
276
+
277
+ case "get_last_activity":
278
+ activity = self._adapter.get_last_activity(**tc.arguments)
279
+ result = activity.isoformat() if activity else "null"
280
+ return result, False
281
+
282
+ case _:
283
+ return f"Unknown tool: {tc.name!r}", True
284
+
285
+ except Exception as exc: # noqa: BLE001
286
+ logger.error("tool_error", tool=tc.name, error=str(exc))
287
+ return f"Tool error: {exc}", True
288
+
289
+
290
+ # ------------------------------------------------------------------
291
+ # Internal helpers
292
+ # ------------------------------------------------------------------
293
+
294
+
295
+ def _apply_exclusion_filters(resources: list[Any]) -> list[Any]:
296
+ exclude_tags = _parse_exclude_tags()
297
+ exclude_types = _parse_exclude_types()
298
+
299
+ if not exclude_tags and not exclude_types:
300
+ return resources
301
+
302
+ filtered = []
303
+ excluded_count = 0
304
+ for r in resources:
305
+ if exclude_types and r.resource_type in exclude_types:
306
+ excluded_count += 1
307
+ continue
308
+ if exclude_tags and _tags_match(r.tags, exclude_tags):
309
+ excluded_count += 1
310
+ continue
311
+ filtered.append(r)
312
+
313
+ if excluded_count > 0:
314
+ logger.info(
315
+ "exclusion_filter_applied",
316
+ excluded=excluded_count,
317
+ remaining=len(filtered),
318
+ )
319
+ return filtered
320
+
321
+
322
+ def _parse_exclude_tags() -> dict[str, str]:
323
+ raw = get_settings().scan.exclude_tags.strip()
324
+ if not raw:
325
+ return {}
326
+ try:
327
+ parsed = json.loads(raw)
328
+ if isinstance(parsed, dict):
329
+ return {str(k): str(v) for k, v in parsed.items()}
330
+ except (json.JSONDecodeError, TypeError):
331
+ logger.warning("exclude_tags_invalid_json", raw=raw)
332
+ return {}
333
+
334
+
335
+ def _parse_exclude_types() -> set[str]:
336
+ return set(get_settings().scan.exclude_resource_types_list)
337
+
338
+
339
+ def _tags_match(resource_tags: dict[str, str], exclude_tags: dict[str, str]) -> bool:
340
+ for key, value in exclude_tags.items():
341
+ if resource_tags.get(key) == value:
342
+ return True
343
+ return False
344
+
345
+
346
+ def _compress_resource(r: dict) -> dict:
347
+ """
348
+ Return a compact resource dict to minimise tokens sent to the AI.
349
+
350
+ Strategy:
351
+ - Shorten key names (resource_id → id, resource_type → type)
352
+ - Truncate long ARNs to their short form for the list view.
353
+ The full ARN is passed back by the AI when it calls get_metrics /
354
+ get_cost / get_last_activity, so the adapter always receives the
355
+ canonical identifier.
356
+ - Drop None / empty values entirely
357
+ - Keep tags only if non-empty (they carry owner/env signals)
358
+ """
359
+ resource_id = r["resource_id"]
360
+ out: dict = {
361
+ "id": resource_id,
362
+ "type": r["resource_type"],
363
+ "region": r["region"],
364
+ }
365
+ if r.get("name"):
366
+ out["name"] = r["name"]
367
+ if r.get("tags"):
368
+ out["tags"] = r["tags"]
369
+ # cloud field is redundant (the AI already knows the cloud from system prompt)
370
+ return out
371
+
372
+
373
+ def _parse_findings(
374
+ args: dict[str, Any],
375
+ cloud: str,
376
+ ) -> tuple[list[ResourceFinding], str]:
377
+ """Convert the AI's submit_findings arguments into ResourceFinding objects."""
378
+ scan_time = datetime.now(tz=timezone.utc)
379
+ raw_findings: list[dict] = args.get("findings", [])
380
+ executive_summary: str = args.get("executive_summary", "")
381
+
382
+ findings = [
383
+ ResourceFinding.from_dict({**f, "cloud": f.get("cloud", cloud)}, scan_time)
384
+ for f in raw_findings
385
+ ]
386
+
387
+ # Ensure descending cost order regardless of what the AI returned
388
+ findings.sort(key=lambda f: f.estimated_monthly_cost, reverse=True)
389
+
390
+ return findings, executive_summary