Skip to content
LangSmith 文档版本 跟随上游 源快照 915c36bf 更新于 2026-10-18

使用 agent 自有中断提示 Slack 用户

从 Managed Deep Agents 工具发布一个 Slack 表单,暂停运行,并在用户提交表单时恢复它。

agent 自有中断是由你自己的代码创建的暂停。一个工具发布一条你在 Block Kit 中设计的 Slack 消息或模态框,然后调用 interrupt()。当用户按下按钮或提交模态框时,Managed Deep Agents 会将答案作为 user_prompt_response 渠道事件交付给部署,并用它恢复被暂停的运行。你可以在运行中途向用户请求结构化输入,例如一次批准、一个选择或一组表单字段,而无需构建 Slack 应用或你自己的 webhook。

这与 interrupt_on 创建的平台自有中断不同。那些会在工具调用前暂停,并由 Slack 或 Studio 为你渲染批准卡片。使用 agent 自有中断时,你拥有这些 block、表单状态以及答案到达后会发生什么。平台只负责把答案带回来。有关平台自有中断,请参阅人在回路。

NOTE

Managed Deep Agents 处于公开 beta 阶段,仅在 US 区域的 LangSmith Cloud 上可用。

NOTE

agent 自有中断需要 managed-deepagents>=0.8.0,以及部署 agent 上的一个 Slack 渠道。表单只在 Slack 中渲染。LangSmith Studio 显示原始的中断值。

工作原理

  1. 一次运行从一条 Slack 消息开始。你的工具从 runtime.channel 读取对话,并使用部署的 bot token 将一条 Block Kit 消息发布到同一个 Slack 线程中。
  2. 工具调用 interrupt(),传入一个带有相关性 ID 的值。线程停在检查点。
  3. 用户按下按钮或提交模态框。Slack 将该交互发送到 Managed Deep Agents,后者将其作为 user_prompt_response 事件转发给部署。
  4. SDK 将该事件与停泊的中断匹配并恢复运行。interrupt() 返回用户的响应,工具继续执行。

以这种方式暂停的工具在运行恢复时会从顶部再次执行,这是标准的 LangGraph 中断行为。对发布做防护,使其只发生一次。参阅发布表单并暂停。

发布表单并暂停

该工具需要三样东西:要发布到的 Slack 对话、bot token,以及将表单与中断关联起来的相关性 ID。

  • 对话:runtime.channel.raw_event 是启动该运行的 Slack 事件。它带有 channel、ts,对于线程消息还带有 thread_ts。发布到那个线程中,以便答案路由回同一个 agent 线程。
  • Bot token:connections.get("mda/slack-bot-token", {"type": "agent"}) 解析 Managed Deep Agents 为该部署所配置的 Slack 应用的 token。该 slug 是保留的,无法通过 mda connections create 创建。
  • 相关性 ID:将同一个字符串放在每个按钮的 value(作为 JSON)中,以及你传给 interrupt() 的值里的 INTERRUPT_CORRELATION_KEY 下。

内置 Slack 渠道的 runtime.channel.post 只发送文本,因此请直接通过 Slack Web API 发布 Block Kit。

python
import json
import uuid

import httpx
from langchain.tools import tool
from langgraph.types import interrupt
from managed_deepagents import (
    INTERRUPT_CORRELATION_KEY,
    ManagedDeepAgentRuntime,
    connections,
)

@tool(parse_docstring=True)
async def request_approval(amount: str, runtime: ManagedDeepAgentRuntime) -> str:
    """Ask the requester to approve or decline a spend.

    Args:
        amount: The spend to approve, such as "$1,200".
    """
    channel = runtime.channel
    if channel is None or channel.provider != "slack":
        return "Approval forms are available only in Slack."

    correlation_id = str(uuid.uuid4())
    # The tool runs again when the interrupt resumes. Post only on the first pass.
    if channel.event["type"] != "user_prompt_response":
        await post_approval_form(channel.raw_event, amount, correlation_id)

    response = interrupt({INTERRUPT_CORRELATION_KEY: correlation_id, "amount": amount})
    return f"The requester chose {response['action']} for {amount}."

