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.
Files changed (68) hide show
  1. {adloop-0.22.2 → adloop-0.24.0}/PKG-INFO +2 -2
  2. {adloop-0.22.2 → adloop-0.24.0}/README.md +1 -1
  3. {adloop-0.22.2 → adloop-0.24.0}/pyproject.toml +1 -1
  4. {adloop-0.22.2 → adloop-0.24.0}/pyproject.toml.orig +1 -1
  5. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/config.py +8 -0
  6. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules/adloop.md +4 -0
  7. adloop-0.24.0/src/adloop/safety/approval.py +380 -0
  8. adloop-0.24.0/src/adloop/safety/approval_card.html +360 -0
  9. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/server.py +205 -2
  10. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/__init__.py +0 -0
  11. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/__main__.py +0 -0
  12. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/_mcp_patches.py +0 -0
  13. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/__init__.py +0 -0
  14. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/ai_max.py +0 -0
  15. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/brands.py +0 -0
  16. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/client.py +0 -0
  17. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/conversion_actions.py +0 -0
  18. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/currency.py +0 -0
  19. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
  20. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/enums.py +0 -0
  21. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/forecast.py +0 -0
  22. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/gaql.py +0 -0
  23. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/image_fetch.py +0 -0
  24. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/pmax.py +0 -0
  25. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/read.py +0 -0
  26. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/validate_only.py +0 -0
  27. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ads/write.py +0 -0
  28. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/auth.py +0 -0
  29. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/cli.py +0 -0
  30. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/crossref.py +0 -0
  31. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/diagnostics.py +0 -0
  32. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ga4/__init__.py +0 -0
  33. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ga4/client.py +0 -0
  34. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ga4/reports.py +0 -0
  35. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ga4/tracking.py +0 -0
  36. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/ga4/write.py +0 -0
  37. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/gsc/__init__.py +0 -0
  38. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/gsc/client.py +0 -0
  39. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/gsc/reports.py +0 -0
  40. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/gtm/__init__.py +0 -0
  41. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/gtm/client.py +0 -0
  42. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/gtm/entities.py +0 -0
  43. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/gtm/read.py +0 -0
  44. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/gtm/write.py +0 -0
  45. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/merchant/__init__.py +0 -0
  46. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/merchant/client.py +0 -0
  47. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/merchant/read.py +0 -0
  48. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/pagespeed.py +0 -0
  49. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/reddit/__init__.py +0 -0
  50. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/reddit/auth.py +0 -0
  51. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/reddit/client.py +0 -0
  52. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/reddit/read.py +0 -0
  53. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/reddit/schedule.py +0 -0
  54. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/reddit/write.py +0 -0
  55. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules/__init__.py +0 -0
  56. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  57. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  58. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules/commands/create-ad.md +0 -0
  59. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  60. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  61. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  62. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/rules_install.py +0 -0
  63. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/runtime.py +0 -0
  64. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/safety/__init__.py +0 -0
  65. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/safety/audit.py +0 -0
  66. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/safety/guards.py +0 -0
  67. {adloop-0.22.2 → adloop-0.24.0}/src/adloop/safety/preview.py +0 -0
  68. {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.22.2
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.22.2"
3
+ version = "0.24.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.22.2"
3
+ version = "0.24.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 = [
@@ -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))