跳到主要内容

表单输入

概述

表单输入有两种入口。第一种是模型在执行业务动作前调用 request_user_input 选择表单;用户提交后, Runner 将结构化数据作为 ToolMessage 返回模型,由模型判断并调用后续业务 Tool。第二种是业务 Tool 执行过程中发现缺少信息,抛出 AgentFormRequiredException;Runner 挂起并在提交后重放原 Tool。 两种方式都通过 AgentFormMessage 向前端提供 Schema,并使用 submitResumeresume 恢复 Turn。

快速开始

方式一:模型主动请求表单

下面的例子中,表单只负责收集预定资料,reserve_meeting_room 才是真正执行预定的业务 Tool:

java
AgentFormDefinition meetingForm = AgentFormDefinition.builder("meeting_room_booking")
    .description("预定会议室时收集会议主题、时间和参会人数")
    .schema(meetingSchema)
    .build();

Tool reserveMeetingRoom = Tool.builder("reserve_meeting_room", "根据完整资料预定会议室")
    .function(arguments -> reserveRoom(arguments))
    .build();

Agent agent = Agent.builder("meeting-agent")
    .instructions("用户要求预定会议室时先调用 request_user_input 选择 meeting_room_booking;"
        + "提交后执行 reserve_meeting_room,不要只回复已收到表单。")
    .chatModel(chatModel)
    .tool(AgentUserInputTool.builder().form(meetingForm).build())
    .tool(reserveMeetingRoom)
    .build();

执行顺序是:模型调用 request_user_input,Runner 保存 Schema 并挂起;前端渲染 AgentFormMessage;用户提交后 Runner 追加匹配原调用的 ToolMessage 并回到 MODEL 阶段。模型读取 表单数据后,自行判断并调用 reserve_meeting_room,随后根据工具结果生成最终回答。Runner 不保存 目标 Tool 名称,也不会替模型生成业务 ToolCall。

方式二:业务 Tool 执行中动态请求表单

1. 定义并注册表单

AgentFormDefinition 只包含 formKeydescription 和标准 JSON Schema:

java
Map<String, Object> affectedSystem = new LinkedHashMap<>();
affectedSystem.put("type", "string");
affectedSystem.put("title", "受影响系统");

Map<String, Object> impactScope = new LinkedHashMap<>();
impactScope.put("type", "string");
impactScope.put("title", "影响范围");
impactScope.put("enum", Arrays.asList(
    "ONE_USER", "PARTIAL_USERS", "ALL_USERS"));

Map<String, Object> properties = new LinkedHashMap<>();
properties.put("affectedSystem", affectedSystem);
properties.put("impactScope", impactScope);

Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", "object");
schema.put("title", "补充故障信息");
schema.put("properties", properties);
schema.put("required", Arrays.asList("affectedSystem", "impactScope"));

AgentFormDefinition form = AgentFormDefinition
    .builder("support_ticket_details")
    .description("创建故障工单缺少受影响系统或影响范围时使用")
    .schema(schema)
    .build();

Agent agent = Agent.builder("support-agent")
    .instructions("创建故障工单缺少必要信息时调用 request_user_input;不要猜测缺失字段。")
    .chatModel(chatModel)
    .tool(AgentUserInputTool.builder().form(form).build())
    .tool(createTicketTool)
    .build();

description 用于说明表单的用途,会连同 formKey 出现在 Tool description 中,帮助模型选择。Schema 保存在 Tool metadata, 不会进入模型可见的参数定义。模型只会产生类似调用:

json
{
  "name": "request_user_input",
  "arguments": {
    "formKey": "support_ticket_details"
  }
}

formKey 参数使用枚举约束,Runner 还会再次校验该 key 是否已经注册。

2. 执行到表单等待点

java
AgentTurn waiting = runner.run(
    agent, "conversation-1", "创建一个高优先级登录故障工单");

if (waiting.getStatus() != AgentTurnStatus.WAITING_FOR_USER) {
    throw new IllegalStateException("expected user input");
}

AgentSuspension suspension = waiting.getSuspension();
String turnId = waiting.getId();
String actionId = suspension.getCorrelationId();
String formKey = String.valueOf(suspension.getMetadata().get("formKey"));
Map<String, Object> savedSchema =
    (Map<String, Object>) suspension.getMetadata().get("schema");

此时原 ToolCall 仍在 pendingToolCalls,Phase 为 TOOLS。Snapshot 保存的是当次请求选中的完整 Schema; 之后修改 Agent 上的表单定义,只影响新 Turn,不会改变已经等待中的页面。

3. 校验并提交表单

业务 API 使用 Suspension 中固化的 Schema 校验提交数据,再恢复原 Turn:

