adloop 0.15.0__tar.gz → 0.16.0__tar.gz

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 (60) hide show
  1. {adloop-0.15.0 → adloop-0.16.0}/PKG-INFO +1 -1
  2. {adloop-0.15.0 → adloop-0.16.0}/pyproject.toml +1 -1
  3. {adloop-0.15.0 → adloop-0.16.0}/pyproject.toml.orig +1 -1
  4. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/ads/conversion_actions.py +1 -0
  5. adloop-0.16.0/src/adloop/ads/validate_only.py +115 -0
  6. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/ads/write.py +171 -44
  7. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/rules/adloop.md +3 -0
  8. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/server.py +14 -3
  9. {adloop-0.15.0 → adloop-0.16.0}/README.md +0 -0
  10. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/__init__.py +0 -0
  11. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/__main__.py +0 -0
  12. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/_mcp_patches.py +0 -0
  13. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/ads/__init__.py +0 -0
  14. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/ads/client.py +0 -0
  15. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/ads/currency.py +0 -0
  16. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/ads/enums.py +0 -0
  17. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/ads/forecast.py +0 -0
  18. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/ads/gaql.py +0 -0
  19. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/ads/pmax.py +0 -0
  20. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/ads/read.py +0 -0
  21. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/auth.py +0 -0
  22. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/cli.py +0 -0
  23. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/config.py +0 -0
  24. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/crossref.py +0 -0
  25. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/diagnostics.py +0 -0
  26. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/ga4/__init__.py +0 -0
  27. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/ga4/client.py +0 -0
  28. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/ga4/reports.py +0 -0
  29. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/ga4/tracking.py +0 -0
  30. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/ga4/write.py +0 -0
  31. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/gsc/__init__.py +0 -0
  32. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/gsc/client.py +0 -0
  33. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/gsc/reports.py +0 -0
  34. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/gtm/__init__.py +0 -0
  35. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/gtm/client.py +0 -0
  36. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/gtm/read.py +0 -0
  37. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/merchant/__init__.py +0 -0
  38. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/merchant/client.py +0 -0
  39. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/merchant/read.py +0 -0
  40. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/pagespeed.py +0 -0
  41. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/reddit/__init__.py +0 -0
  42. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/reddit/auth.py +0 -0
  43. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/reddit/client.py +0 -0
  44. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/reddit/read.py +0 -0
  45. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/reddit/schedule.py +0 -0
  46. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/reddit/write.py +0 -0
  47. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/rules/__init__.py +0 -0
  48. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  49. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  50. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/rules/commands/create-ad.md +0 -0
  51. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  52. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  53. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  54. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/rules_install.py +0 -0
  55. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/runtime.py +0 -0
  56. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/safety/__init__.py +0 -0
  57. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/safety/audit.py +0 -0
  58. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/safety/guards.py +0 -0
  59. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/safety/preview.py +0 -0
  60. {adloop-0.15.0 → adloop-0.16.0}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.15.0
3
+ Version: 0.16.0
4
4
  Summary: The AI command center for Google Ads, Reddit Ads, GA4, and tracking code.
5
5
  Keywords: mcp,google-ads,reddit-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.15.0"
3
+ version = "0.16.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.15.0"
3
+ version = "0.16.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -430,6 +430,7 @@ def draft_remove_conversion_action(
430
430
  entity_id=str(conversion_action_id),
431
431
  customer_id=customer_id,
432
432
  changes={"conversion_action_id": str(conversion_action_id)},
433
+ requires_double_confirm=True,
433
434
  )
434
435
  store_plan(plan)
435
436
  preview = plan.to_preview()
