adloop 0.22.2__tar.gz → 0.24.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.
- {adloop-0.22.2 → adloop-0.24.0}/PKG-INFO +2 -2
- {adloop-0.22.2 → adloop-0.24.0}/README.md +1 -1
- {adloop-0.22.2 → adloop-0.24.0}/pyproject.toml +1 -1
- {adloop-0.22.2 → adloop-0.24.0}/pyproject.toml.orig +1 -1
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/config.py +8 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules/adloop.md +4 -0
- adloop-0.24.0/src/adloop/safety/approval.py +380 -0
- adloop-0.24.0/src/adloop/safety/approval_card.html +360 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/server.py +205 -2
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/__main__.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/brands.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/write.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/auth.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/cli.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/crossref.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/gtm/write.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/merchant/read.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/reddit/read.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/reddit/write.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/runtime.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.22.2 → adloop-0.24.0}/src/adloop/tracking.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: adloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.24.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
|
|
@@ -81,7 +81,7 @@ AdLoop exists because managing Google Ads alongside your code is a mess. These a
|
|
|
81
81
|
|
|
82
82
|
- **"Is my tracking even working?"** Compare the event names in your actual codebase against what GA4 is receiving. Find the mismatches: events you fire that GA4 never sees, events GA4 records that you didn't know about.
|
|
83
83
|
|
|
84
|
-
- **"I need to create ads but the Google Ads UI is hostile."** Draft responsive search ads, create campaigns, add keywords — all through natural language. Every change shows a preview first. Nothing goes live without your explicit confirmation. New ads and campaigns start paused.
|
|
84
|
+
- **"I need to create ads but the Google Ads UI is hostile."** Draft responsive search ads, create campaigns, add keywords — all through natural language. Every change shows a preview first. Nothing goes live without your explicit confirmation: in Claude, ChatGPT and VS Code the preview is an approval card, and only your click on Apply changes the account (the AI cannot press it). New ads and campaigns start paused.
|
|
85
85
|
|
|
86
86
|
- **"My landing page gets paid traffic but nobody converts."** AdLoop joins your ad final URLs with GA4 page-level data. See which pages get clicks but no conversions, which have high bounce rates, and which ones are orphaned from any ad campaign.
|
|
87
87
|
|
|
@@ -53,7 +53,7 @@ AdLoop exists because managing Google Ads alongside your code is a mess. These a
|
|
|
53
53
|
|
|
54
54
|
- **"Is my tracking even working?"** Compare the event names in your actual codebase against what GA4 is receiving. Find the mismatches: events you fire that GA4 never sees, events GA4 records that you didn't know about.
|
|
55
55
|
|
|
56
|
-
- **"I need to create ads but the Google Ads UI is hostile."** Draft responsive search ads, create campaigns, add keywords — all through natural language. Every change shows a preview first. Nothing goes live without your explicit confirmation. New ads and campaigns start paused.
|
|
56
|
+
- **"I need to create ads but the Google Ads UI is hostile."** Draft responsive search ads, create campaigns, add keywords — all through natural language. Every change shows a preview first. Nothing goes live without your explicit confirmation: in Claude, ChatGPT and VS Code the preview is an approval card, and only your click on Apply changes the account (the AI cannot press it). New ads and campaigns start paused.
|
|
57
57
|
|
|
58
58
|
- **"My landing page gets paid traffic but nobody converts."** AdLoop joins your ad final URLs with GA4 page-level data. See which pages get clicks but no conversions, which have high bounce rates, and which ones are orphaned from any ad campaign.
|
|
59
59
|
|
|
@@ -87,6 +87,13 @@ class SafetyConfig:
|
|
|
87
87
|
# (which blocks real writes entirely), this only enforces the order:
|
|
88
88
|
# preview first, apply second.
|
|
89
89
|
two_phase_apply: bool = False
|
|
90
|
+
# Approval cards: when the AI app shows AdLoop's approval card (MCP
|
|
91
|
+
# Apps), only a click on the card applies a change, and
|
|
92
|
+
# confirm_and_apply(dry_run=false) from the model is refused. True also
|
|
93
|
+
# accepts the approval in chat, for app surfaces that claim card support
|
|
94
|
+
# but do not render it. Apps without cards that support confirmation
|
|
95
|
+
# dialogs ask in a dialog before a real apply; True skips that as well.
|
|
96
|
+
allow_chat_approval: bool = False
|
|
90
97
|
log_file: str = "~/.adloop/audit.log"
|
|
91
98
|
blocked_operations: list[str] = field(default_factory=list)
|
|
92
99
|
|
|
@@ -217,6 +224,7 @@ def load_config(config_path: str | None = None) -> AdLoopConfig:
|
|
|
217
224
|
max_bid_increase_pct=safety_raw.get("max_bid_increase_pct", 100),
|
|
218
225
|
require_dry_run=safety_raw.get("require_dry_run", True),
|
|
219
226
|
two_phase_apply=safety_raw.get("two_phase_apply", False),
|
|
227
|
+
allow_chat_approval=safety_raw.get("allow_chat_approval", False),
|
|
220
228
|
log_file=_text(safety_raw, "log_file", "~/.adloop/audit.log"),
|
|
221
229
|
blocked_operations=safety_raw.get("blocked_operations", []),
|
|
222
230
|
),
|
|
@@ -296,6 +296,10 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform:
|
|
|
296
296
|
3. Call `confirm_and_apply(plan_id=..., dry_run=true)` first to test
|
|
297
297
|
4. Only call with `dry_run=false` after explicit user confirmation
|
|
298
298
|
|
|
299
|
+
**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).
|
|
300
|
+
|
|
301
|
+
**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.
|
|
302
|
+
|
|
299
303
|
**Safety behaviors:**
|
|
300
304
|
- New campaigns and RSAs (and every Reddit campaign, ad group and ad) are created as PAUSED — user must explicitly enable them after review.
|
|
301
305
|
- `draft_campaign` REQUIRES `geo_target_ids` and `language_ids` — campaigns without targeting waste budget. The tool rejects drafts with missing targeting.
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Approval cards: a change preview that only a human click applies.
|
|
2
|
+
|
|
3
|
+
AI apps that support MCP Apps (the ``io.modelcontextprotocol/ui``
|
|
4
|
+
extension) render every write tool's preview as AdLoop's approval card,
|
|
5
|
+
with "Test run" and "Apply" buttons. The buttons call ``approve_change``,
|
|
6
|
+
an app-only tool: the MCP Apps spec requires the app to keep it out of the
|
|
7
|
+
model's tool list, AdLoop lists and runs it only for card-capable clients,
|
|
8
|
+
and every call needs the plan's approval code. That code travels in the
|
|
9
|
+
preview result's ``_meta``, which goes to the card and is not meant for the
|
|
10
|
+
model's context, and it is an HMAC the model cannot compute.
|
|
11
|
+
|
|
12
|
+
While a card can be shown, ``confirm_and_apply(dry_run=false)`` from the
|
|
13
|
+
model is refused unless ``safety.allow_chat_approval`` is on, so "the user
|
|
14
|
+
said yes" in the chat is not enough to change an account.
|
|
15
|
+
|
|
16
|
+
Apps without cards that support elicitation (confirmation dialogs) get a
|
|
17
|
+
dialog instead: before ``confirm_and_apply(dry_run=false)`` runs, the server
|
|
18
|
+
asks the user directly, and a decline changes nothing. On the 2026-07-28
|
|
19
|
+
protocol the dialog is an ``InputRequiredResult`` round trip, which works on
|
|
20
|
+
a stateless server; on older protocols it is a server-initiated request,
|
|
21
|
+
used only self-hosted, where the connection can carry it. Apps with neither
|
|
22
|
+
keep the chat flow unchanged.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import contextvars
|
|
28
|
+
import hashlib
|
|
29
|
+
import hmac
|
|
30
|
+
import json
|
|
31
|
+
import secrets
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from collections.abc import Iterator
|
|
34
|
+
from contextlib import contextmanager
|
|
35
|
+
from functools import lru_cache
|
|
36
|
+
from importlib import resources
|
|
37
|
+
from typing import TYPE_CHECKING, Any
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from adloop.safety.preview import ChangePlan
|
|
41
|
+
|
|
42
|
+
UI_EXTENSION_ID = "io.modelcontextprotocol/ui"
|
|
43
|
+
CARD_URI = "ui://adloop/approval-card.html"
|
|
44
|
+
CARD_MIME_TYPE = "text/html;profile=mcp-app"
|
|
45
|
+
META_KEY = "adloop/approval"
|
|
46
|
+
APPROVE_TOOL = "approve_change"
|
|
47
|
+
|
|
48
|
+
_secret: bytes = secrets.token_bytes(32)
|
|
49
|
+
_card_click: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
|
50
|
+
"adloop_card_click", default=False
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def set_approval_secret(secret: bytes) -> None:
|
|
55
|
+
"""Key the approval codes.
|
|
56
|
+
|
|
57
|
+
Self-hosted, plans live in memory, so a random key per process is
|
|
58
|
+
enough. A hosted runtime keeps plans across restarts and workers and
|
|
59
|
+
sets a stable key derived from its own secrets.
|
|
60
|
+
"""
|
|
61
|
+
global _secret
|
|
62
|
+
if len(secret) < 32:
|
|
63
|
+
raise ValueError("approval secret must be at least 32 bytes")
|
|
64
|
+
_secret = bytes(secret)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def client_shows_cards() -> bool:
|
|
68
|
+
"""Whether the calling AI app declared MCP Apps support.
|
|
69
|
+
|
|
70
|
+
Read per request: on the 2026-07-28 protocol the capabilities ride every
|
|
71
|
+
request, so this also works on a stateless server. Anything that cannot
|
|
72
|
+
be read counts as "no cards", which keeps the chat flow.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
from fastmcp.server.dependencies import get_context
|
|
76
|
+
|
|
77
|
+
return bool(get_context().client_supports_extension(UI_EXTENSION_ID))
|
|
78
|
+
except Exception:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def approval_code(plan: ChangePlan) -> str:
|
|
83
|
+
"""The code the card must present to run or apply ``plan``."""
|
|
84
|
+
from adloop.runtime import current_tenant
|
|
85
|
+
|
|
86
|
+
message = "\x00".join((current_tenant(), plan.plan_id, plan.created_at))
|
|
87
|
+
return hmac.new(_secret, message.encode(), hashlib.sha256).hexdigest()[:32]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def code_matches(plan: ChangePlan, code: str) -> bool:
|
|
91
|
+
return hmac.compare_digest(approval_code(plan), str(code or ""))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def card_click_active() -> bool:
|
|
95
|
+
"""True while ``approve_change`` runs a click from the card."""
|
|
96
|
+
return _card_click.get()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@contextmanager
|
|
100
|
+
def card_click() -> Iterator[None]:
|
|
101
|
+
token = _card_click.set(True)
|
|
102
|
+
try:
|
|
103
|
+
yield
|
|
104
|
+
finally:
|
|
105
|
+
_card_click.reset(token)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def chat_apply_refusal(plan_id: str) -> dict | None:
|
|
109
|
+
"""The refusal for a model-issued real apply, or None when it may run."""
|
|
110
|
+
from adloop.runtime import current_config
|
|
111
|
+
|
|
112
|
+
if card_click_active() or not client_shows_cards():
|
|
113
|
+
return None
|
|
114
|
+
if current_config().safety.allow_chat_approval:
|
|
115
|
+
return None
|
|
116
|
+
return {
|
|
117
|
+
"status": "APPROVE_ON_CARD",
|
|
118
|
+
"plan_id": plan_id,
|
|
119
|
+
"message": (
|
|
120
|
+
"Nothing was changed. In this app, changes are applied only by the "
|
|
121
|
+
"user's click on the AdLoop approval card shown with the preview "
|
|
122
|
+
"(Test run, then Apply). Ask the user to approve it there; do not "
|
|
123
|
+
"retry confirm_and_apply with dry_run=false. If the card is not "
|
|
124
|
+
"visible in their app, the workspace owner can allow approval in "
|
|
125
|
+
"chat (AdLoop Cloud: Settings → Safety; self-hosted: "
|
|
126
|
+
"safety.allow_chat_approval in the config)."
|
|
127
|
+
),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _instructions(plan_id: str) -> str:
|
|
132
|
+
from adloop.runtime import current_config
|
|
133
|
+
|
|
134
|
+
text = (
|
|
135
|
+
"The user sees this preview as an AdLoop approval card with Test run "
|
|
136
|
+
"and Apply buttons, and approves it there. Summarize the change and "
|
|
137
|
+
"any warnings, then let the user decide on the card."
|
|
138
|
+
)
|
|
139
|
+
if current_config().safety.allow_chat_approval:
|
|
140
|
+
return text + (
|
|
141
|
+
" Approval in chat is also allowed: only if the user explicitly "
|
|
142
|
+
f"says so, call confirm_and_apply with plan_id='{plan_id}'."
|
|
143
|
+
)
|
|
144
|
+
return text + (
|
|
145
|
+
" Do not call confirm_and_apply with dry_run=false: in this app it "
|
|
146
|
+
"is refused, only the click applies. A dry run "
|
|
147
|
+
f"(confirm_and_apply plan_id='{plan_id}', dry_run=true) is allowed."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def with_card(result: Any) -> Any:
|
|
152
|
+
"""Return a write tool's preview as a card result for card-capable apps.
|
|
153
|
+
|
|
154
|
+
Anything that is not a pending preview (errors, refusals, notes) and
|
|
155
|
+
every result for an app without cards passes through unchanged.
|
|
156
|
+
"""
|
|
157
|
+
if not isinstance(result, dict) or result.get("status") != "PENDING_CONFIRMATION":
|
|
158
|
+
return result
|
|
159
|
+
if not client_shows_cards():
|
|
160
|
+
return result
|
|
161
|
+
from adloop.safety.preview import get_plan
|
|
162
|
+
|
|
163
|
+
plan = get_plan(str(result.get("plan_id") or ""))
|
|
164
|
+
if plan is None:
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
from fastmcp.tools import ToolResult
|
|
168
|
+
from mcp.types import TextContent
|
|
169
|
+
|
|
170
|
+
preview = {**result, "approval": "card", "instructions": _instructions(plan.plan_id)}
|
|
171
|
+
return ToolResult(
|
|
172
|
+
content=[TextContent(type="text", text=json.dumps(preview, default=str))],
|
|
173
|
+
structured_content=preview,
|
|
174
|
+
meta={
|
|
175
|
+
META_KEY: {
|
|
176
|
+
"plan_id": plan.plan_id,
|
|
177
|
+
"code": approval_code(plan),
|
|
178
|
+
"tool": APPROVE_TOOL,
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@lru_cache(maxsize=1)
|
|
185
|
+
def card_html() -> str:
|
|
186
|
+
return (
|
|
187
|
+
resources.files("adloop.safety")
|
|
188
|
+
.joinpath("approval_card.html")
|
|
189
|
+
.read_text(encoding="utf-8")
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def is_app_only(meta: dict | None) -> bool:
|
|
194
|
+
"""True for tools the MCP Apps spec keeps out of the model's tool list."""
|
|
195
|
+
ui = (meta or {}).get("ui")
|
|
196
|
+
if not isinstance(ui, dict):
|
|
197
|
+
return False
|
|
198
|
+
visibility = ui.get("visibility")
|
|
199
|
+
return isinstance(visibility, list) and "model" not in visibility
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Confirmation dialogs (elicitation) for apps without cards
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
DIALOG_KEY = "adloop_confirm"
|
|
207
|
+
_MAX_DIALOG_FIELDS = 10
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass
|
|
211
|
+
class ApplyConfirmation:
|
|
212
|
+
apply: bool = False
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass
|
|
216
|
+
class IrreversibleConfirmation:
|
|
217
|
+
apply: bool = False
|
|
218
|
+
cannot_be_undone: bool = False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _confirmation_type(plan: ChangePlan) -> type:
|
|
222
|
+
return IrreversibleConfirmation if plan.requires_double_confirm else ApplyConfirmation
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def client_can_elicit() -> bool:
|
|
226
|
+
"""Whether the calling app declared form elicitation (confirmation dialogs)."""
|
|
227
|
+
try:
|
|
228
|
+
from fastmcp.server.dependencies import get_context
|
|
229
|
+
|
|
230
|
+
caps = get_context().session.client_capabilities
|
|
231
|
+
except Exception:
|
|
232
|
+
return False
|
|
233
|
+
elicitation = getattr(caps, "elicitation", None) if caps is not None else None
|
|
234
|
+
if elicitation is None:
|
|
235
|
+
return False
|
|
236
|
+
# An empty capability object means form mode (the spec's default).
|
|
237
|
+
form = getattr(elicitation, "form", None)
|
|
238
|
+
url = getattr(elicitation, "url", None)
|
|
239
|
+
return form is not None or url is None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _platform_label(plan: ChangePlan) -> str:
|
|
243
|
+
op = plan.operation
|
|
244
|
+
if op.startswith("reddit_"):
|
|
245
|
+
return "Reddit Ads"
|
|
246
|
+
if op.startswith("gtm_"):
|
|
247
|
+
return "Google Tag Manager"
|
|
248
|
+
if "key_event" in op:
|
|
249
|
+
return "Google Analytics"
|
|
250
|
+
return "Google Ads"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def confirmation_message(plan: ChangePlan) -> str:
|
|
254
|
+
"""The dialog text: what changes, where, and what confirming does."""
|
|
255
|
+
title = plan.operation.replace("reddit_", "").replace("gtm_", "").replace("_", " ")
|
|
256
|
+
target = " ".join(
|
|
257
|
+
part for part in (plan.entity_type.replace("_", " "), plan.entity_id) if part
|
|
258
|
+
)
|
|
259
|
+
lines = [f"AdLoop: apply this change in {_platform_label(plan)}?", ""]
|
|
260
|
+
lines.append(title.capitalize() + (f" ({target})" if target else ""))
|
|
261
|
+
for key, value in list(plan.changes.items())[:_MAX_DIALOG_FIELDS]:
|
|
262
|
+
text = value if isinstance(value, str) else json.dumps(value, default=str)
|
|
263
|
+
if len(text) > 160:
|
|
264
|
+
text = text[:157] + "..."
|
|
265
|
+
lines.append(f"- {key.replace('_', ' ')}: {text}")
|
|
266
|
+
if len(plan.changes) > _MAX_DIALOG_FIELDS:
|
|
267
|
+
lines.append(f"- ... {len(plan.changes) - _MAX_DIALOG_FIELDS} more fields in the preview")
|
|
268
|
+
lines.append("")
|
|
269
|
+
if plan.requires_double_confirm:
|
|
270
|
+
lines.append("This cannot be undone. Tick both boxes to apply it for real.")
|
|
271
|
+
else:
|
|
272
|
+
lines.append("Tick the box to apply it for real. Declining changes nothing.")
|
|
273
|
+
return "\n".join(lines)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _accepted(plan: ChangePlan, action: str | None, content: Any) -> bool:
|
|
277
|
+
if action != "accept":
|
|
278
|
+
return False
|
|
279
|
+
if content is None:
|
|
280
|
+
data: dict = {}
|
|
281
|
+
elif isinstance(content, dict):
|
|
282
|
+
data = content
|
|
283
|
+
else:
|
|
284
|
+
data = vars(content)
|
|
285
|
+
if not data.get("apply"):
|
|
286
|
+
return False
|
|
287
|
+
return not plan.requires_double_confirm or bool(data.get("cannot_be_undone"))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _not_confirmed(plan: ChangePlan) -> dict:
|
|
291
|
+
return {
|
|
292
|
+
"status": "NOT_CONFIRMED",
|
|
293
|
+
"plan_id": plan.plan_id,
|
|
294
|
+
"operation": plan.operation,
|
|
295
|
+
"message": (
|
|
296
|
+
"The user did not confirm the change in the confirmation dialog, so "
|
|
297
|
+
"nothing was changed. Do not retry on your own; ask the user what "
|
|
298
|
+
"they want to do. The preview stays pending."
|
|
299
|
+
),
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _dialog_applies(plan_id: str) -> ChangePlan | None:
|
|
304
|
+
"""The plan when a real apply must be confirmed in a dialog, else None."""
|
|
305
|
+
from adloop.runtime import current_config
|
|
306
|
+
from adloop.safety.preview import get_plan
|
|
307
|
+
|
|
308
|
+
if card_click_active() or client_shows_cards() or not client_can_elicit():
|
|
309
|
+
return None
|
|
310
|
+
safety = current_config().safety
|
|
311
|
+
if safety.allow_chat_approval or safety.require_dry_run:
|
|
312
|
+
# Chat approval is allowed, or the apply turns into a dry run anyway.
|
|
313
|
+
return None
|
|
314
|
+
plan = get_plan(plan_id)
|
|
315
|
+
if plan is None:
|
|
316
|
+
return None
|
|
317
|
+
if safety.two_phase_apply and plan.dry_run_result is None:
|
|
318
|
+
# confirm_and_apply refuses with DRY_RUN_REQUIRED; don't ask first.
|
|
319
|
+
return None
|
|
320
|
+
return plan
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
async def confirm_in_dialog(context: Any, plan_id: str) -> Any | None:
|
|
324
|
+
"""Ask the user before a real apply; None means "go ahead".
|
|
325
|
+
|
|
326
|
+
Otherwise returns an ``InputRequiredToolResult`` (2026-07-28: the app
|
|
327
|
+
shows the dialog and repeats the call with the answer) or a
|
|
328
|
+
NOT_CONFIRMED result when the user declined.
|
|
329
|
+
"""
|
|
330
|
+
plan = _dialog_applies(plan_id)
|
|
331
|
+
if plan is None:
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
from fastmcp.server.elicitation import parse_elicit_response_type
|
|
335
|
+
from fastmcp.tools import ToolResult
|
|
336
|
+
|
|
337
|
+
response_type = _confirmation_type(plan)
|
|
338
|
+
message = confirmation_message(plan)
|
|
339
|
+
|
|
340
|
+
if context._is_modern_protocol():
|
|
341
|
+
responses = context.input_responses
|
|
342
|
+
if responses is None:
|
|
343
|
+
import mcp_types
|
|
344
|
+
from fastmcp.tools.base import InputRequiredToolResult
|
|
345
|
+
|
|
346
|
+
schema = parse_elicit_response_type(response_type).schema
|
|
347
|
+
request = mcp_types.ElicitRequest(
|
|
348
|
+
params=mcp_types.ElicitRequestFormParams(
|
|
349
|
+
mode="form", message=message, requested_schema=schema
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
state = json.dumps({"plan_id": plan.plan_id, "code": approval_code(plan)})
|
|
353
|
+
return InputRequiredToolResult(
|
|
354
|
+
mcp_types.InputRequiredResult(
|
|
355
|
+
input_requests={DIALOG_KEY: request}, request_state=state
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
try:
|
|
359
|
+
state = json.loads(context.request_state or "{}")
|
|
360
|
+
except ValueError:
|
|
361
|
+
state = {}
|
|
362
|
+
if state.get("plan_id") != plan.plan_id or not code_matches(plan, state.get("code", "")):
|
|
363
|
+
return ToolResult(structured_content=_not_confirmed(plan))
|
|
364
|
+
answer = responses.get(DIALOG_KEY) if hasattr(responses, "get") else None
|
|
365
|
+
action = getattr(answer, "action", None)
|
|
366
|
+
content = getattr(answer, "content", None)
|
|
367
|
+
else:
|
|
368
|
+
from adloop.runtime import deployment_mode
|
|
369
|
+
|
|
370
|
+
if deployment_mode() == "server":
|
|
371
|
+
# A stateless hosted server cannot push a request to the app on
|
|
372
|
+
# the older protocol; that app keeps the chat flow.
|
|
373
|
+
return None
|
|
374
|
+
answer = await context.elicit(message, response_type)
|
|
375
|
+
action = getattr(answer, "action", None)
|
|
376
|
+
content = getattr(answer, "data", None)
|
|
377
|
+
|
|
378
|
+
if _accepted(plan, action, content):
|
|
379
|
+
return None
|
|
380
|
+
return ToolResult(structured_content=_not_confirmed(plan))
|