java
Map<String, Object> formData = new LinkedHashMap<>();
formData.put("affectedSystem", "登录系统");
formData.put("impactScope", "ALL_USERS");

// 先完成登录校验、权限校验、JSON Schema 校验和业务幂等处理。
AgentTurn runnable = runner.submitResume(
    turnId,
    AgentResumeCommand.userInput(actionId, formData)
        .withMetadata("submittedBy", "user-1001")
        .withMetadata("requestId", "request-789"));

submitResume(...) 只保存为可运行状态,适合交给 Worker;需要在当前线程立即继续时使用 resume(...)。两者都会校验 actionId,错误或迟到的提交不能恢复其他请求。

完整场景:创建故障工单

假设用户输入:

帮我创建一个高优先级的登录故障工单。

创建工单还需要“受影响系统”和“影响范围”,但用户没有提供。整个过程如下。

1. 模型选择业务表单

模型能看到 request_user_input、允许选择的 formKey 及其 description,但看不到 Schema。它判断当前 信息不足后产生 ToolCall:

json
{
  "id": "call-456",
  "name": "request_user_input",
  "arguments": {
    "formKey": "support_ticket_details"
  }
}

request_user_input 是控制工具,它的 Java function 不会执行。Runner 识别该 ToolCall 后接管流程, 使用 formKey 找到已注册的 AgentFormDefinition

2. Runner 建立等待点

Runner 保留原 ToolCall,创建 USER_INPUT Suspension,并把完整 Schema 保存到 Snapshot。Turn 随后 进入:

text
status = WAITING_FOR_USER
phase  = TOOLS
correlationId = call-456

配置 chatMemoryProvider 后,Runner 同时向当前会话投影一条状态为 PENDINGAgentFormMessage。该消息直接携带 Schema,且 modelVisible=false,因此用于页面展示但不会进入模型 上下文。

Runner 还会发布 TURN_SUSPENDED 等生命周期事件。业务系统可以通过 AgentEventListener 将“会话已 更新”通知推送给前端;事件本身不是表单数据来源,前端应从 ChatMemory 读取 AgentFormMessage

3. 前端渲染并提交

前端根据 AgentFormMessage.schema 渲染文本框、选择框和必填校验。用户填写:

json
{
  "affectedSystem": "统一登录系统",
  "impactScope": "ALL_USERS"
}

业务接口根据 Suspension 中固化的同一份 Schema 校验数据,并使用消息的 actionId 恢复原 Turn:

java
runner.submitResume(
    "turn-123",
    AgentResumeCommand.userInput("call-456", submittedValues)
        .withMetadata("submittedBy", "user-1001"));

提交成功后,ChatMemory 中原来的 AgentFormMessage 会通过 CAS 更新为 SUBMITTED,页面据此隐藏提交 按钮并显示已提交内容。

4. 表单内容成为工具结果

Runner 不会把表单提交当成新的 UserMessage。它会生成一条与原 ToolCall ID 匹配的 ToolMessage:

json
{
  "toolCallId": "call-456",
  "content": {
    "status": "submitted",
    "formKey": "support_ticket_details",
    "data": {
      "affectedSystem": "统一登录系统",
      "impactScope": "ALL_USERS"
    }
  }
}

因此,从模型协议看,这是 request_user_input(call-456) 的执行结果;只是结果来自用户填写,而不是 Java Tool function。

5. 模型继续业务任务

Runner 从原暂停位置继续调用模型。模型读取表单结果后,已经具备创建工单所需的信息,可以调用真正的 业务工具:

json
{
  "name": "create_support_ticket",
  "arguments": {
    "title": "登录故障",
    "priority": "HIGH",
    "affectedSystem": "统一登录系统",
    "impactScope": "ALL_USERS"
  }
}

如果该工具具有外部写入等副作用,还可以继续进入人工审批流程。表单输入负责收集 执行所需信息,人工审批负责决定已经确定的 ToolCall 是否允许执行,两者职责不同。

工具执行时动态请求表单

前面的流程由模型主动调用 request_user_input,适合模型在执行工具前就能判断缺少哪些信息的场景。 如果只有进入业务工具、查询业务规则后才能确定所需字段,可以让工具抛出 AgentFormRequiredException

java
Tool createTicketTool = Tool.builder("create_support_ticket")
    .description("创建故障工单")
    .function(arguments -> {
        AgentToolContext context = AgentToolContext.current();
        Map<String, Object> submitted = context.getSubmittedFormData();

        if (submitted.isEmpty()) {
            throw new AgentFormRequiredException(
                AgentFormDefinition.builder("support_ticket_details")
                    .description("工具发现缺少受影响系统或影响范围")
                    .schema(supportTicketSchema)
                    .build());
        }

        return createSupportTicket(
            String.valueOf(submitted.get("affectedSystem")),
            String.valueOf(submitted.get("impactScope")),
            context.getIdempotencyKey());
    })
    .build();