@@ -0,0 +1,115 @@
1
+ """Run a Google Ads plan in validate-only mode for ``confirm_and_apply`` dry runs.
2
+
3
+ The dry run used to echo the plan back without asking Google anything, so a
4
+ change the API would reject only failed at the real apply. Wrapping the Ads
5
+ client here sends every mutate the plan would send with ``validate_only=True``
6
+ instead: Google checks the full request (field values, policy, references to
7
+ existing entities) and executes nothing.
8
+
9
+ Validate-only responses carry no results, so each validated call answers the
10
+ apply code with placeholder resource names. A later call that references one
11
+ (a plan whose second step uses what the first would have created) cannot be
12
+ validated meaningfully; it is skipped and counted instead of sent.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from types import SimpleNamespace
18
+
19
+ PLACEHOLDER = "adloop-validate-only"
20
+
21
+ _RESPONSE_FIELD_FOR_SERVICE = "campaign_result"
22
+
23
+
24
+ class ValidateOnlyFailure(Exception):
25
+ """Google rejected part of a validate-only request (partial failure)."""
26
+
27
+
28
+ class ValidateOnlyClient:
29
+ """A GoogleAdsClient stand-in whose services only ever validate.
30
+
31
+ Everything except ``get_service`` passes through (enums, ``get_type``),
32
+ and every service method except ``mutate*`` passes through too, so the
33
+ reads some apply paths do before mutating still run normally.
34
+ """
35
+
36
+ def __init__(self, client: object) -> None:
37
+ self._client = client
38
+ self.validated_calls = 0
39
+ self.skipped_calls = 0
40
+
41
+ def __getattr__(self, name: str) -> object:
42
+ return getattr(self._client, name)
43
+
44
+ def get_service(self, name: str, *args: object, **kwargs: object) -> object:
45
+ return _ValidateOnlyService(self, name, self._client.get_service(name, *args, **kwargs))
46
+
47
+
48
+ class _ValidateOnlyService:
49
+ def __init__(self, owner: ValidateOnlyClient, name: str, service: object) -> None:
50
+ self._owner = owner
51
+ self._name = name
52
+ self._service = service
53
+
54
+ def __getattr__(self, attr: str) -> object:
55
+ target = getattr(self._service, attr)
56
+ if not attr.startswith("mutate"):
57
+ return target
58
+
59
+ def validate(request: object = None, **kwargs: object) -> object:
60
+ if request is None:
61
+ request = self._build_request(attr, kwargs)
62
+ request.validate_only = True
63
+
64
+ operations = list(_operations_of(request))
65
+ if PLACEHOLDER in str(request):
66
+ self._owner.skipped_calls += 1
67
+ else:
68
+ response = target(request=request)
69
+ self._owner.validated_calls += 1
70
+ failure = getattr(response, "partial_failure_error", None)
71
+ if failure is not None and getattr(failure, "code", 0):
72
+ raise ValidateOnlyFailure(getattr(failure, "message", "") or str(failure))
73
+
74
+ return _placeholder_response(request.customer_id, len(operations), attr == "mutate")
75
+
76
+ return validate
77
+
78
+ def _build_request(self, method: str, kwargs: dict) -> object:
79
+ request = self._owner._client.get_type(_request_type(self._name, method))
80
+ for key, value in kwargs.items():
81
+ if key in ("operations", "mutate_operations"):
82
+ getattr(request, key).extend(value)
83
+ else:
84
+ setattr(request, key, value)
85
+ return request
86
+
87
+
88
+ def _request_type(service_name: str, method: str) -> str:
89
+ """``mutate_ad_group_criteria`` -> ``MutateAdGroupCriteriaRequest``."""
90
+ if method == "mutate":
91
+ return "Mutate" + service_name.removesuffix("Service") + "Request"
92
+ words = method.removeprefix("mutate_").split("_")
93
+ return "Mutate" + "".join(word.capitalize() for word in words) + "Request"
94
+
95
+
96
+ def _operations_of(request: object) -> object:
97
+ if hasattr(request, "mutate_operations"):
98
+ return request.mutate_operations
99
+ return getattr(request, "operations", [])
100
+
101
+
102
+ def _placeholder_response(customer_id: str, count: int, googleads_mutate: bool) -> object:
103
+ names = [f"customers/{customer_id}/{PLACEHOLDER}/{i}" for i in range(count)]
104
+ if googleads_mutate:
105
+ return SimpleNamespace(
106
+ mutate_operation_responses=[
107
+ SimpleNamespace(**{_RESPONSE_FIELD_FOR_SERVICE: SimpleNamespace(resource_name=n)})
108
+ for n in names
109
+ ],
110
+ partial_failure_error=None,
111
+ )
112
+ return SimpleNamespace(
113
+ results=[SimpleNamespace(resource_name=n) for n in names],
114
+ partial_failure_error=None,
115
+ )
@@ -669,8 +669,13 @@ def update_responsive_search_ad(
669
669
  if errors:
670
670
  return {"error": "Validation failed", "details": errors}
671
671
 
672
+ url_warnings_found: list[str] = []
672
673
  if has_url_change:
673
- url_check = _validate_urls([final_url])
674
+ # _validate_urls returns (errors, warnings) — the same contract the
675
+ # create path and the sitelink path already unpack. Treating the tuple
676
+ # as a dict made every URL-only update fail with
677
+ # "'tuple' object has no attribute 'get'" before a plan existed.
678
+ url_check, url_warnings = _validate_urls([final_url])
674
679
  if url_check.get(final_url):
675
680
  return {
676
681
  "error": "URL validation failed",
@@ -679,6 +684,10 @@ def update_responsive_search_ad(
679
684
  f"{url_check[final_url]}. Ads MUST point to working URLs."
680
685
  ],
681
686
  }
687
+ if url_warnings.get(final_url):
688
+ url_warnings_found.append(
689
+ f"final_url '{final_url}': {url_warnings[final_url]}"
690
+ )
682
691
 
683
692
  changes: dict = {"ad_id": str(ad_id)}
684
693
  if has_url_change:
@@ -703,8 +712,10 @@ def update_responsive_search_ad(
703
712
  preview = plan.to_preview()
704
713
  # Only warn when the creative text is actually being replaced — URL/path
705
714
  # edits do not trigger the learning reset or policy re-review.
715
+ if url_warnings_found:
716
+ preview.setdefault("warnings", []).extend(url_warnings_found)
706
717
  if has_headlines_change or has_descriptions_change:
707
- preview["warnings"] = [_RSA_TEXT_UPDATE_WARNING]
718
+ preview.setdefault("warnings", []).append(_RSA_TEXT_UPDATE_WARNING)
708
719
  return preview
709
720
 
710
721
 
@@ -1232,7 +1243,11 @@ def update_ad_group(
1232
1243
  max_cpc: float = 0,
1233
1244
  ) -> dict:
1234
1245
  """Draft an ad group update for name and manual CPC bid."""
1235
- from adloop.safety.guards import SafetyViolation, check_blocked_operation
1246
+ from adloop.safety.guards import (
1247
+ SafetyViolation,
1248
+ check_bid_increase,
1249
+ check_blocked_operation,
1250
+ )
1236
1251
  from adloop.safety.preview import ChangePlan, store_plan
1237
1252
 
1238
1253
  try:
@@ -1276,6 +1291,12 @@ def update_ad_group(
1276
1291
  f"target governs spend under automated bidding. "
1277
1292
  f"No change made."
1278
1293
  )
1294
+ else:
1295
+ current_bid = _current_ad_group_cpc_bid(config, customer_id, ad_group_id)
1296
+ try:
1297
+ check_bid_increase(current_bid or 0, max_cpc, config.safety)
1298
+ except SafetyViolation as e:
1299
+ errors.append(str(e))
1279
1300
 
1280
1301
  has_any_change = bool(ad_group_name.strip() or max_cpc)
1281
1302
  if not has_any_change:
@@ -1384,6 +1405,9 @@ def remove_entity(
1384
1405
  entity_id=entity_id,
1385
1406
  customer_id=customer_id,
1386
1407
  changes={"action": "REMOVE"},
1408
+ # Removal is irreversible; the preview asks for a second explicit
1409
+ # confirmation, as Reddit removals already did.
1410
+ requires_double_confirm=True,
1387
1411
  )
1388
1412
  store_plan(plan)
1389
1413
  return plan.to_preview()
@@ -1574,6 +1598,7 @@ def update_campaign(
1574
1598
  """