async def post_approval_form(raw_event: dict, amount: str, correlation_id: str) -> None:
    bot_token = await connections.get("mda/slack-bot-token", {"type": "agent"})
    value = json.dumps({INTERRUPT_CORRELATION_KEY: correlation_id})
    blocks = [
        {
            "type": "section",
            "text": {"type": "mrkdwn", "text": f"Approve a spend of *{amount}*?"},
        },
        {
            "type": "actions",
            "elements": [
                {
                    "type": "button",
                    "action_id": "approve_spend",
                    "text": {"type": "plain_text", "text": "Approve"},
                    "value": value,
                },
                {
                    "type": "button",
                    "action_id": "decline_spend",
                    "text": {"type": "plain_text", "text": "Decline"},
                    "value": value,
                },
            ],
        },
    ]
    async with httpx.AsyncClient(timeout=30.0) as client:
        result = await client.post(
            "https://slack.com/api/chat.postMessage",
            headers={"Authorization": f"Bearer {bot_token}"},
            json={
                "channel": raw_event["channel"],
                "thread_ts": raw_event.get("thread_ts") or raw_event["ts"],
                "text": f"Approve a spend of {amount}?",
                "blocks": blocks,
            },
        )
        body = result.json()
        if not body.get("ok"):
            raise RuntimeError(f"Slack rejected the form: {body.get('error')}")

像对待任何其他自定义工具那样,在 agent 定义中注册该工具。中断继承托管的检查点器,因此无需额外的持久化设置。

消息中按钮的任何 action_id 都有效,除了以 mda_modal:: 开头的那个。那个前缀会改为打开一个模态框。参阅打开模态框。

读取响应

interrupt() 返回规范化的响应。它只包含每个提供商共有的字段:

字段类型含义
provider"slack"交付答案的渠道提供商。
actionstring按钮的 action_id,或模态框的 callback_id。
valueJSON,可选按钮的 value 字符串,或模态框的 state.values 对象。对平台不透明。
correlation_idstring,可选当按钮的 value 是带有 mda_correlation_id 键的 JSON 时存在。模态框提交从不携带它。

同样的响应在运行上下文上以 runtime.channel.event 可用,其 type 设为 user_prompt_response。Slack 逐字的交互负载在 runtime.channel.raw_event 上。对于在消息内含输入表单的情况,用户的条目位于 raw_event["state"]["values"],按 block ID 和 action ID 作为键。

Managed Deep Agents 只读取 Slack 交互负载中的第一个动作,因此一次按下必须只对应一个事件。

响应如何找到它的中断

Slack 不知道某个按钮属于哪个中断,因此 SDK 会读取停泊在线程上的中断并做出判断:

停泊的中断响应上的 correlation_id结果
一个缺失恢复该中断。
一个或多个存在且恰好匹配一个恢复匹配的中断。
多个缺失什么都不运行。SDK 记录该歧义并确认该事件。
一个或多个存在但不匹配任何一个,或匹配多个什么都不运行。启动一个回合会放弃停泊的检查点。
无任意在不带新消息的情况下启动一次全新运行。响应只能在 runtime.channel.event 上获取。

当 agent 可能在一个线程上停泊多个中断时,务必带上相关性 ID。没有它,一个线程只有在恰好一个中断待处理时才能被回答。

打开模态框

模态框比消息提供更多空间,并支持完整的 Block Kit 输入元素集。Slack 把模态框称为 view。你编写 view,Managed Deep Agents 打开它。

Slack 会为每次点击签发一个单次使用的 trigger_id,它大约在三秒后过期,并且它是打开模态框的唯一授权。将该点击转发给部署并等待一次运行会错过那个窗口,因此 Managed Deep Agents 直接从点击中已有的内容打开 view。agent 不会被告知这次点击。