Runner 将该异常作为执行控制信号,而不是工具失败:

text
第一次执行 create_support_ticket
→ 抛出 AgentFormRequiredException
→ 保留原 create_support_ticket ToolCall
→ 保存 Schema,进入 WAITING_FOR_USER
→ 前端提交 formData
→ formData 保存进 AgentTurnSnapshot
→ 从头重新执行 create_support_ticket
→ AgentToolContext.getSubmittedFormData() 返回提交内容
→ 工具完成并生成 create_support_ticket 的 ToolMessage

这个入口不需要模型再调用 request_user_input,但它与模型入口使用相同的 AgentFormMessage、前端 渲染和提交 API。两种入口的恢复语义不同:

表单入口提交数据的去向恢复动作
模型调用 request_user_input形成控制 ToolCall 的 ToolMessage回到 MODEL,由模型选择后续 Tool
业务工具抛出输入异常保存为原业务 ToolCall 的恢复数据从头重新执行原工具

工具输入异常必须在产生外部副作用之前抛出,因为 Framework 不会恢复 Java 调用栈,只会重放整个工具 函数。工具应使用 AgentToolContext.getIdempotencyKey() 保证外部写入幂等;同一个 ToolCall 在已经获得 提交数据后再次抛出输入异常会被视为协议错误。首次无副作用的中断和恢复后的执行按同一个逻辑 ToolCall 计算,不会因为表单交互额外消耗 maxToolCalls 配额。

前端渲染

配置 chatMemoryProvider 后,Runner 会把等待状态投影为 AgentFormMessage。消息直接携带 Schema:

json
{
  "type": "agent_form",
  "messageId": "turn-123:input:call-456",
  "turnId": "turn-123",
  "actionId": "call-456",
  "formKey": "support_ticket_details",
  "schema": {
    "type": "object",
    "title": "补充故障信息",
    "properties": {
      "affectedSystem": {
        "type": "string",
        "title": "受影响系统"
      },
      "impactScope": {
        "type": "string",
        "title": "影响范围",
        "enum": ["ONE_USER", "PARTIAL_USERS", "ALL_USERS"]
      }
    },
    "required": ["affectedSystem", "impactScope"]
  },
  "status": "PENDING",
  "actions": ["SUBMIT"]
}

前端直接使用消息中的标准 JSON Schema 渲染表单,不需要再通过 formKey 查询另一套注册中心。Schema 只能描述数据结构和校验规则,不应包含 HTML、JavaScript 或可执行表达式。

消息状态

状态页面行为
PENDING渲染表单并允许提交,getActions() 返回 SUBMIT
SUBMITTED展示已提交结果,不再显示提交按钮
CANCELLEDTurn 已取消或终止,表单只读

提交成功后,Runner 使用 expectedVersion CAS 将原 AgentFormMessage 更新为 SUBMITTED,不会追加 另一条表单结果消息。submittedValues 可用于回显,submittedBysubmittedAt 用于展示当前状态。

AgentFormMessage.modelVisible=false,Schema 和页面状态不会占用模型上下文。模型读取的是恢复时生成的 ToolMessage:

json
{
  "status": "submitted",
  "formKey": "support_ticket_details",
  "data": {
    "affectedSystem": "登录系统",
    "impactScope": "ALL_USERS"
  }
}

执行流程

纯文本输入

底层主动挂起机制仍支持简单文本提交:

java
AgentTurn waiting = runner.suspend(
    turn, AgentSuspension.userInput("请提供订单号"));
runner.submitResume(
    waiting.getId(), AgentResumeCommand.userInput("订单号是 O-1001"));

这条兼容路径不使用 request_user_input,也不会生成 AgentFormMessage。普通多轮聊天追问则无需挂起: 让当前 Turn 正常回答,下一条 UserMessage 创建新的 Turn 即可。

安全与一致性

  • 模型只能看到 formKey 和使用条件,不能定义或修改 Schema。
  • 前端使用消息中的 Schema 渲染;后端必须使用同一份 Schema 重新校验,不能信任前端隐藏字段。
  • formData 属于输入正文;提交人、渠道和请求 ID 等审计属性放入 command metadata。
  • 外部请求先按 requestId 幂等保存,再调用 submitResume(...)
  • ChatMemory 是页面投影,执行状态以 AgentTurnStore 中的 Snapshot 为准。
  • Schema 变化只影响新 Turn;等待中的 Turn 使用 Snapshot 已固化的定义。

通用 Suspension、同步与异步恢复语义见 挂起和恢复