文章建议在集成前核查服务是否返回稳定且可查询的操作标识,以及是否接受调用方生成的幂等键或引用标识。这些能力决定请求超时后能否核对执行结果,以及重试是否会造成重复操作。
大多数 Agent 集成问题都不是逻辑错误,而是假设错误——具体来说,是你想当然地认为,出了问题时,服务提供方会给你足够的信息。
先写重试逻辑,上线后才发现服务提供方的能力缺口,再随着问题暴露,逐个给服务提供方打补丁。这是最常见的路径。这些补救措施通常是对的,却没有留下文档,于是接入下一个服务提供方时,又得重来一遍。
在开始集成前问三个问题,只需要十分钟。另一条路的代价要高得多。
在把任何服务提供方接入 Agent 的操作执行链路之前,先问:
这是后续核对的锚点。如果调用在返回之前超时,你需要有个标识,方便稍后查询。如果服务提供方什么都不返回,或者返回的 ID 无法查询,那么超时后,你就失去了核对的依据。
这决定了在一致性窗口内重试是否安全。如果服务提供方接受你在发起操作前生成的键,那么使用同一个键重试时,就会返回原始结果,而不会重复创建。没有这项能力,在窗口内重试就是碰运气。
这决定了事后核对是否可行。如果你能通过产物 ID 查询当前状态,就能在不重新发起操作的情况下确认交付结果。没有这个接口,你只能摸黑核对。
`enum class ProviderCapabilityTier {
FULL, // Returns artifact ID + accepts idempotency key + queryable by ID
RECONCILABLE, // Returns artifact ID + queryable, no idempotency key support
PARTIAL, // Returns artifact ID, no query endpoint
BLIND // None of the above
}
data class ProviderCapabilities(
val returnsArtifactId: Boolean,
val supportsIdempotencyKey: Boolean,
val hasQueryEndpoint: Boolean
) {
fun tier(): ProviderCapabilityTier = when {
returnsArtifactId && supportsIdempotencyKey && hasQueryEndpoint ->
ProviderCapabilityTier.FULL
returnsArtifactId && hasQueryEndpoint ->
ProviderCapabilityTier.RECONCILABLE
returnsArtifactId ->
ProviderCapabilityTier.PARTIAL
else ->
ProviderCapabilityTier.BLIND
}
}`
`class FullCapabilityDispatcher(val provider: FullProvider) {
suspend fun dispatch(action: Action): ActionRecord {
val idempotencyKey = generateIdempotencyKey(action)
// Store before dispatch — key exists even if call times out
val record = ActionRecord(
actionId = action.id,
dispatchId = idempotencyKey,
delivery = DeliveryState.PENDING,
eligibleForReconciliationAt = Instant.now().plus(provider.consistencyWindow)
)
store(record)
val result = provider.dispatch(action, idempotencyKey = idempotencyKey)
return record.copy(
providerObjectId = result.artifactId,
delivery = DeliveryState.PENDING // Still pending until lookup confirms
)
}
}`
使用同一个幂等键重试,会返回原始结果。在一致性窗口内遇到“未找到”,代价只是多等一会儿,绝不会造成重复操作。
`class ReconcilableDispatcher(val provider: ReconcilableProvider) {
suspend fun dispatch(action: Action): ActionRecord {
val dispatchId = generateDispatchId(action)
val record = ActionRecord(
actionId = action.id,
dispatchId = dispatchId,
delivery = DeliveryState.PENDING,
eligibleForReconciliationAt = Instant.now().plus(provider.consistencyWindow)
)
store(record)
return try {
val result = provider.dispatch(action)
record.copy(providerObjectId = result.artifactId)
} catch (e: TimeoutException) {
// dispatchId is the reconciliation anchor
// Can query by it after the consistency window closes
record // Stays PENDING
}
}
suspend fun reconcile(record: ActionRecord): ActionRecord {
if (!record.isEligibleForReconciliation()) return record
// Query by the dispatch ID we generated
val artifact = provider.lookupByDispatchId(record.dispatchId)
return if (artifact != null) {
record.copy(
providerObjectId = artifact.id,
delivery = DeliveryState.CONFIRMED
)
} else {
record.copy(delivery = DeliveryState.FAILED)
}
}
}`
不支持幂等键,就意味着在窗口内重试存在风险。此时,最小等待时长这个门槛更加重要——一致性窗口结束前,不要重试。
`class PartialCapabilityDispatcher(val provider: PartialProvider) {
suspend fun dispatch(action: Action): ActionRecord {
val record = ActionRecord(
actionId = action.id,
dispatchId = generateDispatchId(action),
delivery = DeliveryState.PENDING,
// Note: longer window because we can't query to confirm
eligibleForReconciliationAt = Instant.now().plus(CONSERVATIVE_WINDOW)
)
store(record)
return try {
val result = provider.dispatch(action)
// Has artifact ID but no query endpoint
// Can only confirm by checking artifact indirectly
record.copy(
providerObjectId = result.artifactId,
delivery = DeliveryState.PENDING
)
} catch (e: TimeoutException) {
// No artifact ID returned, no query endpoint
// Cannot reconcile programmatically
record.copy(delivery = DeliveryState.DENIED_UNRESOLVED)
}
}
}`
超时且没有返回产物 ID,就意味着 DENIED_UNRESOLVED。应当把它记录为集成的约束条件,而不是留待以后修复的 bug。
`class BlindProviderDispatcher(val provider: BlindProvider) {
suspend fun dispatch(action: Action): ActionRecord {
// Document the constraint before the first line of integration code
require(action.requiresHumanReviewOnTimeout) {
"BlindProvider integrations must have human review configured for timeout cases"
}
val record = ActionRecord(
actionId = action.id,
dispatchId = generateDispatchId(action),
delivery = DeliveryState.PENDING,
humanReviewRequired = true
)
store(record)
return try {
provider.dispatch(action)
// Even on success, no artifact ID to anchor reconciliation
record.copy(delivery = DeliveryState.PENDING)
} catch (e: TimeoutException) {
record.copy(
delivery = DeliveryState.DENIED_UNRESOLVED,
humanReviewFlag = HumanReviewFlag(
reason = "Blind provider timeout — action may have succeeded",
requiredAction = "Manual verification required before retry"
)
)
}
}
}`
接入 BLIND 等级的服务提供方,并不一定是错误的决定——但它的超时处理路径,必然需要人工介入。要把这种情况明确交给人处理,别让 DENIED_UNRESOLVED 留在自动重试的队列里。
`fun evaluateProvider(provider: Provider): ProviderEvaluation {
return ProviderEvaluation(
provider = provider,
capabilities = ProviderCapabilities(
returnsArtifactId = provider.dispatchReturnSchema.contains("id"),
supportsIdempotencyKey = provider.requestSchema.contains("idempotency_key")
|| provider.requestSchema.contains("reference_id"),
hasQueryEndpoint = provider.endpoints.any { it.queryableById }
),
documentedConstraints = buildConstraintList(provider),
recommendedTimeoutHandling = tier.timeoutStrategy()
)
}`
在编写集成代码之前,先运行这项检查。它的输出应当成为放在集成代码旁边的文档,而不是只存在于某个人脑子里的假设。
对于 Android 设备端的 Agent 操作,无障碍层确认的是 UI 操作已经发生——点击已经触发,按钮已经按下。下游服务提供方是否记录了结果,则是另一个问题,需要按照另一套时间尺度来判断。
无障碍服务确认日历预约操作已执行,是一次验证。日历服务提供方返回可查询的事件 ID,则是第二次独立验证。两者回答的是不同的问题。服务提供方的能力等级,决定了这两次验证中,你究竟能执行哪些。
提前花十分钟,记录能力等级,在真正需要处理超时之前,就把超时路径设计好。
如果需要采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。