package com.yunhai.agent.service;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.yunhai.agent.auth.AgentRole;
import com.yunhai.agent.config.AgentSettings;
import com.yunhai.agent.llm.LlmClient;
import com.yunhai.agent.llm.dto.ChatMessage;
import com.yunhai.agent.llm.dto.ChatResult;
import com.yunhai.agent.llm.dto.ToolCall;
import com.yunhai.agent.model.BrowserToolRequest;
import com.yunhai.agent.model.ChatRequest;
import com.yunhai.agent.model.ChatResponse;
import com.yunhai.agent.model.ToolCallTrace;
import com.yunhai.agent.tool.CallerContext;
import com.yunhai.agent.tool.FormField;
import com.yunhai.agent.tool.FormRequest;
import com.yunhai.agent.tool.ToolDef;
import com.yunhai.agent.tool.ToolExecutor;
import com.yunhai.agent.tool.ToolParam;
import com.yunhai.agent.tool.ToolRegistry;
import com.yunhai.agent.tool.ToolResult;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 智能体编排核心(ReAct 风格的工具调用循环):
*
*
* 用户输入
* └─► 组装 system + 历史 + 本轮输入
* └─► 调大模型(带上「按角色过滤后」的可用工具 schema)
* ├─ 模型返回 tool_calls ─┬─ 写操作缺必填参数 ─► 【回补全表单】给用户填(不再口头追问)
* │ └─ 参数齐全 ─► 逐个执行(转成对该模块的真实 HTTP 调用)
* │ ├─ 角色越权 ─► 服务端直接拒绝(留痕)
* │ └─► 结果回填为 role=tool 消息 ─► 再次调模型
* └─ 模型返回普通文本 ─► 这就是最终回复,结束
*
*
* 表单提交({@link ChatRequest#getFormSubmit()})走另一条短路:直接执行指定工具,
* 不再让模型二次决策,保证「用户填了什么就提交什么」。
*/
@Slf4j
@Service
public class AgentOrchestrator {
private final AgentSettings settings;
private final LlmClient llmClient;
private final ToolRegistry registry;
private final ToolExecutor executor;
private final SessionStore sessionStore;
private final AgentAuditService auditService;
private final IntentShortcut intentShortcut;
@Autowired
public AgentOrchestrator(AgentSettings settings, LlmClient llmClient, ToolRegistry registry,
ToolExecutor executor, SessionStore sessionStore,
AgentAuditService auditService, IntentShortcut intentShortcut) {
this.settings = settings;
this.llmClient = llmClient;
this.registry = registry;
this.executor = executor;
this.sessionStore = sessionStore;
this.auditService = auditService;
this.intentShortcut = intentShortcut;
}
/**
* 执行一轮对话。
*
* @param req 请求(含用户输入、会话 id,或表单提交)
* @param userDesc 用户身份描述(姓名/角色/学校),用于写进 system prompt
* @param caller 调用者身份(透传给业务模块 + 角色过滤/校验)
* @param authorizedModIds 已授权模块号集合(可空 = 不过滤)
* @param sink 阶段事件出口(同步传 NOOP,SSE 传真实推送)
*/
public ChatResponse chat(ChatRequest req, String userDesc, CallerContext caller,
Set authorizedModIds, EventSink sink) {
EventSink out = sink == null ? EventSink.NOOP : sink;
// 浏览器代执行结果回传:续跑(方案 A)
if (req != null && req.getToolResult() != null && !req.getToolResult().isEmpty()) {
return resumeBrowser(req, caller, authorizedModIds, out);
}
// 表单提交:短路直达执行
if (req != null && req.getFormSubmit() != null
&& StringUtils.isNotBlank(req.getFormSubmit().getTool())) {
return submitForm(req, userDesc, caller, authorizedModIds, out);
}
if (req == null || StringUtils.isBlank(req.getMessage())) {
throw new IllegalArgumentException("请输入内容");
}
Set roles = caller.getRoles();
SessionStore.SessionState session = sessionStore.getOrCreate(req.getSessionId(),
caller.userIdAsInt(), caller.schoolIdAsInt(), rolesText(roles));
session.setTitle(abbreviate(req.getMessage()));
ChatResponse resp = new ChatResponse();
resp.setSessionId(session.getSessionId());
// ★ 裸意图直达:「我要请假」「发布校园公告」这类只有意图、没有细节的句子,直接按身份弹表单,
// 不交给大模型决策(模型可能先去查字典、或自行编造参数直接提交,都不合预期)。
ChatResponse direct = directForm(req.getMessage(), caller, authorizedModIds, roles, session, out);
if (direct != null) {
return direct;
}
List messages = session.getMessages();
if (messages.isEmpty()) {
messages.add(ChatMessage.system(buildSystemPrompt(userDesc, authorizedModIds, roles)));
}
messages.add(ChatMessage.user(req.getMessage()));
boolean enableTools = req.getEnableTools() == null || req.getEnableTools();
return runLoop(session, messages, resp, caller, authorizedModIds, roles, enableTools, false, out);
}
/**
* 模型主循环(ReAct):思考 → 需要工具时「挂起等浏览器」或「服务端执行」→ 再思考,直到给出自然语言回复。
*
* ★ 浏览器代执行(方案 A):业务模块的用户态接口只认浏览器 CAS 会话,
* 服务端直调必然 {@code code=302}(README 第 11 节四条证据)。开启 {@code agent.browser-exec.enabled}
* 且本轮工具都能准备成功时,整批挂起下发给前端,用用户自己的登录态执行后回传续跑({@link #resumeBrowser});
* 准备失败(未知工具 / 模块未配置地址 / 缺参 / 越权)则回退服务端直调老路径(行为与历史版本一致)。
*/
private ChatResponse runLoop(SessionStore.SessionState session, List messages,
ChatResponse resp, CallerContext caller, Set authorizedModIds,
Set roles, boolean enableTools, boolean resuming, EventSink out) {
JSONArray tools = enableTools ? registry.toOpenAiTools(authorizedModIds, roles) : null;
int maxRounds = settings.getLoop().getMaxRounds();
int totalPrompt = 0;
int totalCompletion = 0;
String lastContent = null;
for (int round = 1; round <= maxRounds; round++) {
resp.setRounds(round);
String thinking = round == 1
? (resuming ? "正在根据查询结果整理回答…" : "正在理解你的需求…")
: "正在根据结果继续处理…";
out.emit(AgentEvent.of("thinking", thinking));
ChatResult result;
try {
result = llmClient.chat(messages, tools);
} catch (Exception e) {
String msg = e.getMessage() == null ? "大模型调用失败" : e.getMessage();
out.emit(AgentEvent.of("error", msg));
resp.setReply("抱歉,我这边连接智能服务失败了:" + msg);
resp.setWarning(msg);
return resp;
}
totalPrompt += nz(result.getPromptTokens());
totalCompletion += nz(result.getCompletionTokens());
lastContent = StringUtils.isNotBlank(result.getContent()) ? result.getContent() : lastContent;
if (!result.hasToolCalls()) {
// 没有工具调用 → 这一条就是最终回复
String reply = StringUtils.isNotBlank(result.getContent())
? result.getContent()
: "(模型没有返回内容,请换个说法再试一次)";
messages.add(assistantOf(reply, result.getReasoningContent()));
resp.setReply(reply);
finish(resp, session, totalPrompt, totalCompletion, out);
return resp;
}
// 记录 assistant 的 tool_calls(后续必须以 role=tool 逐条应答,否则上下文非法)
// ★ 同时带上 reasoning_content:thinking mode 下带 tools 的请求要求历史 assistant 原样回传该字段,
// 否则下一轮请求会被接口判 400(The reasoning_content ... must be passed back to the API)。
ChatMessage assistantMsg = assistantOf(result.getContent(), result.getReasoningContent());
assistantMsg.setToolCalls(result.getToolCalls());
messages.add(assistantMsg);
// ★ 预检:只要有一个「写操作」缺必填参数,就整体中断并改出表单。
// 放在执行任何工具之前,避免消息历史里出现「有 tool_call 却没有对应 tool 结果」的非法上下文。
ChatResponse pending = interceptMissing(result.getToolCalls(), messages, caller,
session.getSessionId(), out);
if (pending != null) {
pending.setSessionId(session.getSessionId());
pending.setRounds(round);
finish(pending, session, totalPrompt, totalCompletion, out);
return pending;
}
// ★ 浏览器代执行:整批可准备成功 → 挂起(撤销本轮 assistant(tool_calls),等浏览器回传续跑)
if (settings.getBrowserExec().isEnabled()) {
List batch =
tryPrepareBrowserBatch(result.getToolCalls(), caller);
if (batch != null) {
messages.remove(messages.size() - 1);
BrowserToolRequest bt = new BrowserToolRequest();
bt.setCalls(batch);
String titles = batch.get(0).getTitle()
+ (batch.size() > 1 ? " 等 " + batch.size() + " 项查询" : "");
bt.setNotice("「" + titles + "」需要用你的登录态获取,正在通过你的浏览器执行…");
resp.setBrowserTool(bt);
resp.setReply(bt.getNotice());
out.emit(AgentEvent.of("browser_tool", bt.getNotice(), bt));
log.info("浏览器代执行挂起:session={} 工具={}", session.getSessionId(),
batch.stream().map(BrowserToolRequest.BrowserCall::getName)
.collect(java.util.stream.Collectors.joining(",")));
finish(resp, session, totalPrompt, totalCompletion, out);
return resp;
}
}
for (ToolCall call : result.getToolCalls()) {
String toolName = call.getFunction() == null ? null : call.getFunction().getName();
ToolDef def = toolName == null ? null : registry.get(toolName);
Map args = parseArgs(call.getFunction() == null ? null : call.getFunction().getArguments());
if (def == null) {
// 模型幻觉出了不存在的工具:如实告知,让它改正
String err = "不存在的工具:" + toolName;
out.emit(AgentEvent.of("tool_result", "工具不可用:" + toolName));
messages.add(ChatMessage.tool(call.getId(), "{\"success\":false,\"error\":\"" + err + "\"}"));
ToolCallTrace trace = new ToolCallTrace();
trace.setName(String.valueOf(toolName));
trace.setTitle("未知工具");
trace.setSuccess(false);
trace.setError(err);
resp.getToolCalls().add(trace);
continue;
}
out.emit(AgentEvent.of("tool_call", "正在执行:" + def.getTitle(),
payloadOf(def, args)));
ToolResult tr = executor.execute(def, args, caller);
auditService.record(session.getSessionId(), caller, def, args, tr);
messages.add(ChatMessage.tool(call.getId(), tr.getContent()));
ToolCallTrace trace = buildTrace(def, args, tr);
resp.getToolCalls().add(trace);
if (!def.isReadOnly()) {
resp.setWroteSomething(true);
if (StringUtils.isNotBlank(def.getLandingPath())) {
resp.setLandingPath(def.getLandingPath());
}
}
out.emit(AgentEvent.of("tool_result",
def.getTitle() + (tr.isSuccess() ? " 完成" : " 失败:" + tr.getError()),
payloadOf(def, args)));
log.info("工具 {} 执行 {} 耗时 {}ms", def.getName(),
tr.isSuccess() ? "成功" : "失败(" + tr.getError() + ")", tr.getCostMs());
}
// 继续下一轮,让模型基于工具结果生成自然语言回复
}
// 超过最大轮数:把最后一次内容兜底返回,避免"空转"
String reply = StringUtils.isNotBlank(lastContent)
? lastContent
: "这个需求需要多步操作,我已经执行到这里,建议你到对应页面确认一下结果。";
messages.add(new ChatMessage(ChatMessage.ROLE_ASSISTANT, reply));
resp.setReply(reply);
resp.setWarning("已达到单轮最大处理步数");
finish(resp, session, totalPrompt, totalCompletion, out);
return resp;
}
/**
* 尝试把本轮所有工具调用准备成一个「浏览器代执行」批次。
*
* @return 批次;null = 无法整批代执行(存在未知工具 / 模块未配置地址 / 参数不合法 / 越权),
* 调用方回退服务端直调老路径 —— 保证任何情况下消息历史的 tool_call/tool 结果配对完整。
*/
private List tryPrepareBrowserBatch(List calls,
CallerContext caller) {
List batch = new ArrayList<>();
for (ToolCall call : calls) {
String toolName = call.getFunction() == null ? null : call.getFunction().getName();
ToolDef def = registry.get(toolName);
if (def == null || !settings.getModules().containsKey(def.getModule())) {
return null;
}
// 越权的调用不进批次(浏览器白跑一趟没有意义),交回老路径立即拒绝
if (executor.checkAccess(def, caller) != null) {
return null;
}
Map args = parseArgs(call.getFunction() == null ? null : call.getFunction().getArguments());
try {
batch.add(executor.prepareBrowserCall(def, args));
} catch (Exception e) {
log.info("代执行批次准备失败({}),回退服务端执行:{}", def.getName(), e.getMessage());
return null;
}
}
return batch;
}
/**
* 浏览器代执行的续跑:前端已用用户自己的登录态调完模块接口并带回原始响应。
*
* 服务端重新查注册表解析结果(角色校验、{@code code=302} 识别等与服务端执行完全同源,
* 不信任前端回传的任何路径信息),构造合法的 {@code assistant(tool_calls) + tool} 消息
* 喂回模型继续 —— 模型既可以整理出最终回答,也可以发起下一批工具调用(链式查询)。
*/
private ChatResponse resumeBrowser(ChatRequest req, CallerContext caller,
Set authorizedModIds, EventSink out) {
Set roles = caller.getRoles();
SessionStore.SessionState session = sessionStore.getOrCreate(req.getSessionId(),
caller.userIdAsInt(), caller.schoolIdAsInt(), rolesText(roles));
ChatResponse resp = new ChatResponse();
resp.setSessionId(session.getSessionId());
List messages = session.getMessages();
if (messages.isEmpty()) {
resp.setReply("会话已过期,请重新说一次你的需求。");
resp.setWarning("browser-resume:会话没有历史消息(服务重启或已过期)");
finish(resp, session, 0, 0, out);
return resp;
}
String idPrefix = "br_" + System.currentTimeMillis() + "_";
// 注:这条 assistant 是「服务端补造」的(模型当轮的消息在挂起时已撤销),没有真实 reasoning_content。
// thinking mode 关闭时(agent.llm.thinking=disabled,默认)不需要该字段;若改为开启,此处可能被接口判 400。
ChatMessage assistant = new ChatMessage(ChatMessage.ROLE_ASSISTANT, null);
List calls = new ArrayList<>();
List contents = new ArrayList<>();
int idx = 0;
for (ChatRequest.ToolResultItem item : req.getToolResult()) {
Map args = item.getArguments() == null
? new LinkedHashMap<>() : new LinkedHashMap<>(item.getArguments());
ToolDef def = registry.get(item.getName());
ToolResult tr;
if (def == null) {
tr = unknownToolResult(item.getName());
} else {
ToolResult denied = executor.checkAccess(def, caller);
if (denied != null) {
tr = denied;
} else if (StringUtils.isNotBlank(item.getError())) {
tr = ToolResult.fail(def, "浏览器调用未完成:" + item.getError(), 0);
} else {
tr = executor.parseBrowserResponse(def, item.getStatus(), item.getBody(), 0);
}
auditService.record(session.getSessionId(), caller, def, args, tr);
resp.getToolCalls().add(buildTrace(def, args, tr));
if (!def.isReadOnly()) {
resp.setWroteSomething(true);
if (StringUtils.isNotBlank(def.getLandingPath())) {
resp.setLandingPath(def.getLandingPath());
}
}
}
out.emit(AgentEvent.of("tool_result",
(def == null ? item.getName() : def.getTitle())
+ (tr.isSuccess() ? " 完成" : " 失败:" + tr.getError())));
log.info("浏览器代执行回传 {} {} ", item.getName(), tr.isSuccess() ? "成功" : "失败");
ToolCall tc = new ToolCall();
tc.setId(idPrefix + idx);
ToolCall.Function fn = new ToolCall.Function();
fn.setName(def != null ? def.getName() : item.getName());
fn.setArguments(JSON.toJSONString(args));
tc.setFunction(fn);
calls.add(tc);
contents.add(tr.getContent());
idx++;
}
assistant.setToolCalls(calls);
messages.add(assistant);
for (int i = 0; i < contents.size(); i++) {
messages.add(ChatMessage.tool(idPrefix + i, contents.get(i)));
}
return runLoop(session, messages, resp, caller, authorizedModIds, roles, true, true, out);
}
/** 前端回传了未注册的工具名(正常不会发生):构造统一的失败结果回填给模型 */
private ToolResult unknownToolResult(String name) {
ToolResult tr = new ToolResult();
tr.setToolName(name);
tr.setTitle("未知工具");
tr.setSuccess(false);
tr.setError("不存在的工具:" + name);
tr.setContent("{\"success\":false,\"error\":\"不存在的工具:" + name + "\"}");
return tr;
}
/* ==================== 表单:缺参拦截 与 直提交 ==================== */
/**
* 裸意图直达:命中「只有意图、没有细节」的句子(如「我要请假」「发布校园公告」)时,
* 按当前身份直接构建表单返回 —— 请假:学生拿「请假申请」、教职工拿「请假登记(帮学生请假)」;
* 发布公告:只有教职工能直达,学生落回模型(由模型的权限准则如实说明)。
*
* 判据与能力目录 / 模型侧过滤**完全一致**({@code visibleTools}),
* 因此不存在「直达出一个本身份无权使用的表单」的可能;不命中或不适用就返回 {@code null},
* 照常交给大模型处理。
*/
private ChatResponse directForm(String message, CallerContext caller, Set authorizedModIds,
Set roles, SessionStore.SessionState session, EventSink out) {
IntentShortcut.Intent intent = intentShortcut.match(message);
if (intent == null) {
return null;
}
boolean staff = roles != null
&& (roles.contains(AgentRole.TEACHER) || roles.contains(AgentRole.ADMIN));
String toolName = staff ? intent.getStaffTool() : intent.getStudentTool();
ToolDef def = toolName == null ? null : registry.get(toolName);
if (def == null) {
return null;
}
boolean visible = registry.visibleTools(authorizedModIds, roles).stream()
.anyMatch(t -> t.getName().equals(def.getName()));
if (!visible) {
return null;
}
Map empty = new LinkedHashMap<>();
List missing = missingRequired(def, empty);
if (missing.isEmpty()) {
return null;
}
String reply = staff ? intent.getStaffReply() : intent.getStudentReply();
FormRequest form = buildForm(def, empty, missing, caller);
form.setDescription(reply);
ChatResponse resp = new ChatResponse();
resp.setSessionId(session.getSessionId());
resp.setForm(form);
resp.setReply(reply);
out.emit(AgentEvent.of("form", "需要补充信息:" + String.join("、", form.getMissing())));
log.info("意图直达弹表单:session={} 意图={} 身份={} 工具={}", session.getSessionId(),
intent.getName(), staff ? "教职工" : "学生", def.getName());
finish(resp, session, 0, 0, out);
return resp;
}
/**
* 预检本轮的写操作工具:若有必填参数缺失,则撤销刚加入的 assistant 消息并返回一张补全表单。
*
* @return 需要用户补全信息时返回响应(含 form);一切正常返回 null
*/
private ChatResponse interceptMissing(List calls, List messages,
CallerContext caller, String sessionId, EventSink out) {
for (ToolCall call : calls) {
String toolName = call.getFunction() == null ? null : call.getFunction().getName();
ToolDef def = toolName == null ? null : registry.get(toolName);
if (def == null || def.isReadOnly()) {
continue;
}
// 越权的写操作不走表单(表单填了也执行不了),交由执行期统一拒绝并告知
if (settings.isEnforceRolePermission() && !def.allowsAny(caller.getRoles())) {
continue;
}
Map args = parseArgs(call.getFunction() == null ? null : call.getFunction().getArguments());
List missing = missingRequired(def, args);
if (missing.isEmpty()) {
continue;
}
// 撤销本轮的 assistant(tool_calls),保持历史自洽(用户可能不填表、转而说别的)
if (!messages.isEmpty()) {
messages.remove(messages.size() - 1);
}
FormRequest form = buildForm(def, args, missing, caller);
ChatResponse resp = new ChatResponse();
resp.setForm(form);
resp.setReply(askText(def, missing));
out.emit(AgentEvent.of("form", "需要补充信息:" + String.join("、", form.getMissing()),
payloadOf(def, args)));
return resp;
}
return null;
}
/**
* 表单直提交:用户已填好参数 → 直接执行工具 → 再由模型把结果说成自然语言。
*/
private ChatResponse submitForm(ChatRequest req, String userDesc, CallerContext caller,
Set authorizedModIds, EventSink out) {
ChatRequest.FormSubmit fs = req.getFormSubmit();
Set roles = caller.getRoles();
SessionStore.SessionState session = sessionStore.getOrCreate(req.getSessionId(),
caller.userIdAsInt(), caller.schoolIdAsInt(), rolesText(roles));
ChatResponse resp = new ChatResponse();
resp.setSessionId(session.getSessionId());
ToolDef def = registry.get(fs.getTool());
if (def == null) {
resp.setReply("抱歉,这个操作暂时不可用。");
resp.setWarning("未注册的工具:" + fs.getTool());
return resp;
}
// 角色门禁:提交时再校验一次(模型侧过滤可被绕过,这里是硬约束)
if (!def.allowsAny(roles)) {
resp.setReply("抱歉,当前身份无权执行「" + def.getTitle() + "」(该操作仅限:" + def.roleLabel() + ")。");
resp.setWarning("越权操作已被拒绝:" + def.getName());
return resp;
}
Map args = fs.getArgs() == null
? new LinkedHashMap<>() : new LinkedHashMap<>(fs.getArgs());
// 兜底:表单若有漏填(前端校验被绕过),仍然回到表单而不是带着残缺参数去打接口
List missing = missingRequired(def, args);
if (!missing.isEmpty()) {
resp.setForm(buildForm(def, args, missing, caller));
resp.setReply(askText(def, missing));
return resp;
}
List messages = session.getMessages();
if (messages.isEmpty()) {
messages.add(ChatMessage.system(buildSystemPrompt(userDesc, authorizedModIds, roles)));
}
// 把"用户填表提交了什么"写入历史,让后续对话有上下文
messages.add(ChatMessage.user(renderSubmission(def, args)));
out.emit(AgentEvent.of("tool_call", "正在执行:" + def.getTitle(), payloadOf(def, args)));
// ★ 浏览器代执行(方案 A):模块接口只认浏览器会话,服务端提交必然 302。
// 表单提交同样挂起给前端用用户登录态执行,结果回传后由 resumeBrowser 续跑;
// 准备失败(缺参/模块未配置)时回退服务端直调老路径。
if (settings.getBrowserExec().isEnabled()
&& settings.getModules().containsKey(def.getModule())) {
try {
BrowserToolRequest.BrowserCall bc = executor.prepareBrowserCall(def, args);
BrowserToolRequest bt = new BrowserToolRequest();
bt.setCalls(new ArrayList<>(Collections.singletonList(bc)));
bt.setNotice("「" + def.getTitle() + "」需要用你的登录态提交,正在通过你的浏览器执行…");
resp.setBrowserTool(bt);
resp.setReply(bt.getNotice());
out.emit(AgentEvent.of("browser_tool", bt.getNotice(), bt));
log.info("表单提交转浏览器代执行:session={} 工具={}", session.getSessionId(), def.getName());
finish(resp, session, 0, 0, out);
return resp;
} catch (Exception e) {
log.warn("表单代执行准备失败,回退服务端执行:{} - {}", def.getName(), e.toString());
}
}
ToolResult tr = executor.execute(def, args, caller);
auditService.record(session.getSessionId(), caller, def, args, tr);
// 构造合法的 assistant(tool_calls) + tool 结果,模型据此组织语言
String callId = "form_" + System.currentTimeMillis();
ToolCall tc = new ToolCall();
tc.setId(callId);
ToolCall.Function fn = new ToolCall.Function();
fn.setName(def.getName());
fn.setArguments(JSON.toJSONString(args));
tc.setFunction(fn);
// 同上:服务端补造的 assistant(无真实 reasoning_content),thinking 关闭时无需该字段
ChatMessage assistant = new ChatMessage(ChatMessage.ROLE_ASSISTANT, null);
assistant.setToolCalls(Collections.singletonList(tc));
messages.add(assistant);
messages.add(ChatMessage.tool(callId, tr.getContent()));
resp.getToolCalls().add(buildTrace(def, args, tr));
if (!def.isReadOnly()) {
resp.setWroteSomething(true);
if (StringUtils.isNotBlank(def.getLandingPath())) {
resp.setLandingPath(def.getLandingPath());
}
}
out.emit(AgentEvent.of("tool_result",
def.getTitle() + (tr.isSuccess() ? " 完成" : " 失败:" + tr.getError()),
payloadOf(def, args)));
log.info("表单提交执行 {} {} 耗时 {}ms", def.getName(), tr.isSuccess() ? "成功" : "失败", tr.getCostMs());
String reply;
int promptTokens = 0;
int completionTokens = 0;
try {
ChatResult r = llmClient.chat(messages, null);
promptTokens = nz(r.getPromptTokens());
completionTokens = nz(r.getCompletionTokens());
reply = StringUtils.isNotBlank(r.getContent()) ? r.getContent() : fallbackReply(def, tr);
} catch (Exception e) {
log.warn("表单提交后生成回复失败,改用兜底话术:{}", e.toString());
reply = fallbackReply(def, tr);
}
messages.add(new ChatMessage(ChatMessage.ROLE_ASSISTANT, reply));
resp.setReply(reply);
finish(resp, session, promptTokens, completionTokens, out);
return resp;
}
/** 找出缺失的必填参数(fixed 固定参数视为已提供) */
private List missingRequired(ToolDef def, Map args) {
Map effective = new LinkedHashMap<>();
if (def.getFixed() != null) {
effective.putAll(def.getFixed());
}
if (args != null) {
effective.putAll(args);
}
List missing = new ArrayList<>();
for (ToolParam p : def.getParams()) {
if (!p.isRequired() || p.isHidden()) {
continue;
}
Object v = effective.get(p.getName());
if (v == null || (v instanceof String && StringUtils.isBlank((String) v))) {
missing.add(p);
}
}
return missing;
}
/** 由工具定义生成补全表单 */
private FormRequest buildForm(ToolDef def, Map args, List missing,
CallerContext caller) {
FormRequest form = new FormRequest();
form.setTool(def.getName());
form.setTitle(def.getTitle());
form.setModule(def.getModule());
form.setWrite(!def.isReadOnly());
form.setLandingPath(def.getLandingPath());
// 本次构建内的字典缓存:同一字典只请求一次(字段下拉与已识别回显共用)
Map>> dictCache = new LinkedHashMap<>();
List fields = new ArrayList<>();
List missingLabels = new ArrayList<>();
// ★ 表单包含哪些字段:发布类工具(fullForm)渲染**全部字段**,与模块自己的「新建/发布」页一致
// (置顶 / 短信 / 定时发送 / 附件 / 回执方式等可选项也摆出来,否则用户设不了、发出去的数据与模块页不同);
// 其余工具仍只补「缺失的必填项」。
List shown = new ArrayList<>();
if (def.isFormAll()) {
for (ToolParam p : def.getParams()) {
if (!p.isHidden()) {
shown.add(p);
}
}
} else {
shown.addAll(missing);
}
for (ToolParam p : shown) {
FormField f = p.toFormField();
// 附件控件:补上传地址(模块的附件上传只认浏览器 CAS 会话,必须前端带登录态 POST)。
// 缺省按 「/<模块>/api/base/FileUpload」,个别模块不同(作业是 /api/file/Upload)时由参数覆盖。
if ("file".equals(f.getWidget())) {
f.setUploadPath(StringUtils.isNotBlank(p.getUploadPathOverride())
? p.getUploadPathOverride()
: "/" + def.getModule() + "/api/base/FileUpload");
}
// 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。
//
// ★ 为什么不在这里直接执行字典工具:业务模块的用户态接口只认浏览器 CAS 会话,
// 服务端直调必然 code=302(README 第 11 节)。以前"服务端取一次、失败就退化成
// 数字输入框",结果教师登记请假时得手填 classId/stuId 编号——根本没法用。
// 现在把「去哪儿取」以 optionCall 下发(依赖字段留 {{占位符}}),
// 前端在学生选完班级后自己拉名单,下拉框才是真的下拉框。
BrowserToolRequest.BrowserCall optionCall = buildOptionCall(p, caller);
if (optionCall != null) {
f.setOptionCall(optionCall);
// ★ 多值控件必须保留原样:multiselect(接收班级/宿舍)、scopes(公告推送范围)、
// flow(公告审批流)都是「选项从字典代取 + 用户多选」,降级成单选下拉会直接丢掉多选能力。
String w = f.getWidget();
if (!"multiselect".equals(w) && !"scopes".equals(w) && !"flow".equals(w)) {
f.setWidget("select");
}
// 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖
f.setOptionsFallback(p.isUsingFallbackOptions());
} else if (StringUtils.isNotBlank(p.getOptionsFrom())) {
// 没有代取通道(模块未配地址 / 越权)→ 退回服务端取一次,取不到就保留原控件让用户手填编号
List