要打开模态框,发布一个 action_id 以 mda_modal:: 开头、且 value 为完整 view(作为 JSON)的按钮:

json
{
  "type": "button",
  "action_id": "mda_modal::approve_spend",
  "text": { "type": "plain_text", "text": "Review" },
  "value": "{\"type\":\"modal\",\"title\":{\"type\":\"plain_text\",\"text\":\"Approve spend\"},\"submit\":{\"type\":\"plain_text\",\"text\":\"Submit\"},\"blocks\":[...]}"
}

当用户按下提交时,部署会收到一个 user_prompt_response,其 action 设为 view 的 callback_id(等于按钮的 action_id),value 设为 view 的 state.values。随后模态框关闭。

view 上有两个字段属于平台:

  • callback_id:设为按钮的 action_id,以便提交可被识别。
  • private_metadata:保存模态框被打开时所在的加密渠道和线程。模态框提交不携带渠道或线程,因此这是回到对话的唯一路径。如果你的 view 已经设置了 private_metadata,模态框将不会打开。

每个其他字段都按你编写的那样原样发送到 Slack。Managed Deep Agents 不校验 view,因此一个被 Slack 拒绝的 view 不会向用户显示任何内容。

模态框提交从不携带 correlation_id。模态框只有在恰好一个中断停泊时才能回答一个线程。

整个 view 必须能放进按钮的 value 中,而 Slack 将其上限设为 2000 个字符。当工具发布消息时,Slack 会拒绝一个过大的按钮,因此失败会出现在工具调用中,而不是点击时。

发布但不暂停

一个工具可以发布一个表单并让运行结束,而不调用 interrupt()。随后提交会在不带新消息的情况下启动一次全新运行,你的代码从 runtime.channel.event 读取答案。平台上没有任何东西在跟踪是否有一个提示待处理,因此你的应用自己掌握待处理表单的状态、过期,以及当表单打开时后续聊天消息意味着什么。

当预期用户接下来要做的是回答该提示时,更倾向于使用 interrupt()。

限制与保留名称

平台保留的名称:

名称位置含义
mda_modal:: 前缀按钮 action_id该点击打开一个模态框,且永不到达 agent。
mda_correlation_id按钮 value 内的键将一次点击匹配到一个停泊的中断。导出为 INTERRUPT_CORRELATION_KEY。
callback_id应用定义的 view设为按钮的 action_id。
private_metadata应用定义的 view由平台设置。提供它会阻止模态框打开。
mda/slack-bot-token连接 slug部署的 Slack bot token。无法用 CLI 创建或覆盖。

Managed Deep Agents 在转发 view 前不会检查的 Slack 限制:

限制值
按钮 value2000 个字符
模态框标题24 个字符
每个 view 的 blocks100
callback_id255 个字符

超过最后三项会使模态框无法打开,且不向用户显示任何内容。

了解失败模式

  • 模态框不打开。 view JSON 格式错误、超过某个 Slack 限制,或 trigger_id 已过期。在 Slack 中按下按钮没有任何可见效果。失败会在平台上记录日志。
  • 用户关闭了模态框。 中断保持停泊。没有取消路径,也没有过期。
  • 用户在线程中回复而不是使用表单。 该消息走普通的消息路径,不会参考中断状态。运行以新消息开始,停泊的检查点被放弃,而卡片仍保留其可用按钮。
  • 两个人按下同一个按钮。 两个交互都会被转发。第一个恢复中断,第二个启动一个全新回合。没有任何东西对它们去重。
  • 多个中断停泊且响应没有相关性 ID。 什么都不运行。由于模态框提交从不携带相关性 ID,模态框无法回答有两个停泊中断的线程。
  • 提交无法返回 response_action。 没有逐字段校验错误,没有链式模态框,也没有原地模态框更新。

另请参阅


BytesRouter 是社区翻译站点,所有译文仅供参考,一切以官方英文原文为准。