1575
1599
  from adloop.safety.guards import (
1576
1600
  SafetyViolation,
1601
+ check_bid_increase,
1577
1602
  check_blocked_operation,
1578
1603
  check_budget_cap,
1579
1604
  )
@@ -1628,6 +1653,14 @@ def update_campaign(
1628
1653
  errors.append("campaign_id was not found")
1629
1654
  elif strategy_for_cap != "TARGET_SPEND":
1630
1655
  errors.append("max_cpc requires TARGET_SPEND bidding_strategy")
1656
+ else:
1657
+ # Only a ceiling that already exists has a baseline; a campaign
1658
+ # switching to Maximize Clicks has none to compare against.
1659
+ current_ceiling = _current_campaign_cpc_ceiling(config, customer_id, campaign_id)
1660
+ try:
1661
+ check_bid_increase(current_ceiling or 0, max_cpc, config.safety)
1662
+ except SafetyViolation as e:
1663
+ errors.append(str(e))
1631
1664
 
1632
1665
  has_any_change = any([
1633
1666
  bs,
@@ -1957,14 +1990,23 @@ def _extract_error_message(exc: Exception) -> str:
1957
1990
  if isinstance(exc, GoogleAdsException) and exc.failure:
1958
1991
  parts = []
1959
1992
  for error in exc.failure.errors:
1960
- error_code = error.error_code
1961
- code_field = error_code.WhichOneof("error_code")
1962
- code_value = getattr(error_code, code_field) if code_field else "UNKNOWN"
1963
- line = f"[{code_field}={code_value.name if hasattr(code_value, 'name') else code_value}]"
1964
- if error.message:
1965
- line += f" {error.message}"
1966
- if error.trigger and error.trigger.string_value:
1967
- line += f" (trigger: {error.trigger.string_value})"
1993
+ # The client returns proto-plus messages, on which WhichOneof
1994
+ # does not exist; read the underlying protobuf instead.
1995
+ pb = type(error).pb(error) if hasattr(type(error), "pb") else error
1996
+ code_field = pb.error_code.WhichOneof("error_code")
1997
+ code_name = "UNKNOWN"
1998
+ if code_field:
1999
+ number = getattr(pb.error_code, code_field)
2000
+ enum = pb.error_code.DESCRIPTOR.fields_by_name[code_field].enum_type
2001
+ value = enum.values_by_number.get(number)
2002
+ code_name = value.name if value else str(number)
2003
+ line = f"[{code_field}={code_name}]"
2004
+ if pb.message:
2005
+ line += f" {pb.message}"
2006
+ if pb.HasField("trigger"):
2007
+ kind = pb.trigger.WhichOneof("value")
2008
+ if kind:
2009
+ line += f" (trigger: {getattr(pb.trigger, kind)})"
1968
2010
  parts.append(line)
1969
2011
  if parts:
1970
2012
  msg = "; ".join(parts)
@@ -2008,39 +2050,49 @@ def confirm_and_apply(
2008
2050
 
2009
2051
  if dry_run:
2010
2052
  preflight_checks: dict | None = None
2011
- if is_reddit:
2012
- # Reddit has no validate-only mode; the dry run re-reads the
2013
- # target and re-runs the safety caps against live values. A
2014
- # failed preflight leaves dry_run_result unset so two-phase
2015
- # apply keeps refusing the real write.
2016
- from adloop.reddit.write import preflight
2053
+ validation: dict | None = None
2054
+ # Either way a failed check leaves dry_run_result unset, so two-phase
2055
+ # apply keeps refusing the real write.
2056
+ try:
2057
+ if is_reddit:
2058
+ # Reddit has no validate-only mode; the dry run re-reads the
2059
+ # target and re-runs the safety caps against live values.
2060
+ from adloop.reddit.write import preflight
2017
2061
 
2018
- try:
2019
2062
  preflight_checks = preflight(config, plan)
2020
- except Exception as e:
2021
- error_message = _extract_error_message(e)
2022
- log_mutation(
2023
- config.safety.log_file,
2024
- operation=plan.operation,
2025
- customer_id=plan.customer_id,
2026
- entity_type=plan.entity_type,
2027
- entity_id=plan.entity_id,
2028
- changes=plan.changes,
2029
- dry_run=True,
2030
- result="dry_run_failed",
2031
- error=error_message,
2032
- )
2033
- return {
2034
- "status": "DRY_RUN_FAILED",
2035
- "plan_id": plan.plan_id,
2036
- "operation": plan.operation,
2037
- "error": error_message,
2038
- "message": (
2039
- "The dry run re-checked the target against Reddit and "
2040
- "found a problem; nothing was sent. Fix the cause and "
2041
- "draft again."
2042
- ),
2043
- }
2063
+ elif plan.operation != "create_key_event":
2064
+ # Google Ads checks the exact mutates with validate_only=True
2065
+ # and executes nothing.
2066
+ validation = _validate_with_google(config, plan)
2067
+ except Exception as e:
2068
+ error_message = _extract_error_message(e)
2069
+ log_mutation(
2070
+ config.safety.log_file,
2071
+ operation=plan.operation,
2072
+ customer_id=plan.customer_id,
2073
+ entity_type=plan.entity_type,
2074
+ entity_id=plan.entity_id,
2075
+ changes=plan.changes,
2076
+ dry_run=True,
2077
+ result="dry_run_failed",
2078
+ error=error_message,
2079
+ )
2080
+ checked_against = (
2081
+ "re-checked the target against Reddit"
2082
+ if is_reddit
2083
+ else "sent the change to Google Ads in validate-only mode"
2084
+ )
2085
+ return {
2086
+ "status": "DRY_RUN_FAILED",
2087
+ "plan_id": plan.plan_id,
2088
+ "operation": plan.operation,
2089
+ "error": error_message,
2090
+ "message": (
2091
+ f"The dry run {checked_against} and found a problem; "
2092
+ "nothing was changed. The real apply would fail the same "
2093
+ "way. Fix the cause and draft again."
2094
+ ),
2095
+ }
2044
2096
  log_mutation(
2045
2097
  config.safety.log_file,
2046
2098
  operation=plan.operation,
@@ -2074,6 +2126,18 @@ def confirm_and_apply(
2074
2126
  "Reddit Ads has no validate-only mode: the dry run re-read the "
2075
2127
  "target and re-checked the safety caps; nothing was sent."
2076
2128
  )
2129
+ if validation is not None:
2130
+ response["checks"] = validation
2131
+ response["note"] = (
2132
+ "Google Ads validated this exact change (validate_only) and "
2133
+ "executed nothing."
2134
+ )
2135
+ if validation["skipped_calls"]:
2136
+ response["note"] += (
2137
+ f" {validation['skipped_calls']} later step(s) build on "
2138
+ "objects an earlier step would create, so Google could "
2139
+ "only validate the step(s) before them."
2140
+ )
2077
2141
  if forced_by_config:
2078
2142
  # The caller passed dry_run=false but safety.require_dry_run
2079
2143
  # forced it back on. Tell them exactly why and how to unlock
@@ -2257,6 +2321,38 @@ def _existing_negative_geo_exclusions(
2257
2321
  return ids
2258
2322
 
2259
2323
 
2324
+ def _current_ad_group_cpc_bid(
2325
+ config: AdLoopConfig, customer_id: str, ad_group_id: str
2326
+ ) -> float | None:
2327
+ """The ad group's current manual CPC bid in account currency, or None."""
2328
+ from adloop.ads.gaql import execute_query
2329
+
2330
+ rows = execute_query(config, customer_id, f"""
2331
+ SELECT ad_group.cpc_bid_micros
2332
+ FROM ad_group
2333
+ WHERE ad_group.id = {ad_group_id}
2334
+ LIMIT 1
2335
+ """)
2336
+ micros = rows[0].get("ad_group.cpc_bid_micros") if rows else None
2337
+ return int(micros) / 1_000_000 if micros else None
2338
+
2339
+
2340
+ def _current_campaign_cpc_ceiling(
2341
+ config: AdLoopConfig, customer_id: str, campaign_id: str
2342
+ ) -> float | None:
2343
+ """The campaign's current Maximize Clicks CPC ceiling, or None if unset."""
2344
+ from adloop.ads.gaql import execute_query
2345
+
2346
+ rows = execute_query(config, customer_id, f"""
2347
+ SELECT campaign.target_spend.cpc_bid_ceiling_micros
2348
+ FROM campaign
2349
+ WHERE campaign.id = {campaign_id}
2350
+ LIMIT 1
2351
+ """)
2352
+ micros = rows[0].get("campaign.target_spend.cpc_bid_ceiling_micros") if rows else None
2353
+ return int(micros) / 1_000_000 if micros else None
2354
+
2355
+
2260
2356
  def _ad_group_campaign_bidding_strategy(
2261
2357
  config: AdLoopConfig, customer_id: str, ad_group_id: str
2262
2358
  ) -> str | None:
@@ -2851,8 +2947,20 @@ def _extract_resource_name(resp: object) -> str:
2851
2947
  return ""
2852
2948
 
2853
2949
 
2854
- def _execute_plan(config: AdLoopConfig, plan: object) -> dict:
2855
- """Dispatch to the right API call based on plan.operation."""
2950
+ def _validate_with_google(config: AdLoopConfig, plan: object) -> dict:
2951
+ """Send a Google Ads plan with validate_only=True; raises if Google rejects it."""
2952
+ return _execute_plan(config, plan, validate_only=True)
2953
+
2954
+
2955
+ def _execute_plan(
2956
+ config: AdLoopConfig, plan: object, *, validate_only: bool = False
2957
+ ) -> dict:
2958
+ """Dispatch to the right API call based on plan.operation.
2959
+
2960
+ With ``validate_only`` the plan's Google Ads mutates are sent with
2961
+ ``validate_only=True`` (nothing executes) and the return value reports
2962
+ how many calls Google validated; any rejection raises.
2963
+ """
2856
2964
  from adloop.ads.client import get_ads_client, normalize_customer_id
2857
2965
 
2858
2966
  # Reddit plans have their own executors and never touch Google: the
@@ -2873,6 +2981,25 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict:
2873
2981
  client = get_ads_client(config)
2874
2982
  cid = normalize_customer_id(plan.customer_id)
2875
2983
 
2984
+ if validate_only:
2985
+ from adloop.ads.validate_only import ValidateOnlyClient
2986
+
2987
+ validator = ValidateOnlyClient(client)
2988
+ result = _dispatch_ads_plan(validator, cid, plan)
2989
+ # Multi-step applies catch their own errors and report them in the
2990
+ # result instead of raising; in a dry run that is still a failure.
2991
+ if isinstance(result, dict) and result.get("error"):
2992
+ raise ValueError(result["error"])
2993
+ return {
2994
+ "validated_calls": validator.validated_calls,
2995
+ "skipped_calls": validator.skipped_calls,
2996
+ }
2997
+
2998
+ return _dispatch_ads_plan(client, cid, plan)
2999
+
3000
+
3001
+ def _dispatch_ads_plan(client: object, cid: str, plan: object) -> dict:
3002
+ """Run a Google Ads plan against ``client`` (real or validate-only)."""
2876
3003
  # Conversion-action CRUD lives in its own module; import lazily so the
2877
3004
  # dispatch table (and this module) don't take the dependency at import time.
2878
3005
  from adloop.ads.conversion_actions import (
@@ -244,6 +244,9 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform:
244
244
  - `update_campaign` replaces POSITIVE geo/language targets entirely (not append). Pass the full desired list. NEGATIVE geo exclusions (criteria with `negative=TRUE`) are **preserved** across a positive-geo replacement — they survive the swap. The preview surfaces preserved negative geo IDs in `preserved_negative_geo_target_ids` so the change is auditable. To ADD negative geo exclusions (e.g. exclude a city inside a targeted state), use `add_negative_locations` — do not try to express exclusions through `geo_target_ids`. To remove a negative geo exclusion explicitly, use `remove_entity` with `entity_type="campaign_criterion"`.
245
245
  - `remove_entity` is IRREVERSIBLE — always prefer `pause_entity` unless the user explicitly wants permanent removal. Removal triggers double confirmation in the safety layer.
246
246
  - `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "shared_criterion", "ad_group_criterion", "campaign_criterion", "campaign_asset", "asset", "customer_asset". Use "negative_keyword" to remove campaign-level negative keywords. Use "shared_criterion" to remove a keyword from a shared negative keyword list — the `entity_id` format is "sharedSetId~criterionId" (use the `resource_id` field from `get_negative_keyword_list_keywords`). Use "ad_group_criterion" or "campaign_criterion" to remove demographic targeting (age/gender/parental/income) — pass the `remove_id` returned by `get_demographic_targeting`. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "asset" to remove a standalone asset. Use "customer_asset" to remove a customer-level asset link.
247
+ - A Google Ads `dry_run=true` sends the exact change to Google with `validate_only` — Google checks it and executes nothing. `DRY_RUN_FAILED` carries Google's error and means the real apply would fail the same way; fix the cause and draft again. Steps that build on an object an earlier step would create (e.g. keywords for a new negative list) are counted in `checks.skipped_calls` instead of validated.
248
+ - `max_bid_increase_pct` (default 100) applies to Google Ads bid raises too: `update_ad_group` max_cpc on MANUAL_CPC and `update_campaign` max_cpc on an existing Maximize Clicks ceiling. A first-time ceiling has no baseline and is not capped.
249
+ - New Google Ads ad groups are created enabled; they cannot serve until one of their ads (created PAUSED) is enabled.
247
250
  - `require_dry_run: true` in config overrides `dry_run=false` — the user must change the config to allow real mutations.
248
251
  - `two_phase_apply: true` in config (always on for AdLoop Cloud) refuses `dry_run=false` with status `DRY_RUN_REQUIRED` until that plan_id has completed one `dry_run=true` pass. Run the dry run, show the user, then apply — do not retry `dry_run=false` in a loop.
249
252
  - All operations (including dry runs) are logged to `~/.adloop/audit.log`.
@@ -110,8 +110,13 @@ def _build_orchestration_instructions() -> str:
110
110
  "`dry_run=false` after the user explicitly approves the preview. "
111
111
  "`require_dry_run` in config can override this.\n"
112
112
  "- Respect the config's `max_daily_budget` cap.\n"
113
- "- New campaigns, ad groups, RSAs and Reddit ads are created PAUSED. "
113
+ "- New Google Ads campaigns and RSAs, and every new Reddit campaign, "
114
+ "ad group and ad, are created PAUSED. A new Google Ads ad group is "
115
+ "enabled but cannot serve until one of its (paused) ads is enabled. "
114
116
  "The user must enable them after review.\n"
117
+ "- A Google Ads dry run sends the exact change to Google with "
118
+ "validate_only (nothing executes); DRY_RUN_FAILED means the real "
119
+ "apply would fail the same way.\n"
115
120
  "- One change at a time — don't batch unrelated writes.\n\n"
116
121
  "PRE-WRITE CHECKS (before any `draft_*`):\n"
117
122
  "- BROAD match keywords require Smart Bidding (MAXIMIZE_CONVERSIONS, "
@@ -2273,9 +2278,15 @@ def confirm_and_apply(
2273
2278
  DRY_RUN_REQUIRED until this plan_id has completed one dry_run=true
2274
2279
  pass. Run the dry run, show it to the user, then apply for real.
2275
2280
 
2281
+ Google Ads plans: the dry run sends the exact mutates to Google with
2282
+ validate_only=True, so Google checks the change and executes nothing
2283
+ (`checks` counts the validated calls). Steps that build on an object an
2284
+ earlier step would create cannot be validated and are counted as skipped.
2285
+
2276
2286
  Reddit plans: Reddit has no validate-only mode, so the dry run re-reads
2277
- the target entity and re-checks the safety caps (returned as `checks`);
2278
- a DRY_RUN_FAILED result means the real apply would also fail.
2287
+ the target entity and re-checks the safety caps (returned as `checks`).
2288
+
2289
+ Either way, a DRY_RUN_FAILED result means the real apply would also fail.
2279
2290
 
2280
2291
  The plan_id comes from a prior draft_* or pause/enable tool call.
2281
2292
  """
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes