adloop 0.33.0__tar.gz → 0.33.1__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 (72) hide show
  1. {adloop-0.33.0 → adloop-0.33.1}/PKG-INFO +1 -1
  2. {adloop-0.33.0 → adloop-0.33.1}/pyproject.toml +1 -1
  3. {adloop-0.33.0 → adloop-0.33.1}/pyproject.toml.orig +1 -1
  4. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/rules/adloop.md +1 -1
  5. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/safety/approval.py +69 -0
  6. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/safety/preview.py +5 -0
  7. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/server.py +11 -2
  8. {adloop-0.33.0 → adloop-0.33.1}/README.md +0 -0
  9. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/__init__.py +0 -0
  10. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/__main__.py +0 -0
  11. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/_mcp_patches.py +0 -0
  12. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/__init__.py +0 -0
  13. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/ai_max.py +0 -0
  14. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/audiences.py +0 -0
  15. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/brands.py +0 -0
  16. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/client.py +0 -0
  17. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/conversion_actions.py +0 -0
  18. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/currency.py +0 -0
  19. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/custom_conversion_goals.py +0 -0
  20. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/enums.py +0 -0
  21. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/forecast.py +0 -0
  22. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/gaql.py +0 -0
  23. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/image_fetch.py +0 -0
  24. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/pmax.py +0 -0
  25. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/read.py +0 -0
  26. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/shopping.py +0 -0
  27. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/validate_only.py +0 -0
  28. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ads/write.py +0 -0
  29. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/auth.py +0 -0
  30. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/cli.py +0 -0
  31. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/config.py +0 -0
  32. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/crossref.py +0 -0
  33. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/diagnostics.py +0 -0
  34. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ga4/__init__.py +0 -0
  35. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ga4/client.py +0 -0
  36. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ga4/reports.py +0 -0
  37. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ga4/tracking.py +0 -0
  38. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/ga4/write.py +0 -0
  39. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/gsc/__init__.py +0 -0
  40. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/gsc/client.py +0 -0
  41. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/gsc/reports.py +0 -0
  42. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/gtm/__init__.py +0 -0
  43. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/gtm/client.py +0 -0
  44. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/gtm/entities.py +0 -0
  45. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/gtm/read.py +0 -0
  46. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/gtm/write.py +0 -0
  47. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/merchant/__init__.py +0 -0
  48. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/merchant/client.py +0 -0
  49. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/merchant/read.py +0 -0
  50. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/pagespeed.py +0 -0
  51. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/reddit/__init__.py +0 -0
  52. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/reddit/auth.py +0 -0
  53. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/reddit/client.py +0 -0
  54. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/reddit/read.py +0 -0
  55. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/reddit/schedule.py +0 -0
  56. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/reddit/write.py +0 -0
  57. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/rules/__init__.py +0 -0
  58. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/rules/commands/analyze-performance.md +0 -0
  59. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/rules/commands/budget-plan.md +0 -0
  60. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/rules/commands/create-ad.md +0 -0
  61. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/rules/commands/create-campaign.md +0 -0
  62. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  63. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  64. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/rules_install.py +0 -0
  65. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/runtime.py +0 -0
  66. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/safety/__init__.py +0 -0
  67. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/safety/approval_card.html +0 -0
  68. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/safety/audit.py +0 -0
  69. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/safety/guards.py +0 -0
  70. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/tracking.py +0 -0
  71. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/upload_card.html +0 -0
  72. {adloop-0.33.0 → adloop-0.33.1}/src/adloop/uploads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.33.0
3
+ Version: 0.33.1
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.33.0"
3
+ version = "0.33.1"
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.33.0"
3
+ version = "0.33.1"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -345,7 +345,7 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform:
345
345
 
346
346
  **Approval cards (Claude, ChatGPT, VS Code and other apps with MCP Apps):** when the preview comes back with `approval: "card"`, the user sees it as an AdLoop approval card with Test run, Apply and Discard buttons, and approves there. Summarize the change and its warnings, then let the user decide on the card instead of asking for a "yes" in the chat. In those apps `confirm_and_apply(dry_run=false)` is refused with status `APPROVE_ON_CARD` (a dry run still works), because only the click applies; the AI cannot press the button. Do not retry. If the user says the card is not visible, tell them the workspace owner can allow approval in chat (AdLoop Cloud: Settings → Safety; self-hosted: `safety.allow_chat_approval`). After a click the card posts a message such as "Applied on the AdLoop approval card: ..."; continue from there (e.g. remind them to enable a new paused ad).
