a2a-source-edit: write agent.py
This commit is contained in:
837
agent.py
837
agent.py
@@ -1,189 +1,734 @@
|
|||||||
"""receives-support-email-checks-1-919313 agent.
|
"""Support email refund workflow agent.
|
||||||
|
|
||||||
Starter stack:
|
Receives support email, checks Stripe refund eligibility with caller-provided
|
||||||
- DeepAgents for tool-calling orchestration
|
credentials, drafts a customer reply, requires explicit approval before any
|
||||||
- Caller-provided LLM credentials via ctx.llm
|
refund mutation, and posts redacted audit summaries to Slack.
|
||||||
- A tiny model-call middleware hook you can replace with tracing,
|
|
||||||
routing, rate limits, or policy checks
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
import re
|
||||||
from typing import Any
|
import time
|
||||||
|
from email.utils import parseaddr
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel
|
import httpx
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
import a2a_pack as a2a
|
import a2a_pack as a2a
|
||||||
from a2a_pack import (
|
from a2a_pack import (
|
||||||
A2AAgent,
|
A2AAgent,
|
||||||
LLMProvisioning,
|
AgentPlatformResources,
|
||||||
{{ auth_type }},
|
ConsumerSetup,
|
||||||
|
ConsumerSetupField,
|
||||||
|
EgressPolicy,
|
||||||
|
NoAuth,
|
||||||
Pricing,
|
Pricing,
|
||||||
|
Resources,
|
||||||
RunContext,
|
RunContext,
|
||||||
WorkspaceAccess,
|
WorkspaceAccess,
|
||||||
WorkspaceMode,
|
WorkspaceMode,
|
||||||
)
|
)
|
||||||
from a2a_pack.context import LLMCreds
|
from a2a_pack.mail import InboundEmailPayload
|
||||||
|
|
||||||
|
|
||||||
|
LEDGER_PATH = "outputs/support-refund-audit-ledger.json"
|
||||||
|
MAX_EMAIL_BODY_CHARS = 12_000
|
||||||
|
MAX_REFUND_AMOUNT_CENTS = 100_000
|
||||||
|
DEFAULT_CURRENCY = "usd"
|
||||||
|
HTTP_TIMEOUT = httpx.Timeout(12.0, connect=4.0, read=8.0, write=8.0)
|
||||||
|
|
||||||
|
|
||||||
class ReceivesSupportEmailChecks1919313Config(BaseModel):
|
class ReceivesSupportEmailChecks1919313Config(BaseModel):
|
||||||
pass
|
max_auto_eligible_refund_cents: int = Field(
|
||||||
|
default=50_000,
|
||||||
|
ge=1,
|
||||||
|
le=MAX_REFUND_AMOUNT_CENTS,
|
||||||
|
description="Maximum refund amount the agent may mark eligible for approval.",
|
||||||
|
)
|
||||||
|
allowed_refund_currencies: list[str] = Field(
|
||||||
|
default_factory=lambda: [DEFAULT_CURRENCY],
|
||||||
|
description="Lowercase ISO currencies that may be proposed for refunds.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """\
|
class ReceivesSupportEmailChecks1919313(A2AAgent[ReceivesSupportEmailChecks1919313Config, NoAuth]):
|
||||||
You are a compact tool-calling agent.
|
|
||||||
|
|
||||||
Use the text_stats tool when the user asks about text, counts, summaries,
|
|
||||||
or anything where exact length/word numbers would help. Mention tool results
|
|
||||||
briefly instead of dumping raw JSON.
|
|
||||||
"""
|
|
||||||
|
|
||||||
RUNTIME_SKILLS_DIR = "receives-support-email-checks-1-919313/.deepagents/skills/"
|
|
||||||
DEEPAGENTS_RECURSION_LIMIT = 500
|
|
||||||
|
|
||||||
|
|
||||||
class ReceivesSupportEmailChecks1919313(A2AAgent[ReceivesSupportEmailChecks1919313Config, {{ auth_type }}]):
|
|
||||||
name = "receives-support-email-checks-1-919313"
|
name = "receives-support-email-checks-1-919313"
|
||||||
description = "Receives support email, checks Stripe refund eligibility, drafts replies, requires approval before refunds, and posts audit summaries to Slack."
|
description = (
|
||||||
|
"Receives support email, checks Stripe refund eligibility with caller-provided "
|
||||||
|
"credentials, drafts the customer reply, requires explicit approval before "
|
||||||
|
"executing refunds, and posts redacted audit summaries to Slack."
|
||||||
|
)
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
config_model = ReceivesSupportEmailChecks1919313Config
|
config_model = ReceivesSupportEmailChecks1919313Config
|
||||||
auth_model = {{ auth_type }}
|
auth_model = NoAuth
|
||||||
|
|
||||||
# Hosted generated agents read the caller's saved LLM credential through
|
consumer_setup = ConsumerSetup.from_fields(
|
||||||
# ctx.llm. The platform may proxy that credential through LiteLLM, but agent
|
ConsumerSetupField.secret(
|
||||||
# code never reads provider keys, LiteLLM master keys, or OPENAI_API_KEY
|
"STRIPE_SECRET_KEY",
|
||||||
# directly.
|
label="Stripe secret key",
|
||||||
llm_provisioning = LLMProvisioning.PLATFORM
|
description="Caller-provided Stripe API key used only server-side to inspect and create refunds.",
|
||||||
pricing = Pricing(
|
input_type="password",
|
||||||
price_per_call_usd=0.0,
|
),
|
||||||
caller_pays_llm=True,
|
ConsumerSetupField.secret(
|
||||||
notes="Starter agent uses the caller's saved LLM credential via ctx.llm.",
|
"SLACK_BOT_TOKEN",
|
||||||
|
label="Slack bot token",
|
||||||
|
description="Caller-provided Slack token used only server-side for audit summaries.",
|
||||||
|
input_type="password",
|
||||||
|
),
|
||||||
|
ConsumerSetupField.config(
|
||||||
|
"SLACK_CHANNEL_ID",
|
||||||
|
label="Slack audit channel ID",
|
||||||
|
description="Slack channel where refund audit summaries should be posted.",
|
||||||
|
input_type="text",
|
||||||
|
),
|
||||||
|
ConsumerSetupField.config(
|
||||||
|
"STRIPE_BASE_URL",
|
||||||
|
label="Stripe API base URL",
|
||||||
|
description="Optional test override. Defaults to https://api.stripe.com.",
|
||||||
|
required=False,
|
||||||
|
input_type="url",
|
||||||
|
),
|
||||||
|
ConsumerSetupField.config(
|
||||||
|
"SLACK_BASE_URL",
|
||||||
|
label="Slack API base URL",
|
||||||
|
description="Optional test override. Defaults to https://slack.com/api.",
|
||||||
|
required=False,
|
||||||
|
input_type="url",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
platform_resources = AgentPlatformResources(mailbox=True)
|
||||||
|
resources = Resources(cpu="500m", memory="512Mi", max_runtime_seconds=300)
|
||||||
workspace_access = WorkspaceAccess.dynamic(
|
workspace_access = WorkspaceAccess.dynamic(
|
||||||
max_files=64,
|
max_files=8,
|
||||||
allowed_modes=(WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY),
|
allowed_modes=(WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY),
|
||||||
require_reason=False,
|
require_reason=False,
|
||||||
)
|
)
|
||||||
tools_used = ("deepagents", "langchain")
|
egress = EgressPolicy(allow_hosts=("api.stripe.com", "slack.com"))
|
||||||
|
tools_used = ("email", "stripe", "slack", "httpx")
|
||||||
@a2a.tool(description="Ask the starter DeepAgent to answer with tool calls when useful")
|
pricing = Pricing(
|
||||||
async def ask(self, ctx: RunContext[{{ auth_type }}], prompt: str) -> str:
|
price_per_call_usd=0.0,
|
||||||
creds = ctx.llm
|
caller_pays_llm=False,
|
||||||
await ctx.emit_progress(f"llm: {creds.model} via {creds.source}")
|
notes="Deterministic support refund workflow. Provider API usage is paid through caller-provided Stripe and Slack credentials.",
|
||||||
if not creds.api_key:
|
|
||||||
return (
|
|
||||||
"LLM key required. Add an LLM credential in Settings > LLM "
|
|
||||||
"credentials before running this agent; for local --invoke "
|
|
||||||
"runs set AGENT_LLM_KEY."
|
|
||||||
)
|
)
|
||||||
graph = self._build_deep_agent(ctx=ctx, creds=creds)
|
|
||||||
state = await graph.ainvoke(
|
|
||||||
{"messages": [{"role": "user", "content": prompt}]},
|
|
||||||
config={"recursion_limit": DEEPAGENTS_RECURSION_LIMIT},
|
|
||||||
)
|
|
||||||
await ctx.emit_progress("deepagent finished")
|
|
||||||
return _last_message_text(state)
|
|
||||||
|
|
||||||
def _build_deep_agent(
|
@a2a.tool(
|
||||||
|
description="Handle one inbound support email: dedupe, check Stripe refund eligibility, draft reply, and post a Slack audit summary without executing a refund.",
|
||||||
|
timeout_seconds=120,
|
||||||
|
idempotent=True,
|
||||||
|
max_retries=0,
|
||||||
|
cost_class="external-api",
|
||||||
|
)
|
||||||
|
async def process_support_email(
|
||||||
self,
|
self,
|
||||||
*,
|
ctx: RunContext[NoAuth],
|
||||||
ctx: RunContext[{{ auth_type }}],
|
sender_email: str,
|
||||||
creds: LLMCreds,
|
subject: str,
|
||||||
) -> Any:
|
body: str,
|
||||||
# Lazy imports keep `a2a card` usable before local dependencies are
|
message_id: str,
|
||||||
# installed. `a2a deploy` installs requirements.txt during the build.
|
stripe_payment_intent_id: str,
|
||||||
from a2a_pack.deepagents import create_a2a_deep_agent
|
requested_refund_amount_cents: int,
|
||||||
from langchain.agents.middleware import wrap_model_call
|
currency: str = DEFAULT_CURRENCY,
|
||||||
from langchain_core.tools import tool
|
customer_name: str = "customer",
|
||||||
|
) -> dict[str, Any]:
|
||||||
@tool
|
"""Preview a refund workflow from bounded, typed email fields."""
|
||||||
def text_stats(text: str) -> str:
|
email = {
|
||||||
"""Return exact word, character, and line counts for text."""
|
"sender": sender_email,
|
||||||
words = [part for part in text.split() if part.strip()]
|
"subject": subject,
|
||||||
return json.dumps(
|
"body": body[:MAX_EMAIL_BODY_CHARS],
|
||||||
{
|
"message_id": message_id,
|
||||||
"characters": len(text),
|
"references": [],
|
||||||
"words": len(words),
|
"attachments": [],
|
||||||
"lines": len(text.splitlines()) or 1,
|
"date": None,
|
||||||
}
|
}
|
||||||
)
|
return await self._process_email_payload(
|
||||||
|
|
||||||
@wrap_model_call
|
|
||||||
async def log_model_call(request: Any, handler: Any) -> Any:
|
|
||||||
messages = request.state.get("messages", [])
|
|
||||||
print(
|
|
||||||
"[middleware] model_call "
|
|
||||||
f"model={creds.model} source={creds.source} messages={len(messages)}"
|
|
||||||
)
|
|
||||||
return await handler(request)
|
|
||||||
|
|
||||||
backend = ctx.workspace_backend()
|
|
||||||
skill_sources = _seed_runtime_skills(backend, ctx)
|
|
||||||
# create_a2a_deep_agent resolves provider:model strings with
|
|
||||||
# langchain.init_chat_model from ctx.llm, preserving LiteLLM routing,
|
|
||||||
# provider-specific extra body, and runtime model overrides.
|
|
||||||
return create_a2a_deep_agent(
|
|
||||||
ctx,
|
ctx,
|
||||||
creds=creds,
|
email=email,
|
||||||
backend=backend,
|
stripe_payment_intent_id=stripe_payment_intent_id,
|
||||||
skills=skill_sources or None,
|
requested_refund_amount_cents=requested_refund_amount_cents,
|
||||||
tools=[text_stats],
|
currency=currency,
|
||||||
middleware=[log_model_call],
|
customer_name=customer_name,
|
||||||
system_prompt=SYSTEM_PROMPT,
|
)
|
||||||
|
|
||||||
|
@a2a.tool(
|
||||||
|
description="Execute a previously proposed Stripe refund only when the caller supplies explicit approval bound to the exact action digest.",
|
||||||
|
timeout_seconds=120,
|
||||||
|
idempotent=True,
|
||||||
|
max_retries=0,
|
||||||
|
cost_class="payment-mutation",
|
||||||
|
)
|
||||||
|
async def execute_approved_refund(
|
||||||
|
self,
|
||||||
|
ctx: RunContext[NoAuth],
|
||||||
|
action_digest: str,
|
||||||
|
approval_token: str,
|
||||||
|
stripe_payment_intent_id: str,
|
||||||
|
amount_cents: int,
|
||||||
|
currency: str = DEFAULT_CURRENCY,
|
||||||
|
customer_email: str = "",
|
||||||
|
reason: str = "requested_by_customer",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute one approved refund with a Stripe idempotency key."""
|
||||||
|
setup = _read_setup(ctx)
|
||||||
|
if setup["status"] != "ready":
|
||||||
|
return setup
|
||||||
|
|
||||||
|
normalized = _normalize_refund_action(
|
||||||
|
payment_intent_id=stripe_payment_intent_id,
|
||||||
|
amount_cents=amount_cents,
|
||||||
|
currency=currency,
|
||||||
|
customer_email=customer_email,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
expected_digest = _action_digest(normalized)
|
||||||
|
if expected_digest != action_digest:
|
||||||
|
return {
|
||||||
|
"status": "declined",
|
||||||
|
"decline_code": "tampered_action",
|
||||||
|
"message": "The approval digest does not match the requested refund action.",
|
||||||
|
"expected_action_digest": expected_digest,
|
||||||
|
"provided_action_digest": action_digest,
|
||||||
|
}
|
||||||
|
expected_token = _approval_phrase(action_digest)
|
||||||
|
if approval_token != expected_token:
|
||||||
|
return {
|
||||||
|
"status": "approval_required",
|
||||||
|
"approval_required": True,
|
||||||
|
"action_digest": action_digest,
|
||||||
|
"approval_instructions": f"To execute, submit approval_token exactly as: {expected_token}",
|
||||||
|
}
|
||||||
|
|
||||||
|
ledger = await _load_ledger(ctx)
|
||||||
|
idempotency_key = _idempotency_key("refund", action_digest)
|
||||||
|
if idempotency_key in ledger.get("refunds", {}):
|
||||||
|
previous = ledger["refunds"][idempotency_key]
|
||||||
|
return {
|
||||||
|
"status": "duplicate_execution",
|
||||||
|
"executed": previous.get("executed", False),
|
||||||
|
"idempotency_key": idempotency_key,
|
||||||
|
"provider_refund_id": previous.get("provider_refund_id"),
|
||||||
|
"audit": previous.get("audit"),
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.emit_progress("Creating approved Stripe refund with idempotency key")
|
||||||
|
stripe_result = await _create_stripe_refund(setup, normalized, idempotency_key)
|
||||||
|
now = _now()
|
||||||
|
audit = {
|
||||||
|
"event": "refund_execution",
|
||||||
|
"status": stripe_result["status"],
|
||||||
|
"action_digest": action_digest,
|
||||||
|
"idempotency_key": idempotency_key,
|
||||||
|
"payment_intent_id": stripe_payment_intent_id,
|
||||||
|
"amount_cents": amount_cents,
|
||||||
|
"currency": currency.lower(),
|
||||||
|
"customer_email_hash": _hash_customer(customer_email),
|
||||||
|
"timestamp": now,
|
||||||
|
}
|
||||||
|
if stripe_result["status"] == "executed":
|
||||||
|
audit["provider_refund_id"] = stripe_result.get("provider_refund_id")
|
||||||
|
slack_result = await _post_slack_audit(setup, audit)
|
||||||
|
audit["slack_status"] = slack_result["status"]
|
||||||
|
|
||||||
|
ledger.setdefault("refunds", {})[idempotency_key] = {
|
||||||
|
"executed": stripe_result["status"] == "executed",
|
||||||
|
"provider_refund_id": stripe_result.get("provider_refund_id"),
|
||||||
|
"audit": audit,
|
||||||
|
"created_at": now,
|
||||||
|
}
|
||||||
|
await _save_ledger(ctx, ledger)
|
||||||
|
|
||||||
|
if stripe_result["status"] == "timeout":
|
||||||
|
return {
|
||||||
|
"status": "needs_reconciliation",
|
||||||
|
"executed": False,
|
||||||
|
"idempotency_key": idempotency_key,
|
||||||
|
"action_digest": action_digest,
|
||||||
|
"message": "Stripe timed out. Reconcile by checking Stripe for the idempotency key before retrying.",
|
||||||
|
"audit": audit,
|
||||||
|
"slack": slack_result,
|
||||||
|
}
|
||||||
|
if stripe_result["status"] != "executed":
|
||||||
|
return {
|
||||||
|
"status": "declined",
|
||||||
|
"executed": False,
|
||||||
|
"decline_code": stripe_result.get("error_type", "provider_error"),
|
||||||
|
"idempotency_key": idempotency_key,
|
||||||
|
"action_digest": action_digest,
|
||||||
|
"audit": audit,
|
||||||
|
"slack": slack_result,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": "executed",
|
||||||
|
"executed": True,
|
||||||
|
"provider_refund_id": stripe_result.get("provider_refund_id"),
|
||||||
|
"idempotency_key": idempotency_key,
|
||||||
|
"action_digest": action_digest,
|
||||||
|
"audit": audit,
|
||||||
|
"slack": slack_result,
|
||||||
|
}
|
||||||
|
|
||||||
|
@a2a.tool(
|
||||||
|
description="Inbound email handler for the agent mailbox. It deduplicates and returns a draft reply; refund execution still requires a separate explicit approval call.",
|
||||||
|
on_email=True,
|
||||||
|
timeout_seconds=120,
|
||||||
|
idempotent=True,
|
||||||
|
max_retries=0,
|
||||||
|
cost_class="external-api",
|
||||||
|
)
|
||||||
|
async def receive_support_email(
|
||||||
|
self,
|
||||||
|
ctx: RunContext[NoAuth],
|
||||||
|
email: InboundEmailPayload,
|
||||||
|
) -> dict[str, str] | None:
|
||||||
|
parsed = _extract_refund_request(email)
|
||||||
|
if not parsed.get("stripe_payment_intent_id"):
|
||||||
|
return {
|
||||||
|
"subject": _reply_subject(email.get("subject", "Support request")),
|
||||||
|
"body": (
|
||||||
|
"Thanks for contacting support. We could not identify a Stripe payment intent "
|
||||||
|
"in your message, so a teammate will review this manually."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
result = await self._process_email_payload(
|
||||||
|
ctx,
|
||||||
|
email=email,
|
||||||
|
stripe_payment_intent_id=parsed["stripe_payment_intent_id"],
|
||||||
|
requested_refund_amount_cents=int(parsed.get("amount_cents") or 0),
|
||||||
|
currency=str(parsed.get("currency") or DEFAULT_CURRENCY),
|
||||||
|
customer_name=parsed.get("customer_name") or "customer",
|
||||||
|
)
|
||||||
|
draft = result.get("draft_reply") or {}
|
||||||
|
body = str(draft.get("body") or "Thanks for contacting support. We are reviewing your refund request.")
|
||||||
|
return {"subject": str(draft.get("subject") or _reply_subject(email.get("subject", "Support request"))), "body": body}
|
||||||
|
|
||||||
|
async def _process_email_payload(
|
||||||
|
self,
|
||||||
|
ctx: RunContext[NoAuth],
|
||||||
|
*,
|
||||||
|
email: dict[str, Any],
|
||||||
|
stripe_payment_intent_id: str,
|
||||||
|
requested_refund_amount_cents: int,
|
||||||
|
currency: str,
|
||||||
|
customer_name: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
setup = _read_setup(ctx)
|
||||||
|
if setup["status"] != "ready":
|
||||||
|
return setup
|
||||||
|
validation = _validate_refund_request(
|
||||||
|
stripe_payment_intent_id=stripe_payment_intent_id,
|
||||||
|
amount_cents=requested_refund_amount_cents,
|
||||||
|
currency=currency,
|
||||||
|
allowed_currencies=self.config.allowed_refund_currencies,
|
||||||
|
max_refund_cents=self.config.max_auto_eligible_refund_cents,
|
||||||
|
)
|
||||||
|
if validation["status"] != "valid":
|
||||||
|
return validation
|
||||||
|
|
||||||
|
message_id = str(email.get("message_id") or "").strip()
|
||||||
|
sender = _sender_email(str(email.get("sender") or ""))
|
||||||
|
email_key = _email_key(message_id, sender, stripe_payment_intent_id)
|
||||||
|
ledger = await _load_ledger(ctx)
|
||||||
|
if email_key in ledger.get("emails", {}):
|
||||||
|
previous = ledger["emails"][email_key]
|
||||||
|
return {
|
||||||
|
"status": "duplicate_email",
|
||||||
|
"duplicate": True,
|
||||||
|
"message_id": message_id,
|
||||||
|
"previous_action_digest": previous.get("action_digest"),
|
||||||
|
"draft_reply": previous.get("draft_reply"),
|
||||||
|
"audit": previous.get("audit"),
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.emit_progress("Checking Stripe payment intent refund eligibility")
|
||||||
|
stripe_check = await _check_stripe_payment_intent(setup, stripe_payment_intent_id)
|
||||||
|
policy = _evaluate_refund_policy(
|
||||||
|
stripe_check=stripe_check,
|
||||||
|
requested_amount_cents=requested_refund_amount_cents,
|
||||||
|
currency=currency,
|
||||||
|
max_refund_cents=self.config.max_auto_eligible_refund_cents,
|
||||||
|
)
|
||||||
|
normalized_action = _normalize_refund_action(
|
||||||
|
payment_intent_id=stripe_payment_intent_id,
|
||||||
|
amount_cents=requested_refund_amount_cents,
|
||||||
|
currency=currency,
|
||||||
|
customer_email=sender,
|
||||||
|
reason="requested_by_customer",
|
||||||
|
)
|
||||||
|
action_digest = _action_digest(normalized_action)
|
||||||
|
draft_reply = _draft_customer_reply(
|
||||||
|
sender=sender,
|
||||||
|
subject=str(email.get("subject") or "Support request"),
|
||||||
|
customer_name=customer_name,
|
||||||
|
policy=policy,
|
||||||
|
amount_cents=requested_refund_amount_cents,
|
||||||
|
currency=currency,
|
||||||
|
action_digest=action_digest,
|
||||||
|
)
|
||||||
|
audit = {
|
||||||
|
"event": "refund_preview",
|
||||||
|
"status": policy["status"],
|
||||||
|
"message_id_hash": _stable_hash(message_id),
|
||||||
|
"customer_email_hash": _hash_customer(sender),
|
||||||
|
"payment_intent_id": stripe_payment_intent_id,
|
||||||
|
"amount_cents": requested_refund_amount_cents,
|
||||||
|
"currency": currency.lower(),
|
||||||
|
"action_digest": action_digest,
|
||||||
|
"approval_required": policy.get("approval_required", True),
|
||||||
|
"timestamp": _now(),
|
||||||
|
}
|
||||||
|
slack_result = await _post_slack_audit(setup, audit)
|
||||||
|
audit["slack_status"] = slack_result["status"]
|
||||||
|
|
||||||
|
ledger.setdefault("emails", {})[email_key] = {
|
||||||
|
"action_digest": action_digest,
|
||||||
|
"draft_reply": draft_reply,
|
||||||
|
"audit": audit,
|
||||||
|
"created_at": _now(),
|
||||||
|
}
|
||||||
|
await _save_ledger(ctx, ledger)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": policy["status"],
|
||||||
|
"duplicate": False,
|
||||||
|
"approval_required": True,
|
||||||
|
"executed": False,
|
||||||
|
"action_digest": action_digest,
|
||||||
|
"idempotency_key_preview": _idempotency_key("refund", action_digest),
|
||||||
|
"approval_instructions": (
|
||||||
|
"Review the draft and policy result. To execute the refund, call "
|
||||||
|
"execute_approved_refund with this action_digest and approval_token "
|
||||||
|
f"exactly: {_approval_phrase(action_digest)}"
|
||||||
|
),
|
||||||
|
"policy": policy,
|
||||||
|
"draft_reply": draft_reply,
|
||||||
|
"audit": audit,
|
||||||
|
"slack": slack_result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_setup(ctx: RunContext[Any]) -> dict[str, Any]:
|
||||||
|
missing: list[str] = []
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for name in ("STRIPE_SECRET_KEY", "SLACK_BOT_TOKEN"):
|
||||||
|
try:
|
||||||
|
values[name] = ctx.consumer_secret(name)
|
||||||
|
except Exception:
|
||||||
|
missing.append(name)
|
||||||
|
slack_channel = str(ctx.consumer_config("SLACK_CHANNEL_ID", "") or "").strip()
|
||||||
|
if not slack_channel:
|
||||||
|
missing.append("SLACK_CHANNEL_ID")
|
||||||
|
if missing:
|
||||||
|
return {
|
||||||
|
"status": "setup_required",
|
||||||
|
"missing": sorted(set(missing)),
|
||||||
|
"message": "Configure caller-provided Stripe and Slack setup before running this workflow.",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": "ready",
|
||||||
|
"stripe_key": values["STRIPE_SECRET_KEY"],
|
||||||
|
"slack_token": values["SLACK_BOT_TOKEN"],
|
||||||
|
"slack_channel": slack_channel,
|
||||||
|
"stripe_base_url": _clean_base_url(ctx.consumer_config("STRIPE_BASE_URL", "https://api.stripe.com")),
|
||||||
|
"slack_base_url": _clean_base_url(ctx.consumer_config("SLACK_BASE_URL", "https://slack.com/api")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_base_url(value: Any) -> str:
|
||||||
|
text = str(value or "").strip().rstrip("/")
|
||||||
|
return text or "https://api.stripe.com"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_refund_request(
|
||||||
|
*,
|
||||||
|
stripe_payment_intent_id: str,
|
||||||
|
amount_cents: int,
|
||||||
|
currency: str,
|
||||||
|
allowed_currencies: list[str],
|
||||||
|
max_refund_cents: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not re.fullmatch(r"pi_[A-Za-z0-9_]+", stripe_payment_intent_id or ""):
|
||||||
|
return {"status": "declined", "decline_code": "invalid_payment_intent", "executed": False}
|
||||||
|
currency_clean = (currency or "").lower().strip()
|
||||||
|
if currency_clean not in {c.lower() for c in allowed_currencies}:
|
||||||
|
return {"status": "declined", "decline_code": "unsupported_currency", "currency": currency_clean, "executed": False}
|
||||||
|
if amount_cents <= 0 or amount_cents > min(max_refund_cents, MAX_REFUND_AMOUNT_CENTS):
|
||||||
|
return {"status": "declined", "decline_code": "amount_out_of_policy", "max_refund_cents": max_refund_cents, "executed": False}
|
||||||
|
return {"status": "valid"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_stripe_payment_intent(setup: dict[str, Any], payment_intent_id: str) -> dict[str, Any]:
|
||||||
|
url = f"{setup['stripe_base_url']}/v1/payment_intents/{payment_intent_id}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {setup['stripe_key']}"},
|
||||||
|
params={"expand[]": "latest_charge"},
|
||||||
|
)
|
||||||
|
except (httpx.TimeoutException, asyncio.TimeoutError):
|
||||||
|
return {"status": "timeout", "error_type": "provider_timeout"}
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
return {"status": "provider_error", "error_type": type(exc).__name__}
|
||||||
|
if resp.status_code in {401, 403}:
|
||||||
|
return {"status": "auth_error", "error_type": "stripe_auth"}
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return {"status": "not_found", "error_type": "payment_intent_not_found"}
|
||||||
|
if resp.status_code == 429:
|
||||||
|
return {"status": "rate_limited", "error_type": "stripe_rate_limit"}
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return {"status": "provider_error", "error_type": "stripe_error", "status_code": resp.status_code}
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return {"status": "provider_error", "error_type": "malformed_json"}
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"payment_intent_status": data.get("status"),
|
||||||
|
"amount_received": int(data.get("amount_received") or data.get("amount") or 0),
|
||||||
|
"amount_refunded": _amount_refunded(data),
|
||||||
|
"currency": str(data.get("currency") or "").lower(),
|
||||||
|
"livemode": bool(data.get("livemode")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _amount_refunded(payment_intent: dict[str, Any]) -> int:
|
||||||
|
charge = payment_intent.get("latest_charge")
|
||||||
|
if isinstance(charge, dict):
|
||||||
|
return int(charge.get("amount_refunded") or 0)
|
||||||
|
charges = payment_intent.get("charges")
|
||||||
|
if isinstance(charges, dict):
|
||||||
|
data = charges.get("data") or []
|
||||||
|
if data and isinstance(data[0], dict):
|
||||||
|
return int(data[0].get("amount_refunded") or 0)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_refund_policy(
|
||||||
|
*,
|
||||||
|
stripe_check: dict[str, Any],
|
||||||
|
requested_amount_cents: int,
|
||||||
|
currency: str,
|
||||||
|
max_refund_cents: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if stripe_check["status"] == "timeout":
|
||||||
|
return {"status": "needs_reconciliation", "approval_required": True, "reason": "Stripe timed out; reconcile before retrying."}
|
||||||
|
if stripe_check["status"] != "ok":
|
||||||
|
return {"status": "declined", "approval_required": True, "reason": stripe_check.get("error_type", "stripe_lookup_failed")}
|
||||||
|
if stripe_check.get("livemode") is False:
|
||||||
|
# Test mode is allowed for dry-run eligibility, but still requires approval for execution.
|
||||||
|
pass
|
||||||
|
if stripe_check.get("currency") != currency.lower():
|
||||||
|
return {"status": "declined", "approval_required": True, "reason": "Currency mismatch."}
|
||||||
|
if stripe_check.get("payment_intent_status") not in {"succeeded", "requires_capture"}:
|
||||||
|
return {"status": "declined", "approval_required": True, "reason": "Payment intent is not in a refundable state."}
|
||||||
|
refundable = max(0, int(stripe_check.get("amount_received") or 0) - int(stripe_check.get("amount_refunded") or 0))
|
||||||
|
if requested_amount_cents > refundable:
|
||||||
|
return {"status": "declined", "approval_required": True, "reason": "Requested refund exceeds remaining refundable amount.", "refundable_amount_cents": refundable}
|
||||||
|
if requested_amount_cents > max_refund_cents:
|
||||||
|
return {"status": "declined", "approval_required": True, "reason": "Requested refund exceeds configured policy limit."}
|
||||||
|
return {"status": "proposed", "approval_required": True, "refundable_amount_cents": refundable, "reason": "Eligible for an approved refund."}
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_stripe_refund(setup: dict[str, Any], action: dict[str, Any], idempotency_key: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{setup['stripe_base_url']}/v1/refunds",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {setup['stripe_key']}",
|
||||||
|
"Idempotency-Key": idempotency_key,
|
||||||
|
},
|
||||||
|
data={
|
||||||
|
"payment_intent": action["payment_intent_id"],
|
||||||
|
"amount": str(action["amount_cents"]),
|
||||||
|
"reason": action["reason"],
|
||||||
|
"metadata[action_digest]": _action_digest(action),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except (httpx.TimeoutException, asyncio.TimeoutError):
|
||||||
|
return {"status": "timeout", "error_type": "provider_timeout"}
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
return {"status": "provider_error", "error_type": type(exc).__name__}
|
||||||
|
if resp.status_code in {401, 403}:
|
||||||
|
return {"status": "provider_error", "error_type": "stripe_auth"}
|
||||||
|
if resp.status_code == 429:
|
||||||
|
return {"status": "provider_error", "error_type": "stripe_rate_limit"}
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return {"status": "provider_error", "error_type": "stripe_error", "status_code": resp.status_code}
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return {"status": "provider_error", "error_type": "malformed_json"}
|
||||||
|
return {"status": "executed", "provider_refund_id": data.get("id"), "provider_status": data.get("status")}
|
||||||
|
|
||||||
|
|
||||||
|
async def _post_slack_audit(setup: dict[str, Any], audit: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = {
|
||||||
|
"channel": setup["slack_channel"],
|
||||||
|
"text": _slack_audit_text(audit),
|
||||||
|
"unfurl_links": False,
|
||||||
|
"unfurl_media": False,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{setup['slack_base_url']}/chat.postMessage",
|
||||||
|
headers={"Authorization": f"Bearer {setup['slack_token']}", "Content-Type": "application/json"},
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
except (httpx.TimeoutException, asyncio.TimeoutError):
|
||||||
|
return {"status": "timeout", "message": "Slack audit post timed out."}
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
return {"status": "provider_error", "error_type": type(exc).__name__}
|
||||||
|
if resp.status_code in {401, 403}:
|
||||||
|
return {"status": "auth_error"}
|
||||||
|
if resp.status_code == 429:
|
||||||
|
return {"status": "rate_limited"}
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return {"status": "provider_error", "status_code": resp.status_code}
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return {"status": "provider_error", "error_type": "malformed_json"}
|
||||||
|
if data.get("ok") is not True:
|
||||||
|
return {"status": "provider_error", "error_type": str(data.get("error") or "slack_error")}
|
||||||
|
return {"status": "posted", "message_ts": data.get("ts"), "channel": data.get("channel")}
|
||||||
|
|
||||||
|
|
||||||
|
def _slack_audit_text(audit: dict[str, Any]) -> str:
|
||||||
|
return (
|
||||||
|
f"Support refund audit: {audit.get('event')} status={audit.get('status')} "
|
||||||
|
f"pi={audit.get('payment_intent_id')} amount={audit.get('amount_cents')} {audit.get('currency')} "
|
||||||
|
f"digest={audit.get('action_digest')} customer_hash={audit.get('customer_email_hash')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _runtime_skills_root(ctx: RunContext[Any]) -> str:
|
def _draft_customer_reply(*, sender: str, subject: str, customer_name: str, policy: dict[str, Any], amount_cents: int, currency: str, action_digest: str) -> dict[str, str]:
|
||||||
workspace = getattr(ctx, "_workspace", None)
|
dollars = f"{amount_cents / 100:.2f} {currency.upper()}"
|
||||||
prefixes = tuple(getattr(workspace, "write_prefixes", ()) or ())
|
greeting = f"Hi {customer_name.strip() or 'there'},"
|
||||||
if not prefixes:
|
if policy["status"] == "proposed":
|
||||||
outputs_prefix = getattr(workspace, "outputs_prefix", None)
|
body = (
|
||||||
prefixes = (outputs_prefix or "outputs/",)
|
f"{greeting}\n\nThanks for contacting support. We found your payment and your requested refund of {dollars} "
|
||||||
prefix = str(prefixes[0]).strip("/")
|
"appears eligible under our refund policy. For your protection, no refund has been issued yet; "
|
||||||
return f"/{prefix}/{RUNTIME_SKILLS_DIR}" if prefix else f"/{RUNTIME_SKILLS_DIR}"
|
"a support teammate must approve the exact refund action first.\n\n"
|
||||||
|
f"Reference: {action_digest}\n\nWe will follow up once the review is complete."
|
||||||
|
)
|
||||||
|
elif policy["status"] == "needs_reconciliation":
|
||||||
|
body = (
|
||||||
|
f"{greeting}\n\nThanks for contacting support. We are reviewing your refund request, but our payment provider "
|
||||||
|
"did not respond in time. A teammate will reconcile the payment record before taking any action."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
body = (
|
||||||
|
f"{greeting}\n\nThanks for contacting support. We reviewed the refund request and cannot automatically mark it "
|
||||||
|
f"eligible because: {policy.get('reason', 'it requires manual review')}. A teammate will review it manually."
|
||||||
|
)
|
||||||
|
return {"to": sender, "subject": _reply_subject(subject), "body": body}
|
||||||
|
|
||||||
|
|
||||||
def _seed_runtime_skills(backend: Any, ctx: RunContext[Any]) -> list[str]:
|
async def _load_ledger(ctx: RunContext[Any]) -> dict[str, Any]:
|
||||||
"""Copy packaged DeepAgents skills into the invocation workspace.
|
try:
|
||||||
|
view = await ctx.workspace.open_view(
|
||||||
DeepAgents loads skills from its backend, while source-controlled
|
purpose="read support refund audit ledger",
|
||||||
``skills/`` folders live in the image. This bridge lets generated agents
|
hints=[LEDGER_PATH],
|
||||||
ship reusable SKILL.md bundles without giving up durable A2A workspace
|
max_files=1,
|
||||||
files.
|
mode=WorkspaceMode.READ_WRITE_OVERLAY,
|
||||||
"""
|
reason="Deduplicate emails and refund executions.",
|
||||||
root = Path(__file__).parent / "skills"
|
)
|
||||||
if not root.exists():
|
for f in view.files:
|
||||||
return []
|
if f.path == LEDGER_PATH:
|
||||||
runtime_skills_root = _runtime_skills_root(ctx)
|
raw = await view.read(LEDGER_PATH)
|
||||||
uploads: list[tuple[str, bytes]] = []
|
return json.loads(raw.decode("utf-8"))
|
||||||
for path in root.rglob("*"):
|
except Exception:
|
||||||
if path.is_file():
|
pass
|
||||||
rel = path.relative_to(root).as_posix()
|
return {"emails": {}, "refunds": {}}
|
||||||
uploads.append((runtime_skills_root + rel, path.read_bytes()))
|
|
||||||
if uploads:
|
|
||||||
backend.upload_files(uploads)
|
|
||||||
return [runtime_skills_root]
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _last_message_text(state: dict[str, Any]) -> str:
|
async def _save_ledger(ctx: RunContext[Any], ledger: dict[str, Any]) -> None:
|
||||||
messages = state.get("messages") or []
|
data = json.dumps(ledger, sort_keys=True, indent=2).encode("utf-8")
|
||||||
if not messages:
|
try:
|
||||||
return json.dumps(state, default=str)
|
grant = await ctx.workspace.request_access(
|
||||||
|
files=[LEDGER_PATH],
|
||||||
|
mode=WorkspaceMode.READ_WRITE_OVERLAY,
|
||||||
|
reason="Persist support email and refund idempotency ledger.",
|
||||||
|
purpose="write support refund audit ledger",
|
||||||
|
)
|
||||||
|
view = await ctx.workspace.open_view(
|
||||||
|
purpose="write support refund audit ledger",
|
||||||
|
hints=[LEDGER_PATH],
|
||||||
|
max_files=1,
|
||||||
|
mode=WorkspaceMode.READ_WRITE_OVERLAY,
|
||||||
|
reason=f"Use grant {grant.grant_id} to write audit ledger.",
|
||||||
|
)
|
||||||
|
await view.write(LEDGER_PATH, data)
|
||||||
|
except Exception:
|
||||||
|
# Artifact fallback keeps an audit copy even if durable workspace access is unavailable.
|
||||||
|
ref = await ctx.write_artifact("support-refund-audit-ledger.json", data, "application/json")
|
||||||
|
await ctx.emit_artifact(ref)
|
||||||
|
|
||||||
content = getattr(messages[-1], "content", None)
|
|
||||||
if isinstance(content, str):
|
def _normalize_refund_action(*, payment_intent_id: str, amount_cents: int, currency: str, customer_email: str, reason: str) -> dict[str, Any]:
|
||||||
return content
|
return {
|
||||||
if isinstance(content, list):
|
"payment_intent_id": payment_intent_id.strip(),
|
||||||
parts: list[str] = []
|
"amount_cents": int(amount_cents),
|
||||||
for item in content:
|
"currency": currency.lower().strip(),
|
||||||
if isinstance(item, dict):
|
"customer_email_hash": _hash_customer(customer_email),
|
||||||
text = item.get("text") or item.get("content")
|
"reason": reason.strip() or "requested_by_customer",
|
||||||
if text:
|
}
|
||||||
parts.append(str(text))
|
|
||||||
elif item:
|
|
||||||
parts.append(str(item))
|
def _action_digest(action: dict[str, Any]) -> str:
|
||||||
return "\n".join(parts) if parts else json.dumps(content, default=str)
|
return hashlib.sha256(json.dumps(action, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||||
return str(content or messages[-1])
|
|
||||||
|
|
||||||
|
def _approval_phrase(action_digest: str) -> str:
|
||||||
|
return f"APPROVE-REFUND-{action_digest[:16]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _idempotency_key(prefix: str, digest: str) -> str:
|
||||||
|
return f"a2a-{prefix}-{digest[:32]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _email_key(message_id: str, sender: str, payment_intent_id: str) -> str:
|
||||||
|
return _stable_hash("|".join([message_id.strip().lower(), sender.strip().lower(), payment_intent_id.strip()]))
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_customer(value: str) -> str:
|
||||||
|
return _stable_hash((value or "").strip().lower())[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_hash(value: str) -> str:
|
||||||
|
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _sender_email(value: str) -> str:
|
||||||
|
parsed = parseaddr(value)[1]
|
||||||
|
return parsed or value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _reply_subject(subject: str) -> str:
|
||||||
|
text = (subject or "Support request").strip()
|
||||||
|
return text if text.lower().startswith("re:") else f"Re: {text}"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_refund_request(email: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
text = f"{email.get('subject', '')}\n{email.get('body', '')}"[:MAX_EMAIL_BODY_CHARS]
|
||||||
|
pi_match = re.search(r"\b(pi_[A-Za-z0-9_]+)\b", text)
|
||||||
|
amount_match = re.search(r"(?:\$|USD\s*)?(\d+(?:\.\d{1,2})?)\s*(usd|USD)?", text)
|
||||||
|
cents = int(float(amount_match.group(1)) * 100) if amount_match else 0
|
||||||
|
sender = _sender_email(str(email.get("sender") or ""))
|
||||||
|
name = parseaddr(str(email.get("sender") or ""))[0] or "customer"
|
||||||
|
return {
|
||||||
|
"stripe_payment_intent_id": pi_match.group(1) if pi_match else "",
|
||||||
|
"amount_cents": cents,
|
||||||
|
"currency": "usd",
|
||||||
|
"customer_email": sender,
|
||||||
|
"customer_name": name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> int:
|
||||||
|
return int(time.time())
|
||||||
|
|||||||
Reference in New Issue
Block a user