347
347
 
348
- **Confirmation dialogs (apps without cards that support elicitation):** when you call `confirm_and_apply(dry_run=false)` after the user's go-ahead, AdLoop itself asks the user in the app's confirmation dialog before anything changes; irreversible changes need an extra "cannot be undone" tick there. You do not answer that dialog. `NOT_CONFIRMED` means the user declined or closed it: nothing was changed, the preview stays pending, so ask what they want instead of retrying. Apps with neither cards nor dialogs keep the chat flow above.
348
+ **Confirmation dialogs (apps without cards that support elicitation):** when you call `confirm_and_apply(dry_run=false)` after the user's go-ahead, AdLoop itself asks the user in the app's confirmation dialog before anything changes; irreversible changes need an extra "cannot be undone" tick there. You do not answer that dialog. `NOT_CONFIRMED` means the user declined or closed it: nothing was changed, the preview stays pending, so ask what they want instead of retrying. A dry run's `next_step` says when a dialog will follow, so tell the user to look out for it. `CONFIRMATION_UNANSWERED` means the app received the dialog but never returned an answer (some headless agents and IDE setups declare dialogs they cannot show): nothing was changed; ask the user whether a dialog appeared, and if their app cannot show one, relay the message's ways out (approval in chat, or an app with the approval card). Apps with neither cards nor dialogs keep the chat flow above.
349
349
 
350
350
  **Safety behaviors:**
351
351
  - New campaigns and RSAs (and every Reddit campaign, ad group and ad) are created as PAUSED — user must explicitly enable them after review.
@@ -335,6 +335,64 @@ def _not_confirmed(plan: ChangePlan) -> dict:
335
335
  }
336
336
 
337
337
 
338
+ def _dialog_unanswered(plan: ChangePlan) -> dict:
339
+ return {
340
+ "status": "CONFIRMATION_UNANSWERED",
341
+ "plan_id": plan.plan_id,
342
+ "operation": plan.operation,
343
+ "message": (
344
+ "AdLoop asked the user to confirm this change in a dialog of their app, "
345
+ "but the app never returned an answer, so nothing was changed and the "
346
+ "plan stays pending. Ask the user whether a confirmation dialog appeared. "
347
+ "If it did, call confirm_and_apply again and have them answer it. If their "
348
+ "app cannot show dialogs (for example a headless agent or a script), the "
349
+ "workspace owner can allow approval in chat (AdLoop Cloud: Settings -> "
350
+ "Safety -> 'Also allow approval in chat'; self-hosted: "
351
+ "safety.allow_chat_approval: true), or the change can be applied from an "
352
+ "app that shows AdLoop's approval card, such as Claude or ChatGPT. Calling "
353
+ "confirm_and_apply again sends the dialog once more."
354
+ ),
355
+ }
356
+
357
+
358
+ def _mark_dialog(plan: ChangePlan, open_: bool) -> None:
359
+ """Record (or clear) that a dialog for this plan awaits an answer."""
360
+ from datetime import datetime, timezone
361
+
362
+ from adloop.safety.preview import store_plan
363
+
364
+ plan.dialog_requested_at = datetime.now(timezone.utc).isoformat() if open_ else ""
365
+ store_plan(plan)
366
+
367
+
368
+ def dialog_expected(plan_id: str) -> bool:
369
+ """Whether a real apply of this plan will ask the user in a dialog.
370
+
371
+ Used after a successful dry run, so the AI can tell the user to look out
372
+ for it instead of letting an unanswered dialog look like a hang.
373
+ """
374
+ if _dialog_applies(plan_id) is None:
375
+ return False
376
+ try:
377
+ from fastmcp.server.dependencies import get_context
378
+
379
+ if get_context()._is_modern_protocol():
380
+ return True
381
+ except Exception:
382
+ return False
383
+ from adloop.runtime import deployment_mode
384
+
385
+ # Older protocols get the dialog only self-hosted (see confirm_in_dialog).
386
+ return deployment_mode() != "server"
387
+
388
+
389
+ DIALOG_NOTE = (
390
+ "The real apply (dry_run=false) asks the user to confirm in a dialog shown by "
391
+ "their app; tell them to look out for it and answer it. If no dialog appears, "
392
+ "the call returns CONFIRMATION_UNANSWERED on the next attempt with the ways out."
393
+ )
394
+
395
+
338
396
  def _dialog_applies(plan_id: str) -> ChangePlan | None:
339
397
  """The plan when a real apply must be confirmed in a dialog, else None."""
340
398
  from adloop.runtime import current_config
@@ -375,6 +433,14 @@ async def confirm_in_dialog(context: Any, plan_id: str) -> Any | None:
375
433
  if context._is_modern_protocol():
376
434
  responses = context.input_responses
377
435
  if responses is None:
436
+ if plan.dialog_requested_at:
437
+ # The last dialog for this plan was sent and this is a fresh
438
+ # call, not its answer: the app received the dialog and never
439
+ # returned one. Say so instead of sending another dialog the
440
+ # app will sit on until its timeout. Cleared, so asking again
441
+ # sends a new dialog.
442
+ _mark_dialog(plan, False)
443
+ return ToolResult(structured_content=_dialog_unanswered(plan))
378
444
  import mcp_types
379
445
  from fastmcp.tools.base import InputRequiredToolResult
380
446
 
@@ -385,11 +451,14 @@ async def confirm_in_dialog(context: Any, plan_id: str) -> Any | None:
385
451
  )
386
452
  )
387
453
  state = json.dumps({"plan_id": plan.plan_id, "code": approval_code(plan)})
454
+ _mark_dialog(plan, True)
388
455
  return InputRequiredToolResult(
389
456
  mcp_types.InputRequiredResult(
390
457
  input_requests={DIALOG_KEY: request}, request_state=state
391
458
  )
392
459
  )
460
+ if plan.dialog_requested_at:
461
+ _mark_dialog(plan, False) # answered, whatever the answer is
393
462
  try:
394
463
  state = json.loads(context.request_state or "{}")
395
464
  except ValueError:
@@ -25,6 +25,11 @@ class ChangePlan:
25
25
  # Ad platform the plan targets: "" (Google, the default so stored plans
26
26
  # from before the field existed still load) or "reddit".
27
27
  platform: str = ""
28
+ # When a confirmation dialog for this plan was last sent and not yet
29
+ # answered (ISO timestamp, "" when none is open). An app that receives
30
+ # the dialog but never answers it would otherwise leave every retry
31
+ # waiting until its own timeout; see approval.confirm_in_dialog.
32
+ dialog_requested_at: str = ""
28
33
 
29
34
  # Payload the applier needs but a preview must never show: an upload's rows
30
35
  # (the caller id cannot be hashed, so call uploads carry raw phone numbers),
@@ -4482,6 +4482,11 @@ def confirm_and_apply(
4482
4482
  only the user's click on the card applies the change. Dry runs still
4483
4483
  work. The workspace owner can allow approval in chat as well.
4484
4484
 
4485
+ Confirmation dialogs: in apps that show dialogs instead, dry_run=false
4486
+ first asks the user in a dialog. A declined dialog returns NOT_CONFIRMED;
4487
+ an app that receives the dialog but never answers it gets
4488
+ CONFIRMATION_UNANSWERED on the next call, with the ways out.
4489
+
4485
4490
  Args:
4486
4491
  plan_id: The plan_id returned by a prior draft_*, update_*, add_*,
4487
4492
  pause/enable/remove or other preview tool call. An unknown or
@@ -4490,13 +4495,17 @@ def confirm_and_apply(
4490
4495
  applies the change for real.
4491
4496
  """
4492
4497
  from adloop.ads.write import confirm_and_apply as _impl
4493
- from adloop.safety.approval import chat_apply_refusal
4498
+ from adloop.safety.approval import DIALOG_NOTE, chat_apply_refusal, dialog_expected
4494
4499
 
4495
4500
  if not dry_run:
4496
4501
  refusal = chat_apply_refusal(plan_id)
4497
4502
  if refusal is not None:
4498
4503
  return refusal
4499
- return _impl(current_config(), plan_id=plan_id, dry_run=dry_run)
4504
+ result = _impl(current_config(), plan_id=plan_id, dry_run=dry_run)
4505
+ if (dry_run and isinstance(result, dict) and result.get("status") == "DRY_RUN_SUCCESS"
4506
+ and dialog_expected(plan_id)):
4507
+ result["next_step"] = DIALOG_NOTE
4508
+ return result
4500
4509
 
4501
4510
 
4502
4511
  @_tool(
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
File without changes
File without changes
File without changes